Commit Graph
273 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 4d0aa7ab9e feat: Clerk-unabhängiges Empfehlungsprogramm mit einstellbaren Rängen (Web + Admin)
Persönliche, unbegrenzt nutzbare Empfehlungscodes (z. B. BUBI-7K3M), die erst
nach der Registrierung im Onboarding eingelöst werden — keine Kopplung an den
Clerk-Sign-up-Ablauf mehr. Registrierung ohne Code bleibt möglich.

- Schema (rein additiv): Owner-Felder für Code, Einladungsstatus, Zähler und
  Rang-Cache, ReferralRank / ReferralRankAchievement / ReferralSettings,
  AcquisitionSource, NotificationType REFERRAL_JOINED / REFERRAL_RANK_UP
- Kernlogik src/lib/referrals.ts: Einlösen (7-Tage-Fenster, kein eigener Code,
  keine Kreis-Einladungen, gesperrte Einladende ungültig), gegenseitiges
  Folgen, Qualifizierung erst nach Tier + erstem Beitrag (Foto oder fertiges
  Video), Tageslimit mit Prüf-Markierung, Ränge mit Bestandsschutz
- Web: /einladen, öffentliche Seite /r/[code], Onboarding-Schritt
  "Wie hast du von PawFeed erfahren?", Rang-Abzeichen, Profilring,
  "Eingeladen von …", neue Benachrichtigungen
- Admin /p/[secret]/referrals: Ränge anlegen/bearbeiten/deaktivieren mit
  Live-Vorschau, Einstellungen, Übersicht mit Prüfung und Aberkennung;
  bevorzugte Verifizierung als Vorteil
- prisma/scripts/referral-setup.ts: Start-Ränge, Settings, Code-Backfill,
  optional --credit-existing für Alt-Einladungen

Noch offen: Mobile-App, Abbau des alten InviteCode-Ablaufs, Rechtstexte,
db push + Deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 06:25:13 +02:00
adminandClaude Opus 5.5 71ac36b08a feat(landing): self-host Inter font and promote Android app on Google Play
- Replace next/font/google with next/font/local + vendored Inter variable
  woff2 (latin, @fontsource-variable/inter@5.3.0), so neither visitors nor
  the Docker build contact Google's servers.
- Add PlayStoreBadge (official Google Play artwork, DE/EN by locale, served
  locally from /public/badges) to the hero, a new "PawFeed in your pocket"
  section, and the shared landing footer (also covers /ueber-uns).
- Centralize the Play Store URL in src/lib/app-links.ts (derived from the
  android.package id org.pawfeed.app).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:54:14 +02:00
adminandClaude Sonnet 5 aa75fe612b fix(stories): video picker not showing videos on story creation
The story upload input's accept attribute listed explicit image+video
MIME subtypes (image/jpeg,...,video/mp4,...). Several mobile
browsers'/OS's native 'Photos & Videos' pickers only reliably offer
both media kinds when given the broad image/*,video/* wildcard
categories - the explicit mixed list was silently narrowing the picker
to photos only, matching the user's report ('nur Zugriff auf Fotos,
Videos werden gar nicht angezeigt').

The actual accept/reject decision is unchanged (still enforced in
handleFile() against the specific ALLOWED_IMAGE_TYPES/
ALLOWED_VIDEO_TYPES lists) - this only relaxes what the OS offers in
the picker dialog.

Code-reviewed before commit (WORKFLOW.md step 6) - fixed the one
finding that became newly reachable by this fix: videos were
previously never selectable at all, so a parameterized/codec-suffixed
MIME string (e.g.  from some Android camera-roll
pickers) could never surface as a rejection; now strips codec
parameters before the ALLOWED_VIDEO_TYPES comparison. Documented two
other findings as accepted, not-fixed-here trade-offs in code comments
(iOS HEIC auto-transcode interaction, broader picker surfacing formats
that still get rejected post-selection) rather than silently ignoring
them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 19:08:07 +02:00
adminandClaude Sonnet 5 ea3cf1dcd4 fix(admin): music-track upload failing on valid audio files
Two independent root causes behind 'admin music upload fails':

1. Supabase bucket name mismatch — code expects 'music-tracks' (plural,
   MUSIC_STORAGE_BUCKET), but the bucket was created as 'music-track'
   (singular). Every upload failed with a bucket-not-found error from
   Supabase Storage, surfaced only as a generic toast. Fixed by creating
   the correctly-named bucket (public, 5MB limit, audio/mpeg|mp4|aac
   allowed_mime_types matching the app's own constants) via the Supabase
   Storage API; verified end-to-end with a signed-upload-url + PUT probe.
   The old empty 'music-track' bucket is unused but still exists -
   deleting it needs the user's own action (blocked here by the
   auto-mode classifier's cloud-storage-delete guard).

2. Client-side validation trusted the browser-reported File.type, which
   is sniffed from the OS's MIME registry and is inconsistent for
   .m4a/.aac (e.g. Windows Chrome reports .m4a as audio/x-m4a, not
   audio/mp4) - a perfectly valid file could get rejected as 'wrong
   type'. Now derives the content-type from the file extension instead.

Code-reviewed before commit (WORKFLOW.md step 6) - fixed two of four
findings: the extension lookup now uses a Map instead of a plain object
(prototype-shadowing via a file named e.g. 'x.constructor' previously
bypassed the rejection), and requires an actual '.' before the
extension (a bare filename like 'mp3' no longer matches). Extracted the
allowed-types/size constants into a new client-safe src/lib/music-
content-types.ts (supabase-storage.ts re-exports them) so the client
form and server-side Zod schema share one source of truth instead of
two independently maintained copies. Left the trust-the-extension
trade-off as-is (documented in code): this panel sits behind
ADMIN_SECRET + a role check, not the public upload surface, and the
old File.type check was never real content-based validation either.

Full test suite green (one unrelated legal.test.ts timeout in the
full-suite run was confirmed flaky - passes cleanly in isolation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 18:49:51 +02:00
adminandClaude Sonnet 5 e9bad092f2 feat: pet-suggestion notifications, story music overlay, auth UX fixes
Three pieces of work from this session:

- Pet-suggestion notifications: a weekly cron (species/breed affinity
  heuristic) suggests pets to follow, with an owner opt-out and web/mobile
  notification UI wiring.
- Story music overlay: admin-curated royalty-free track catalog
  (/p/[secret]/music), attachable to stories on web (StoryForm/StoryViewer),
  with mute-original support for video stories. Mobile UI is deferred to
  the next native build (needs an audio-playback module) — tracked in
  mobile/PENDING-NATIVE-BUILD.md.
- Auth UX fixes: sign-in/sign-up now use KeyboardAvoidingView + ScrollView
  and a shared PasswordField with a show/hide toggle (mobile). Web's
  ActivePetContext gained clearGactivePetId(), wired to sign-out via a new
  SignOutCleanup component, fixing a false "you do not have permission to
  access this pet" error after switching accounts on the same browser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 21:02:49 +02:00
admin 4758ce8d87 feat: add Child Safety Standards page for Google Play compliance
New public page at /kinderschutz — required by Google Play's Child
Safety Standards policy for apps with user-generated content/social
features (https://support.google.com/googleplay/android-developer/answer/14747720).
Registered as public + maintenance-exempt in proxy.ts, matching the
existing /impressum, /datenschutz, /nutzungsbedingungen pattern.

Content covers only mechanisms that actually exist in this codebase:
in-app reporting (ReportSheet + admin moderation review), a named
responsible contact (same identity as /impressum), and the German/EU
legal framework (StGB §184b/c, DSA, JuSchG) for law-enforcement
cooperation on confirmed CSAE content — no invented automated-
detection claims.

Cross-linked from the other legal pages' footers, the web /pets
footer, and the mobile app's My Pets legal-links section (new
pets.legalChildSafety i18n key, DE/EN).
2026-09-07 18:57:49 +02:00
admin 462f295caa feat(mobile): add German/English i18n across the whole app
- i18next + react-i18next + expo-localization: device-locale detection,
  an MMKV-persisted DE/EN/Automatisch override (mirrors web's cookie-only
  locale, no backend field needed), and a language switcher on the Pets
  tab next to the theme toggle
- every screen and shared component converted from hardcoded German
  strings to translation keys; de.json/en.json kept in exact key-parity
  (218 keys each, verified programmatically)
- backend: src/trpc/init.ts now accepts an x-locale header as a fallback
  locale source (cookie still wins, web unaffected) since mobile has no
  cookie jar to set NEXT_LOCALE in; mobile's tRPC client sends the
  current language on every request
- fixes a latent bug: species/breed pickers were hardcoded to locale
  "de" while every other pet query resolved names via ctx.locale's
  default (English, since mobile never sent a locale cookie) — both now
  use the same current-language value
- expo-localization is a new native module (config plugin) and needs a
  native rebuild to actually link; deviceLocale() falls back to English
  instead of crashing when it isn't linked yet, so the app stays usable
  before that rebuild happens

Needs a NAS deploy (src/trpc/init.ts is shared backend code) before the
x-locale fallback and the species/breed locale fix take effect for mobile.
2026-09-06 17:31:41 +02:00
adminandClaude Sonnet 5 0323e8d449 feat: add Expo push notification infrastructure
Owner.expoPushToken + owner.registerPushToken/clearPushToken, plus a
best-effort Expo push send wired into notify() alongside the existing
in-app Notification row. Missing/expired token or unreachable Expo push
service silently no-ops — push delivery must never break the underlying
action it's attached to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 09:49:02 +02:00
admin c225c9aef7 fix(admin): mount Clerk client SDK on admin panel to stop UNAUTHORIZED
The admin panel (src/app/p/[secret]/layout.tsx) never wrapped its tree
in ClerkThemeProvider, unlike (app)/layout.tsx — a SpeedUp-Plan P0
optimization that deliberately kept Clerk's client bundle off routes
without a <UserButton/>. Consequence: no client-side session-token
refresh ran on admin pages. The initial page load stayed authenticated
(Clerk's server-side handshake reissues the token on full document
navigations), but subsequent tRPC XHRs — e.g. legal.publish — started
failing with UNAUTHORIZED/401 a few minutes in, since that handshake
only fires for navigations, not fetch calls. Confirmed live: a fresh
page reload always restored a working session, matching this exactly.

Mounting ClerkThemeProvider in the admin layout re-adds background
token refresh. Also gives the legal-publish error handler an
actionable message for UNAUTHORIZED specifically (distinct from other
failures), since a residual case can still occur after a real 7-day
session expiry.
2026-09-04 17:56:43 +02:00
admin e9378d9d7a feat(health): require explicit, revocable consent for Health-Tracking
Add a per-pet consent gate (Art. 9 Abs. 2 lit. a DSGVO) in front of the
whole Health-Tracking area (weight, vet visits, vaccines, emergency
vet, feeding, Health Card): an un-pre-checked checkbox must be
confirmed before any of it is used, enforced server-side in
health.ts (not just the UI) via a new assertHealthConsent check on
every data-bearing endpoint.

Revoking consent (health.revokeConsent) deletes every health record
for that pet in one transaction and is available at any time from the
Health page. Pet gains healthConsentGivenAt/healthConsentRevokedAt
(nullable, additive — db push already applied against the shared
Supabase instance).

Extends the Datenschutzerklärung (Abschnitt 3) with the Art. 9 legal
basis and the revoke/auto-delete mechanism, per counsel's follow-up.
2026-09-04 16:10:14 +02:00
admin 122a5ba73b docs(legal): refine pet-data personal-data disclaimer per counsel wording
Narrow the Art. 4 No. 1 GDPR non-personal-data claim to data that
permits no inference about the owner as a natural person, and call
out the case where it can (e.g. pet photos alongside a profile
picture) as personal data after all.
2026-09-04 15:50:42 +02:00
admin ef2f67a353 docs(legal): address counsel review of Datenschutzerklärung
- clarify pet-only data (breed, weight, health records) is not
  personal data under Art. 4 No. 1 GDPR since GDPR protects only
  natural persons; still handled confidentially per policy
- switch Clerk/Cloudflare/Mux/Stripe from SCC framing to EU-U.S. DPF
  certification (Art. 45 adequacy decision), strengthen the residual
  SCC/Schrems-II fallback clause beyond TLS + access restrictions
- document 30-day retention and rationale for technical error reports
2026-09-04 15:40:20 +02:00
admin 922c2c1aec docs: correct script-src comment — strict-dynamic ignores the added host
Verified live that adding Clerk's frontend API host to script-src does
NOT resolve the CSP violation in modern browsers: with 'strict-dynamic'
present, CSP3 ignores host-based entries in that directive entirely
(Chrome's own violation message confirms this). The console noise is a
Clerk SDK-side loading quirk, not something fixable here without
dropping 'strict-dynamic'. Keeping the host entry only as the same
spec-mandated pre-CSP3 fallback the existing 'unsafe-inline' entry
already relies on, with the comment corrected to say so.
2026-09-01 20:58:07 +02:00
admin 99d9e5813e fix: allow Clerk's frontend API host in CSP script-src
Clerk's clerkMiddleware auto-adds its frontend API host to connect-src
but not script-src, so the initial (unversioned) clerk.browser.js/
ui.browser.js load was blocked by strict-dynamic before the 307 redirect
to the versioned URL — two CSP violations reported to /api/csp-report on
every page that renders Clerk UI, dragging the Best Practices Lighthouse
score down. Derives the host from the publishable key (same approach as
the existing Supabase/GlitchTip hostname helpers) instead of hardcoding it.
2026-09-01 20:23:24 +02:00
admin f56d1c7cb1 docs: document Clerk German localization, mark Capacitor mobile plan superseded
- README: note @clerk/localizations' deDE usage in the tech stack table
  and shipped-features list.
- ClerkThemeProvider: comment explaining why deDE is needed (Clerk's own
  UI defaults to English regardless of the app's next-intl locale).
- mobil.md: mark the Capacitor plan superseded by the React Native/Expo
  scaffold decided this session (see mobile/README.md).
2026-09-01 20:18:49 +02:00
admin 21e7f29acb feat: localize Clerk auth UI to German via @clerk/localizations
The sign-in/sign-up UI rendered in English despite the rest of the app
being German — @clerk/localizations ships an official deDE locale that
plugs directly into ClerkProvider's existing appearance config.
2026-09-01 19:53:55 +02:00
admin 641a58a1b5 feat(admin): add button legend to Posts page
The post-card action row (comments, hide/unhide, pin/unpin, AI-disclosure
correction, delete) had no explanation of what each icon does — add a help
popover next to the "Posts" heading listing icon + action name.
2026-08-25 19:17:39 +02:00
admin 06a25b4e28 feat(admin): link attention stat tiles to their queue pages
Open reports and Verifications pending summarized a number but gave no way to
act on it — link each tile straight to /reports and /verification, with
hover/focus states and a chevron affordance so it reads as navigation, not
just a number.
2026-08-25 19:14:43 +02:00
admin 2f7cacfc82 feat(admin): color-code dashboard Overview by category, unify depth system
The Overview card was a flat, uncategorized list of 10 stats with no visual
grouping. Split it into three labeled, color-identified groups so a moderator
can scan by kind of number instead of reading every label:

- Activity (emerald): owners, pets, posts, DAU, MAU
- Violations (rose): banned users, shadowbanned, blacklist entries — values
  only pick up the color when actually > 0, same "act on this" convention as
  the existing AttentionStat tiles
- Info (sky): active ads, verifications closed

Also switched the maintenance banner and AttentionStat tiles from border to
ring-1, matching the shared Card component's depth convention so the whole
dashboard uses one visual vocabulary instead of two.
2026-08-25 19:09:34 +02:00
admin f12f4edc69 feat(admin): fix admin shell theme tokens, declutter dashboard, add active-page nav highlight
Admin shell used hardcoded zinc-* classes instead of semantic design tokens and
a redundant "PawFeed Admin" kicker label. Dashboard was a wall of 12 identically
weighted stat cards with no hierarchy. Sidebar/mobile nav gave no indication of
the current page.

- layout.tsx/AdminMobileNav.tsx/GranularityToggle.tsx/TimeSeriesChart.tsx: replace
  zinc-* with bg-background/text-foreground/border-border etc., force dark via
  the `dark` class so the admin surface stays permanently dark regardless of
  site-wide theme toggle
- New AdminSidebarNav.tsx: highlights the current page (desktop sidebar)
- AdminMobileNav.tsx: same active-state logic for the mobile drawer, topbar
  shows the current page label instead of a static title
- page.tsx: replace the 12-card stat grid with 2 AttentionStat tiles (reports,
  pending verifications — color means "act on this") plus a flat Overview list,
  and convert the 5 chart containers to the shared Card component
2026-08-25 18:52:08 +02:00
admin 2cb54d4ed4 feat(design): rebuild landing page, header, and legal pages against new DESIGN.md
Introduces PRODUCT.md/DESIGN.md (Impeccable skill) capturing PawFeed's
existing brand identity ("The Spotlight Porch") as durable reference, then
applies it:

- Landing hero: restrained "spotlight" glow instead of a full orange
  gradient panel, tagline as the actual headline with a marigold accent on
  "Stars"/"stars", real PawPrint mark fixed (was a mismatched hand-drawn
  shape, also fixed a viewBox overflow clipping bug).
- Features section: one dominant mechanism (pet-as-identity) plus a quiet
  supporting list, replacing the four-identical-cards grid craft-floor
  refuses.
- Header: dropped the generic glass-blur sticky nav for a flat header with
  a soft marigold gradient hairline; CTA button now matches DESIGN.md
  (rounded-lg, no shadow, same orange as the hero CTA); logo gets a
  paw-spring hover as a small on-brand moment; new active-page indicator
  (LandingNavLinks) under the current nav link.
- Legal pages (Impressum/Datenschutz/Nutzungsbedingungen): replaced the
  stacked numbered-card-per-section layout with a flowing typographic
  document (hairline dividers, no boxes, no arbitrary section-number
  badges) via the shared LegalLayout/LegalSection components.
- Nutzungsbedingungen: moved Sponsoring earlier (now 3a, right after
  Nutzerinhalte & Urheberrecht) while keeping Salvatorische Klausel/Kontakt
  last; existing section numbers left untouched to avoid breaking the
  "gemäß Abschnitt 5" cross-reference in Haftungsausschluss.
2026-08-25 18:22:58 +02:00
admin 6e9e08be70 feat(insights): make spotlight post/story cards clickable
Top-post and top-story cards in the pet insights dashboard now open the
real post (PostDetailDialog, same fetch-by-id pattern as the notifications
page) or story (StoryViewer, new initialStoryId prop to jump straight to
the right story instead of always starting at index 0).
2026-08-24 18:57:33 +02:00
admin 45448573ea feat(insights): add owner-facing post/story analytics (Gitea #30)
New insights router aggregates the reactionCount/commentCount counters
(Post pre-existing, Story added in #29) into a small per-pet dashboard —
total paws/comments received and which post/story is resonating most.
Reachable from the owner's own pet profile ("Meine Tiere"), owner-only.
2026-08-24 18:39:03 +02:00
admin 05c9abfb7a feat(stories): add paw reactions and comments (Gitea #29)
Story reactions/comments mirror the existing Post/Ad engagement models
(StoryReaction/StoryComment tables, denormalized counters on Story) with
a comment overlay rendered on top of the still-playing story rather than
pausing it. No notification wiring yet, matching the Advertisement
engagement model's precedent.
2026-08-24 18:28:19 +02:00
admin 75377c4711 fix(follows): fix 404 on followers/following pages, add visibility setting
pets.byId is owner-only (throws FORBIDDEN for pets you don't own), but
followers/page.tsx and following/page.tsx used it just to grab the
pet's name for the heading/empty-state — any visitor viewing another
pet's followers or following list got a 404 (Gitea #33). Swapped to
pets.getProfile, the same public-safe procedure the actual profile
page already uses.

Also adds the requested privacy control: Pet.followerVisibility
(EVERYONE/FOLLOWERS_ONLY/NOBODY, default EVERYONE) gates
follows.listFollowers/listFollowing via a new assertCanViewFollowerLists
helper — FOLLOWERS_ONLY checks whether any pet the caller owns follows
the target pet; the owner can always see their own. Both pages now
render a "private" message instead of erroring when blocked. Editable
from the pet edit page's Privacy section, next to the existing DM
policy control.
2026-08-23 17:10:06 +02:00
admin 8b74ca5763 fix(admin): make users page responsive on mobile
Search input (w-64) + two status/role selects (w-36) sat in one
unwrappable row, and each user row's shrink-0 action-button cluster
(up to 6 buttons) never yielded width to the info block — both
squeezed everything together below md:. Filter bar now stacks
(search full-width, filters side by side below it), and user rows
stack info above actions on mobile, row layout from sm: up.
2026-08-23 16:05:50 +02:00
admin f7049569e1 fix(admin): make admin panel layout responsive on mobile
The permanent 224px sidebar in the admin layout had no responsive
handling at all, leaving almost no width for content on a phone
(Gitea #10). Sidebar is now hidden below md: and replaced by a
topbar + slide-in Sheet drawer (AdminMobileNav) with the same nav
links; content padding and the outer flex direction adapt per
breakpoint.
2026-08-23 16:00:31 +02:00
admin d9930cba60 fix(security): enforce blocks on interaction, close remaining review findings
- New assertNotBlocked() helper (src/lib/assert-not-blocked.ts), mirroring
  assertPetOwnership. comments.create, reactions.toggle (create path), and
  reposts.create now reject direct interaction between blocked pets, not
  just feed visibility (product decision: enforce, matching messages.ts's
  existing DM block check).
- videos.updateCaption and stories.getViewers now route through the shared
  assertPetOwnership helper instead of duplicating the inline ownership
  check.
- ads.ts mutations (recordView, toggleReaction, addComment, toggleRepost)
  gain the same rate limiting their post-side equivalents already have.
- follows.listFollowers/listFollowing/getPetsWithActiveStories now apply
  getSpiderExclusionId(), matching the established per-query pattern in
  feed.ts/explore.ts/search.ts.

Remaining findings from the review are addressed:
- timingSafeStringEqual's length short-circuit needs no fix (matches
  Node's own crypto.timingSafeEqual behavior).
2026-08-23 14:50:20 +02:00
admin ddf3c2c4e6 fix(security): close GDPR-export/health-card token guessing and video IDOR
- GDPR export token (admin.exportUserData) and health-card token
  (health.getOrCreateCard) now get an explicit crypto-random token
  instead of falling back to Prisma's cuid() default, which is
  timestamp/counter-derived and not meant for unguessable secrets.
- Add IP-based rate limiting to both public token endpoints
  (/api/gdpr-export/[token], /health-card/[token]) to close the
  brute-force window.
- videos.getByPostId now applies the same hiddenAt/shadowban
  visibility filter posts.getById already uses, closing an IDOR that
  let any authenticated user read Mux data for hidden/shadowbanned
  video posts.

Found via a security-reviewer pass over the tRPC/API layer.
2026-08-23 14:25:25 +02:00
admin 5870d8448a feat(admin): add deleteBroadcast with required reason for the audit trail
Broadcasts had no delete path (Phase 1 gap) — had to remove a test
message via a one-off script yesterday. New admin.deleteBroadcast
mutation mirrors hidePost/banOwner: SUPER_ADMIN-gated (same bar as
createBroadcast), reason required (min 1 char), wraps delete + a
DELETE_BROADCAST ModerationLog entry in one transaction.
BroadcastReceipt rows cascade via the existing onDelete: Cascade.

Frontend: trash icon per history item opens a confirm dialog with a
required reason input, mirroring the posts admin page's delete-confirm
pattern.
2026-08-22 15:48:39 +02:00
admin 39a72ae45a fix(landing): use official Cloudflare badge artwork instead of custom text
Cloudflare's brand guidelines flag a hand-written "Protected by
Cloudflare" text label as misleading — replace with their official
badge PNG (public/badges/cloudflare-protected.png), same fixed-size
<img> pattern already used for AiDisclosureBadge.
2026-08-22 15:22:41 +02:00
admin 8d058b00a1 feat(landing): add Cloudflare + security-headers trust badges to footer
Was implemented and locally verified earlier this session but never
committed/deployed before moving on to the CSP work — closing that gap.
2026-08-22 15:12:45 +02:00
admin 9265aa4dc1 fix(security): switch CSP to nonce + strict-dynamic (drop unsafe script-src sources)
Mozilla Observatory's rescan flagged the enforced CSP as "implemented
unsafely" (-20) for unsafe-inline/https:/http: in script-src. Enable
Clerk's strict mode, which drops http:/https: and adds a per-request
nonce + 'strict-dynamic' instead. Clerk forwards the resulting CSP
header (with nonce) on both the response and the downstream request,
which is exactly what Next.js's automatic nonce application reads
during SSR, so framework scripts and page bundles are covered for
free. next-themes' hand-written FOUC-prevention <script> is not
covered by that automatic mechanism, so RootLayout now reads the
x-nonce header and passes it to ThemeProvider explicitly.

'unsafe-inline' stays in the header text as a spec-mandated fallback
for pre-CSP3 browsers; strict-dynamic-aware browsers ignore it.
'unsafe-eval' is kept for now (see prior commit ddbeee7) — untouched
by this change, separate follow-up once confirmed unnecessary.
2026-08-22 14:39:53 +02:00
admin ab1b58d644 feat(security): capture line/column/sample in CSP violation reports
Add 'report-sample' to script-src and capture lineNumber/columnNumber/
sample in /api/csp-report for both report shapes (Reporting API and
legacy report-uri). Previously only sourceFile was captured, which
wasn't enough to pin down the 2026-08-15 eval violations — live repro
via the avatar-crop flow (react-easy-crop) came back clean, so the
next real occurrence needs better diagnostics to actually find it.
2026-08-22 14:14:27 +02:00
admin ddbeee759b fix(security): enforce Content-Security-Policy instead of report-only
Mozilla Observatory docked 25 points for CSP being report-only. The
report-only period since 2026-08-12 came back clean in GlitchTip except
6 isolated script-src eval hits on 2026-08-15 (likely react-easy-crop
on the avatar-crop flow) that never recurred. Keep 'unsafe-eval' in
script-src defensively so an enforced policy can't repeat the earlier
incident where a blocked required resource crashed ClerkProvider
app-wide.
2026-08-22 13:51:55 +02:00
admin e89ddc4d32 fix(feed): add reaction/comment/repost actions to milestone posts, fix dark-mode contrast
MilestoneCard never rendered PostCard's action row (PawButton,
comment button, PawBackButton) -- reacting/commenting was already
fully supported server-side (reactions.ts/comments.ts don't restrict
by post type), the UI for it was just missing since MILE-02 shipped.

Also widens the prop type from a narrow local MilestoneCardPost to
the existing PostCardPost (PostCard.tsx already passes the full post
object in, the local type just didn't reflect it) -- this both
unlocks reactionCount/commentCount and lets MilestoneCard open the
same PostDetailDialog PostCard uses for commenting, instead of
needing a parallel implementation.

Separately: the card's bg-orange-50/border-orange-200 were hardcoded
light-only Tailwind classes with no dark: variant, so in Dark Mode the
pet name (text-foreground, which flips to a light color) sat on a
background that stayed pale and unreadable -- same failure mode as
the Clerk auth pages fixed earlier. Switched to the opacity-based
bg-orange-500/5 + border-orange-500/20 pattern already used elsewhere
in the app (unterstuetzen page, notifications list), which adapts to
either theme automatically instead of needing a manual dark: override.
2026-08-21 22:44:26 +02:00
admin fa3f5faa58 fix(i18n): translate milestone type labels
MILESTONE_META in src/lib/milestone-meta.ts held hardcoded English
labels ("Birthday", "Vet Visit", ...) consumed directly by
MilestoneCard and MilestoneForm -- bypassing next-intl entirely, so no
de.json entry could ever have translated them (there was no key to
translate, not a missing one).

Adds a MilestoneTypes namespace to both message catalogs and switches
the two user-facing consumers to useTranslations("MilestoneTypes")
for the label, keeping MILESTONE_META only for the icon lookup. The
admin posts page (p/[secret]/posts) still reads MILESTONE_META.label
directly and is left as-is -- the admin panel is English-only by
existing project convention, not part of the localized surface.
2026-08-21 22:33:03 +02:00
admin c1951b29b9 fix(auth): remove hardcoded light-mode Clerk appearance override on log-in/sign-up
SignIn/SignUp passed their own appearance.variables.colorBackground
("#FAFAFA", always light) and colorPrimary, which Clerk merges on top
of ClerkThemeProvider's appearance -- so in Dark Mode the provider's
baseTheme: dark (light-on-dark default text colors) stayed active
while colorBackground got forced back to light by the page-level
override. Light text on a light background made the whole form
unreadable.

Both page-level appearance props now only set elements.formButtonPrimary
(the orange CTA, theme-independent) -- colorPrimary/colorBackground are
already handled correctly, theme-aware, by ClerkThemeProvider.
2026-08-21 22:24:09 +02:00
admin 3d8c473116 refactor(ui): extract shared PetIdentityBadges component
The verified-checkmark + sponsor-sparkle JSX block was copy-pasted
across 6 near-identical spots (PostCard, pets/[petId] profile,
Sidebar x2, MobileNav x2, pets/[petId]/edit) with inconsistent icon
sizing (h-3 / h-3.5 / h-4) -- part of the same Gitea #31 DRY audit as
the pet-repository select consolidation.

Adds src/components/ui/pet-identity-badges.tsx (same role as the
existing pet-avatar.tsx: a small, domain-specific, reusable
presentational component) and migrates all 5 display sites to it. The
sponsor prop is optional -- Sidebar/MobileNav/edit page simply omit it
rather than faking a sponsor variant. isActiveSponsor() from
lib/sponsor.ts is unchanged, only reused instead of duplicated.

Deliberately NOT touched: the admin verify-toggle button in
p/[secret]/users/page.tsx -- an interactive control with its own
click/fill logic, not a passive display badge; forcing it into this
component would be a forced abstraction, not real DRY.

Pure refactor, no visual change -- verified via tsc, vitest, and a
production build.
2026-08-21 22:07:53 +02:00
admin e0c887c71c refactor(trpc): centralize pet-identity select fragments in a repository layer
The Prisma select fragment { id, name, nickname, avatarKey } (and its
isVerified + owner-sponsor extension) was copy-pasted ~38x across 13
router files -- no repository/query layer existed to consolidate it
(Gitea #31 DRY audit).

Adds src/repositories/pet-repository.ts exporting petIdentitySelect and
petCardSelect (composed via spread) plus their inferred Prisma payload
types, and migrates every call site to import and reuse them -- either
directly or composed with the few extra fields a given query needs
(e.g. ownerId, bio, isVerified where the base fragment doesn't cover
it). A handful of structurally different, smaller field selections
(e.g. name+nickname only, no id) were deliberately left as-is rather
than force-fit into the shared shape.

Pure refactor, no behavior change -- verified via tsc, vitest, and a
production build.
2026-08-21 22:01:57 +02:00
admin 1f177c5e88 perf(landing): inline render-blocking CSS, fix LCP image fetchpriority
Two follow-ups from a fresh PageSpeed Insights run (mobile 87/100/100/100):

1. next.config.ts: enable experimental.inlineCss. Removes a ~342ms
   render-blocking CSS request (globals.css, ~21.6 KiB gzip -- shared by
   every route since Tailwind bundles the whole app's utilities into one
   file). Officially documented, dependency-free, and explicitly
   recommended for small atomic-CSS bundles like ours. Applies globally
   (can't be scoped per route), so hard/full page loads lose cross-page
   CSS caching -- soft client-side navigations are unaffected.

2. page.tsx hero image: `priority` was deprecated in Next.js 16 in favor
   of `fetchPriority`/`loading` as separate props -- it still emitted a
   <link rel=preload> but no longer set fetchpriority="high" on the <img>
   itself. Replaced with loading="eager" fetchPriority="high" per the
   bundled Next.js 16 docs.

SpeedUp-Plan, Gitea #28.
2026-08-21 06:43:22 +02:00
admin 167b9e1a80 feat(seo): add llms.txt, fix middleware blocking .txt static files
Adds public/llms.txt (llms.txt spec: H1 + summary + linked sections)
describing the product for AI agents/crawlers -- usage-focused only, no
internal vendor/infra details.

Also fixes proxy.ts's middleware matcher, which excludes common static
file extensions from Clerk's auth gate but was missing .txt -- every
request to /llms.txt (and any future .txt file, e.g. robots.txt) was
being redirected to the Clerk sign-in page instead of served. No app
route under src/app uses a .txt path, so this only affects genuinely
static files in public/.

SpeedUp-Plan (Gitea #28) -- last remaining Lighthouse finding.
2026-08-20 20:43:25 +02:00
admin bf9bb34f96 perf(landing): replace low-res hero mockup PNG with a retina WebP
The old public/app-preview-mobile.png was exactly 327x714px -- 1:1 with
its CSS display size, so retina displays (2x/3x DPR) had no headroom and
Lighthouse flagged it as "serves images with low resolution"
(image-size-responsive, SpeedUp-Plan P2).

Replaced with a fresh feed screenshot supplied by the user, center-cropped
to the same 327:714 aspect ratio and delivered at 981x2142 (3x) as WebP
(~193 KB) so next/image's built-in optimizer has real headroom to generate
a proper srcset instead of upscaling a 1x source.
2026-08-20 20:33:33 +02:00
admin 556aac7e22 fix(theme): guard theme-icon rendering against SSR/client hydration mismatch
resolvedTheme from next-themes is undefined during SSR and on the client's
first paint (before its mount effect runs), so ThemeToggleButton and the
shared useThemeToggle hook (Sidebar, MobileNav) rendered a Sun/Moon icon
that could mismatch the server-rendered one on first hydration whenever
the system/stored theme was dark -- a React #418 hydration error.

Both now gate isDark on a mounted flag, matching next-themes' documented
hydration-safe pattern.

Found via a Chrome DevTools Lighthouse re-check on the SpeedUp-Plan work
(Gitea #28) -- pre-existing, unrelated to the Clerk-provider scoping.
2026-08-20 20:11:55 +02:00
admin 383817916d perf(landing): scope Clerk provider + fix contrast/landmark on marketing page
Moves ClerkThemeProvider from the root layout into (app)/layout.tsx and
(auth)/layout.tsx — it was previously mounted globally, shipping the
~240 KiB Clerk client SDK to every route including the public marketing
page. Only Sidebar's UserButton and the (auth) sign-in/sign-up forms
actually need client-side Clerk context; server-side auth() calls are
unaffected since they read from proxy.ts's clerkMiddleware, not React
context.

Also fixes two WCAG AA contrast failures on the hero/header CTAs
(orange-500/600 -> orange-700, ~2.8:1/3.6:1 -> ~5.5:1) and adds a
missing <main> landmark around the landing page content.

Source: PageSpeed Insights audit, mobile Performance 70 -> targets >90.
2026-08-20 19:00:35 +02:00
admin bc731ac434 fix(pets): stop active-pet selection reverting on every page reload
ActivePetProvider read localStorage in a useEffect, but
ActivePetInitializer (a descendant, so its effect fires first — React
runs effects child-before-parent) checked activePetId in its own
effect and overwrote localStorage with the owner's first pet whenever
it still saw the initial null — which was always, on every reload,
since the provider's own effect hadn't run yet. The switched-to pet
never had a chance to be read back.

Reads localStorage synchronously via a lazy useState initializer
(window-guarded for SSR) instead, so activePetId is already correct
on the client's first render and the race can't happen.
2026-08-16 17:17:25 +02:00
admin 27cb34df27 fix(stories): portal the story overlay to escape overflow-auto ancestor
Real root cause of the mobile rendering bug (confirmed via an actual
Android Chrome screenshot, not devtools emulation): StoryViewer's
fixed inset-0 div is a descendant of <main className="overflow-auto">
in the (app) layout. On Android Chrome, a position:fixed element
inside an overflow-auto ancestor can size itself against that
ancestor's full scrollable content instead of the real viewport —
the overlay rendered many times taller than the screen, so only a
small top slice was visible, looking like a heavily zoomed crop. The
previous two commits were chasing the video's object-fit/aspect-ratio,
which was never the actual problem.

Renders the overlay via createPortal to document.body instead, the
same pattern Radix/Base UI's Sheet and Dialog already use internally
for exactly this class of bug.
2026-08-16 17:09:50 +02:00
admin 661f36475a fix(stories): restore full-bleed mobile video rendering
The aspect-ratio-sized box from the previous commit caused a
mobile-only bug — video appeared cropped/zoomed instead of fitting.
Root cause not fully isolated (mux-player's object-fit default is
contain, confirmed in its source, so it wasn't that), but the sized
box is the only recent change in that path. Gates it to sm+ viewports
via a CSS custom property + Tailwind arbitrary aspect-ratio, and
reverts to full-bleed + explicit object-fit: contain below that —
the combination already verified working before the box-sizing
change.
2026-08-16 14:54:15 +02:00
admin 07c0f82293 fix(stories): size the video player box to the real aspect ratio
The player box was full-bleed (w-full h-full) with object-fit: contain
handling the letterboxing internally — visually correct but the box
itself still spanned the whole screen, so the black letterbox area
read as part of the player rather than just backdrop. Sizes the box
itself to the story's aspectRatio (defaulting to 9:16 before Mux
reports the real value), centered via flex — same visual result, but
now only the actual content area is the "box".
2026-08-16 14:47:34 +02:00
admin 8186c36b9a feat(stories): support 30s video uploads, mirroring normal video posts
Story gains mediaType (IMAGE/VIDEO) plus the same Mux fields as
VideoPost (muxUploadId/AssetId/PlaybackId, videoStatus, durationSecs,
aspectRatio); storageKey becomes optional since video stories don't
use Supabase Storage.

- 30s duration cap (deliberately shorter than the 180s normal-post
  cap) enforced client-side before upload (reads the file's own
  duration) and again server-side in the mux webhook and
  stories.getVideoStatus's self-heal path, so a bypassed client check
  or a direct API call can't produce an oversized story video.
- MAX_STORY_VIDEO_DURATION_SECS lives in a new client-safe
  src/lib/story-video-limits.ts rather than stories.ts, which is
  server-only — importing from a server-only module into StoryForm
  (a client component) pulled the pg driver into the client bundle
  and broke the build.
- Mux webhook now resolves an asset to either a VideoPost or a Story;
  video stories never fan out to the feed (mirrors the normal-post
  webhook branch, minus fanOutPost).
- StoryViewer: video stories autoplay muted (tap to unmute — browsers
  block autoplay-with-sound past the story that was directly clicked
  open) with a real playback-time progress bar (image stories keep
  the fixed 5s bar), rendered via object-contain to correctly handle
  the ~9:16 portrait ratio typical of phone-shot story videos instead
  of a fixed 16:9 box.
2026-08-16 14:36:44 +02:00