Commit Graph
96 Commits
Author SHA1 Message Date
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 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 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 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 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 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
admin faf8ee98b0 fix(videos): fan out posts that self-heal to READY via polling
getByPostId's self-heal path (client polls while PROCESSING, checks
Mux directly) set status/muxPlaybackId on READY but never called
fanOutPost, unlike the mux webhook handler's video.asset.ready case.
Videos that self-healed this way played fine but never entered any
pet's feed. Found via a live upload test after the replica rollout.
2026-08-16 13:07:31 +02:00
admin 1293f7c879 feat(pets): add optional nickname/Rufname shown alongside the pet's real name
Adds Pet.nickname (nullable, max 30 chars) and a shared formatPetName()
helper so the display format ("Rex (Rexi)") stays consistent across
feed, admin panel, messages, sidebar/nav, and mentions instead of
duplicating the formatting logic at each read site.
2026-08-15 13:25:54 +02:00
admin 1ee2b53a57 feat(species): expand taxonomy to 14 species, add Arachnophobie-Helfer
Add Horse, Rabbit, Guinea Pig, Hamster, Mouse, Rat, Snake, Lizard, Turtle,
Gecko, and Spider as Species (with breed taxonomies + DE translations),
alongside the existing Dog/Cat/Bird. The species/breed system was already
fully data-driven (pet creation, onboarding, ad targeting all read
pets.listSpecies/listBreeds dynamically), so this is mostly seed data.

Spiders get dedicated handling: an opt-in Owner.hideSpiders preference
(offered once during onboarding via ArachnophobiaOptIn, always
re-toggleable on /pets via HideSpidersToggle) fully excludes Species=Spider
pets/posts from feed, explore, and search when enabled.
2026-08-15 11:57:24 +02:00
admin 5660a3a09d feat(admin): system broadcast banner, Phase 1 (Gitea #10)
Admins can send in-app announcements to all owners from /p/[secret]/broadcasts;
owners see them as a dismissible banner and confirm with "Gelesen". Tracks a
per-broadcast BroadcastReceipt (not a single rolling timestamp) so delivered/
read counts are visible per message and a later email-escalation phase can
target exactly the owners who haven't read a given broadcast.
2026-08-15 11:27:11 +02:00
admin 9071bf3c9a feat(about): add "Über uns" page with founding story and live stats (Gitea #23)
New public /ueber-uns page — deliberately doesn't preview or link into the
platform itself (no phone mockup, no post previews), just the founding
story plus live member/pet/sponsor counts for transparency. Fully
bilingual via the existing LanguageSwitcher.

- src/trpc/routers/stats.ts: new public stats.getPublicStats procedure,
  Redis-cached (5min TTL), sponsor count includes lapsed subscriptions
  (not just currently-active badges) per discussion on the issue.
- src/proxy.ts: /ueber-uns registered as a public route, exempt from
  maintenance mode (doesn't touch the platform, so no reason to gate it).
- Extracted LandingHeader/LandingFooter from src/app/page.tsx into shared
  components under src/components/landing/ — this page reuses the same
  navbar, and duplicating it risked the responsive header fixes from
  earlier today drifting out of sync between two copies. The "Bald
  verfügbar" placeholder nav link now points at the real page.
- src/lib/stripe.ts: made the Stripe client a lazy singleton instead of
  constructing it at module scope. Eager construction crashed the entire
  tRPC appRouter (and anything importing it, e.g. createTRPCCaller() on
  any public page) whenever STRIPE_SECRET_KEY is unset — invisible until
  now because no public page had used createTRPCCaller() before.
- stats.getPublicStats wraps its Redis cache read/write in .catch() so a
  Redis outage degrades to uncached rather than breaking the page,
  matching the existing pattern in src/lib/maintenance.ts.

Verified locally via dev server + Chrome DevTools MCP emulation (DE/EN,
mobile 375px, desktop) — this is a public unauthenticated page so, unlike
most of today's other fixes, testable outside of Clerk auth.
2026-08-14 18:51:42 +02:00
admin b3d0551bd8 fix(notifications): deep-link like/comment/mention notifications to the actual post
Notification rows for REACTION/COMMENT/MENTION/PAW_BACK linked to the post
owner's profile page instead of the specific post — there was no way to
land on the post itself. Add posts.getById for direct post lookups and
have the pet profile page pick up a ?post= query param to auto-open the
post detail dialog, falling back to a fetch when the post isn't among the
most recently loaded ones. Follow notifications already linked correctly
to the actor's profile.
2026-08-14 14:36:25 +02:00
adminandClaude Sonnet 5 91b7662419 fix(sponsor): show Verified badge in feed, add sidebar link, mark sponsors in admin
Post-launch fixes from live testing (Gitea #22):
- Feed/explore postInclude never selected Pet.isVerified, so PostCard only
  ever rendered the new Sponsor badge, silently dropping Verified next to
  it. Both now render side by side, matching the pet profile page.
- /unterstuetzen had no navigation entry anywhere -- added to the desktop
  Sidebar (mobile bottom nav is a fixed 5-tab bar, left alone per scope).
- Admin users list showed no sponsor status at all -- SPONSOR badge added
  next to the user_XXXX id, same pattern as the existing BANNED/
  SHADOWBANNED badges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 10:50:35 +02:00
adminandClaude Sonnet 5 c6ffc1336e feat(sponsor): Stripe-based voluntary sponsoring with Sponsor badge (Gitea #22)
Replaces the (never-launched) ad business direction with a freiwilliger
Beitrag/Community-Abo model: owners can support PawFeed via Stripe with
a one-time payment or a monthly SEPA/card subscription (2 EUR minimum),
declared as Sponsoring/Community-Abo, not a tax-deductible donation. The
Sponsor badge (only perk, no feature unlocks) is Owner-scoped and shows
on every post of every one of their pets, plus their profile.

- Schema: Owner.stripeCustomerId/sponsorSince/sponsorPeriodEnd/
  sponsorSubscriptionId/sponsorSubscriptionStatus, new
  SponsorContribution model (payment history)
- src/lib/stripe.ts (singleton client, mux.ts pattern) + src/lib/sponsor.ts
  (isActiveSponsor: one-time givers keep the badge permanently, subscribers
  through sponsorPeriodEnd even after cancelling -- no mid-period cliff)
- src/trpc/routers/sponsor.ts: getStatus, createCheckoutSession (SEPA +
  card for subscriptions, card for one-time, price_data since amounts are
  user-chosen), cancelSubscription (cancel_at_period_end, no hard stop)
- src/app/api/webhooks/stripe: checkout.session.completed, invoice.paid,
  customer.subscription.updated/deleted (mux webhook route pattern)
- src/app/(app)/unterstuetzen: amount chips (3/5/10 EUR) prefilling an
  editable field, one-time/subscription toggle, status + contribution
  history, in-app cancel button (no Stripe-hosted portal, per requirement)
- Badge surfaced in feed/explore postInclude + PostCard, and on the pet
  profile page next to the existing isVerified badge
- Nutzungsbedingungen Sec. 12 (draft, needs sign-off before launch) +
  Stripe added to the Datenschutzerklaerung's Auftragsverarbeiter list
- src/__tests__/sponsor.test.ts (7 tests), Stripe client fully mocked

STRIPE_WEBHOOK_SECRET intentionally not yet configured -- the webhook
endpoint can only be registered in the Stripe dashboard once this is
deployed and reachable at https://pawfeed.org/api/webhooks/stripe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 10:24:08 +02:00
adminandClaude Sonnet 5 f5f788b38c feat(admin): maintenance mode, registration IP capture, post pinning & trending hashtags (Gitea #10 Phase 4)
Three of the five remaining Phase 4 admin-dashboard items from Gitea #10:

- Maintenance mode: Redis-backed global flag, gate in proxy.ts (exempts
  /p/*, /api/*, legal pages, and sign-in/Clerk routes so a locked-out
  admin can always get back in), SUPER_ADMIN-only dashboard toggle,
  fails open on Redis errors.
- Registration IP: Owner.registrationIp captured defensively at every
  owner.upsert create branch (consume-invite, legal.acknowledge,
  legal.acknowledgeUploadConsent, pets.create, owner.setThemePreference)
  since no single onboarding step is a code-guaranteed first touchpoint
  across all invite/legal-doc states. Datenschutzerklärung updated and
  user-approved.
- Trends & Promotion: Post.pinnedAt field, admin.pinPost/unpinPost
  (mirrors the existing hidePost/unhidePost pattern), explore.getTrending
  now surfaces pinned posts first regardless of the 48h window (with
  explicit nulls:"last" ordering -- Postgres sorts NULL first on DESC by
  default), trending-hashtags panel + pin/unpin button on the admin
  posts page.

Password/2FA-reset admin trigger (item 5) was dropped -- Clerk's own
self-service reset already covers it. Broadcast/system messaging (item 2)
stays deferred pending a shared design session.

Reviewed via /code-review; findings addressed: the registration-IP capture
point, Postgres NULL ordering, and the maintenance-mode admin lockout gap
listed above were all fixes applied after that review, not part of the
original draft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 08:45:44 +02:00
adminandClaude Sonnet 5 19f22102df feat(mail): use branded HTML template with personalized greeting (Gitea #21)
GDPR export emails were unstyled HTML with a bare "Hallo," greeting.
Adds a landing-page-branded email shell (orange gradient header, paw
wordmark) and pulls the owner's first name from their Clerk profile
for the salutation, falling back to "Hallo," when unavailable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:13:08 +02:00
admin 50e2848312 feat(legal): AI-content disclosure for user uploads (Gitea #18)
Owners can now optionally mark uploaded content as AI_MODIFIED or
AI_GENERATED (mutually exclusive, defaults to NONE) across every upload
surface — photo posts, video posts, milestones, stories, and pet avatars.
Once set, the choice is owner-immutable; only a moderator can correct it
via a new admin.setAiDisclosure mutation (Posts admin page), which
requires a reason and writes to ModerationLog like every other
moderation action.

The disclosure renders as a UI-only overlay badge (the two provided
label assets) wherever the content is shown — feed, post detail, story
viewer, milestone cards, and the pet profile avatar — never baked into
the image pixels.

New shared components: AiDisclosureSelect (the 3-way picker used in all
five upload forms) and AiDisclosureBadge (the overlay renderer).
2026-08-13 13:44:16 +02:00
admin b4a49f8ed3 fix: address findings from full security/functionality audit (Gitea #17)
Rate-limiting on messages.send, reports.create, reactions.toggle,
follows.create, reposts.create. Unique constraints on Report to stop
report-flooding. Mux webhook now fails hard in production without a
signing secret instead of skipping verification. Admin-panel outline
buttons fixed on 4 more pages (same white-on-dark bug as the Users page).
Timing-safe comparison for ADMIN_SECRET/CRON_SECRET. Fixed duplicate HSTS
header (Cloudflare's stronger one was being shadowed by the app's own).
tRPC now returns clean JSON 401s instead of HTML redirects when
unauthenticated, and no longer blocks public procedures for logged-out
visitors. 14 previously-silent catch blocks now log to GlitchTip.
invite/page.tsx and EmergencyVetSection.tsx fully localized (were 100%
hardcoded German). Stale TODO comments removed, auth.test.ts scaffold
rewritten to match the actual lazy-upsert pattern instead of a Clerk
webhook flow that was never built.
2026-08-13 12:42:49 +02:00
admin d670cf9173 feat(theme): account-bound Dark Mode toggle (Gitea #8)
Wires up the already-present next-themes dependency and globals.css
:root/.dark token infrastructure. Persists the choice on Owner.themePreference
so it survives relogin on a new device, not just localStorage. Toggle lives
in the Sidebar (desktop) and the pet-switcher dropdown (mobile); Clerk's own
UI now follows the same theme via a small ClerkThemeProvider wrapper.
2026-08-13 10:54:23 +02:00
admin 72ae462513 feat(admin): shared PawFeed Team inbox in the admin panel (Gitea #10)
The existing "Message" button on the Users page routed into the
normal /messages UI via assertPetOwnership, which only ever worked
for the literal ADMIN_OWNER_ID account — every other admin/moderator
got a silent FORBIDDEN, since the Team pet has one fixed owner. This
was already flagged as a known limitation in a code comment.

Adds three admin-gated procedures (assertAdmin instead of
assertPetOwnership) — listTeamConversations, getTeamConversationThread,
sendTeamReply — reusing the existing Conversation/Message model and
the sendTeamMessage helper. New /p/[secret]/messages page: a two-pane
inbox (conversation list + thread + reply box) any SUPPORT+ admin can
use, so the whole team sees the same conversation history instead of
only the bootstrap account. The Users-page Message button now deep-links
here instead of the broken consumer route.
2026-08-12 19:28:07 +02:00
admin 4000a1eed9 feat(admin): DAU/MAU tracking and expanded dashboard stats (Gitea #10 Phase 3, part 4/4)
Redis-backed visit tracking (src/lib/visit-tracking.ts): daily SADD
sets keyed "visits:YYYY-MM-DD", same fail-open idiom as the existing
rate-limit.ts. Recorded once per authenticated (app) layout render —
SADD is idempotent so no client-side dedup is needed. DAU = today's
SCARD, MAU = SUNION across the trailing 30 daily sets.

Dashboard additions:
- 6 new stat cards: DAU, MAU, shadowban count, blacklist entry count,
  verifications open/closed.
- 3 new time-series charts (reusing the existing TimeSeriesChart/
  GranularityToggle components): visits, comments, and reactions
  over time. Comments/reactions are plain Postgres bucket queries
  cloned from the existing getPostsOverTime pattern; visits reroutes
  through the new Redis helpers per bucket.

This closes out Gitea #10 Phase 3 (hide-without-delete, shadowban,
blacklist, and now analytics — all four parts shipped).
2026-08-12 19:03:40 +02:00
admin a35cb6640f feat(admin): clickable reported posts and color-coded user actions (Gitea #10)
Two follow-up requests on the admin dashboard:

- Reports page: the post preview is now clickable, opening a detail
  dialog with full-size images, the complete comment list (reusing
  the existing admin.listComments query), and a "liked by" list of
  pets — previously moderators only saw a caption snippet and raw
  counts, with no way to inspect what was actually reported. Backed
  by a new admin.getReportedPostDetail query (images + reactions;
  comments intentionally reuse the existing procedure instead of
  duplicating that fetch).
- Users page: Warning/Export/Message buttons get distinct colors
  (amber/blue/sky) matching the existing Ban (red) / Shadowban
  (violet) pattern, instead of all three sharing the same neutral
  gray outline.
2026-08-12 18:49:44 +02:00
admin 7395d83194 feat(admin): word/link/hashtag blacklist module (Gitea #10 Phase 3, part 3/4)
Adds a BlacklistEntry model (type WORD|LINK|HASHTAG, unique per
type+value) with a new admin module at /p/[secret]/blacklist —
add/list/delete entries, filterable by type.

Enforcement, per explicit spec from this session:
- New posts are checked at creation time (posts.create) against the
  current blacklist (whole-word regex match for WORD, domain match
  incl. subdomains for LINK, exact match for HASHTAG) and auto-hidden
  immediately via the existing hiddenAt mechanism if matched — never
  blocks creation, just hides right after, reversible via
  admin.unhidePost. Logged as HIDE_POST with moderatorId "SYSTEM".
- Adding a new entry retroactively scans existing non-hidden posts
  (capped at 5000 for safety) and hides matches too, logged per-post
  under the admin who added the entry.
- Removing an entry does NOT auto-unhide already-hidden posts —
  that stays a manual review step via the existing hide/unhide UI.

Matching lives in src/lib/blacklist.ts: word matching uses \b word
boundaries (a blacklisted "ass" won't match "assist"), link matching
extracts URLs and compares hostnames including subdomains.

Scope: post captions/hashtags only, not comment bodies — comments
don't have a hiddenAt field yet, so this is a natural follow-up once
they do, using the same pattern.

Gitea #10
2026-08-12 16:06:37 +02:00
admin eb26cb5fbe feat(admin): shadowban owners (Gitea #10 Phase 3, part 2/4)
Adds a Shadowban model (mirrors UserBan: reason, issuedBy, expiresAt =
null for permanent) plus MODERATOR+ shadowbanOwner/removeShadowban
mutations logging SHADOWBAN_USER/UNSHADOWBAN_USER to ModerationLog.

Unlike a ban, a shadowbanned owner is never told and keeps using the
app normally — their posts just quietly stop reaching anyone else.
Every reader-facing post query now excludes a shadowbanned owner's
pets for other viewers while still showing that owner their own
content, via a `pet: { OR: [{ ownerId: ctx.userId }, { owner: {
shadowban: null } }] }` filter: feed (both Redis-postId lookup and the
Postgres fallback), profile grid, hashtag search, and the milestones
tab. Explore trending has no reliable per-viewer identity (public
procedure) so it excludes shadowbanned owners unconditionally — a
low-stakes gap since a single post rarely lands there anyway.

Admin dashboard (Gitea #10's "Konten sperren ... Shadowban" spec item):
Users page now shows a violet SHADOWBANNED badge + reason next to the
existing ban badge, and a Ghost-icon shadowban/un-shadowban button
mirroring the ban/unban flow (reason required, unlike ban's optional
one, since the deceptive nature of the action warrants a mandatory
audit trail).

Gitea #10
2026-08-12 15:49:45 +02:00
admin a29c57e37a feat(admin): hide posts without deleting (Gitea #10 Phase 3, part 1/4)
Adds Post.hiddenAt (nullable, db push applied) plus MODERATOR+ admin
mutations hidePost/unhidePost, each logging HIDE_POST/UNHIDE_POST to
ModerationLog with the standard moderatorRole+ipAddress stamp.

hidePost requires a non-empty reason (visible to nobody but audit
trail); unhidePost is a plain reversal. The admin posts grid shows a
"Hidden" overlay badge and swaps the delete-adjacent action button
between hide/unhide based on hiddenAt.

Every reader-facing post query now filters hiddenAt: null so a hidden
post disappears everywhere without touching its row: feed (discovery
candidates, Postgres fallback, and the Redis-postId lookup — the same
place blocked-pet filtering already happens, so an already-fanned-out
hidden post silently drops out at read time), feed backfill on new
follows, explore trending, profile grid, hashtag search, and the
milestones tab.

Admin-facing queries (listPosts, analytics bucketing) are deliberately
NOT filtered — moderators need to see hidden posts to unhide them, and
volume analytics shouldn't be skewed by moderation actions.

Gitea #10
2026-08-12 14:23:00 +02:00
admin 3a5b72b0f7 fix(verification): profile context in review, i18n on edit page, badge in pet switcher
Three follow-ups from testing the verification flow:
- listVerificationRequests now pulls bio, join date, post/follower
  counts, and the owner's ban+warning status onto the review card, so a
  moderator can check the checklist without leaving the page
- Edit-pet page was entirely hardcoded English regardless of locale —
  converted to next-intl (new EditPet namespace, en+de)
- the pet switcher (Sidebar + MobileNav) didn't show the verified badge,
  only the full profile page did — added it to both
2026-08-12 10:19:16 +02:00
admin a4c4568065 feat(verification): self-service request flow with mandatory review checklist
Gitea #10 Option B:
- new VerificationRequest model — owner submits a message via the pet
  edit page, moderator reviews against a fixed 4-item checklist
  (authenticity/impersonation risk, complete profile, good standing,
  established presence). All four met -> approved, Pet.isVerified flips
  on; any unmet -> auto-rejected with an itemized note of what's missing
- new admin queue /p/[secret]/verification (Moderator+ only, same gate
  as the audit log), status filter, review dialog enforces the checklist
- owner is notified automatically either way via a "PawFeed Team" DM —
  extracted the team-pet lookup/create logic (previously only inline in
  startTeamConversation) into src/lib/team-pet.ts, adding sendTeamMessage
  which reuses the normal Conversation/Message + MESSAGE notification
- review decisions log to ModerationLog with role+IP, same as every
  other Gitea #10 action this session
2026-08-12 10:03:59 +02:00
admin 54fb0d55bc feat(admin): warning system, pet verification badges, report severity, admin GDPR erasure
Gitea #10 Phase 2:
- Warning model (append-only, no delete — Owner history), issueWarning +
  listWarnings, surfaced on the Users page with a badge + dialog
- Pet.isVerified checkmark (lives on Pet, not Owner — pets are the social
  identity per CLAUDE.md), toggleable from the Users page, shown on the
  public profile page
- listReports now derives a HIGH/MEDIUM/LOW severity from ReportReason
  and sorts severity-first; Reports page shows the derived badge
- Admin-triggered GDPR Art. 17 erasure: extracted the self-service
  /api/account/delete logic into a shared deleteOwnerAccount() helper,
  new admin.deleteUserAccount (SUPER_ADMIN only, requires typing the
  exact ownerId to confirm, blocks self-deletion) reuses it — Delete
  button only renders for SUPER_ADMIN/bootstrap callers
- all four new/changed mutations log to ModerationLog with role+IP
2026-08-12 09:32:33 +02:00
admin e099763211 feat(admin): add filterable audit log page, gated to Moderator+
Gitea #10 follow-up:
- new admin.getMyRole procedure (no minRole gate) so the panel can answer
  "what's my own role" without hitting a permission wall
- fixes a regression from the permission-matrix change: layout.tsx was
  using listModerators (now MODERATOR+) just to check "does this user have
  any admin access at all", which locked SUPPORT accounts out of the
  entire panel — swapped to getMyRole
- getModerationLog gains cursor pagination, action/targetType/moderatorId
  filters, and resolves each entry's moderator to a pet name
- new /p/[secret]/log page renders the filterable log; the nav link only
  shows for MODERATOR/SUPER_ADMIN/bootstrap, and the page itself shows a
  clear "no permission" state if a SUPPORT account hits the URL directly
2026-08-12 09:02:21 +02:00
admin c94e60ab82 feat(admin): enforce 3-tier permission matrix, stamp role+IP on every log entry
Gitea #10 follow-up:
- assertAdmin now takes a minRole (SUPPORT < MODERATOR < SUPER_ADMIN)
  instead of a boolean requireSuperAdmin flag, and returns the caller's
  resolved role so mutation sites can log it. Applied per the agreed
  matrix: read-only/support tasks stay SUPPORT, destructive content/user
  actions require MODERATOR, ads/roles/invites/legal-doc-publishing
  require SUPER_ADMIN.
- ModerationLog gains moderatorRole and ipAddress (both nullable —
  historical rows predate capture), populated on every write for
  login-abuse forensics.
2026-08-12 08:53:32 +02:00
admin 2d656bd779 feat(admin): add SUPPORT role, user search/filter, and moderation audit gaps
Gitea #10 follow-up, Phase 1:
- AdminRoleType gains SUPPORT (same access as MODERATOR for now, pending
  a permission-matrix pass to scope it down)
- listUsers gains search (Clerk query + local pet-name match), ban-status,
  and role filters
- ModerationLog now also captures ad create/update/delete and role
  grant/revoke, which previously left no audit trail
- new getModeratorThroughput query surfaces per-moderator action counts
  on the Moderators page
2026-08-12 08:38:25 +02:00
admin 48ce0396ca feat(admin): search posts by pet name in addition to caption 2026-08-12 08:09:25 +02:00
admin af018a234e fix(admin): show milestone/repost context instead of blank thumbnail
Post moderation grid rendered milestone and repost posts as an empty
ImageOff placeholder with no way to tell what they were. Now shows the
milestone icon+label, or the original post's thumbnail and pet name
for reposts.
2026-08-12 07:54:15 +02:00
admin efdf407642 feat(admin): add species/breed ad targeting (Gitea #9)
Advertisement.species existed in the schema but was never wired up —
the admin form never let you set it and the feed's ad query ignored it
entirely, showing every active ad to every pet regardless of species.

Adds a new Advertisement.breeds field alongside it and wires both
through the whole path: a targeting UI in the ad create/edit dialog
(species checkboxes, each expandable into its own breed checklist),
and a filter in admin.getActiveAds (now scoped to the viewing pet) so
cat food ads stop reaching dog owners. Empty species/breeds = shown to
everyone, unchanged from today's behavior.
2026-08-11 15:09:56 +02:00
admin ed6842bc39 feat(admin): redesign Posts panel as a 3-column media grid (Gitea #7)
The list view made post content hard to scan — thumbnails rendered at
inconsistent sizes and captions were easy to miss. Switches to a
responsive grid (3 cols on desktop) with a fixed-size thumbnail per
card, a same-size placeholder for posts without media, a caption
below each card, and a play-icon overlay + Mux thumbnail for video
posts (listPosts now also selects videoPost.muxPlaybackId).
2026-08-11 14:47:06 +02:00
admin 5e2a11e44f feat(admin): route GDPR export downloads through pawfeed.org instead of Supabase (Gitea #6 follow-up)
Replaces the emailed Supabase signed URL with a bespoke DataExportToken
DB record, delivered via a new /api/gdpr-export/[token] route that
streams the file server-side. The storage backend is now an internal
implementation detail; every link the user sees reads pawfeed.org.
2026-08-11 14:21:39 +02:00
admin f12dec05bd feat(admin): add GDPR Art. 15 data-export feature (Gitea #6, Plan B)
Aggregates everything held about an Owner (gdpr-export.ts: pets, posts,
stories, comments, reactions, reposts, follows/blocks, health records,
messages, notifications, reports, ban/consent history, Clerk profile)
into a single JSON bundle, uploads it to a private Supabase Storage
bucket (data-export-storage.ts — separate from the public pet-avatars
bucket used for media), and emails the owner a 7-day signed download
link via the new SMTP module. Triggered from a new export button per
user in /p/[secret]/users, logged to ModerationLog.

Deliberately not a zip with embedded media bytes: avoids new archiving
dependencies, the risk of a slow synchronous request for owners with
many posts, and the fact that Mux video assets aren't trivially
re-downloadable — media is referenced by its existing CDN URL instead.
Ad interactions and issued invite codes are not yet included, flagged
in the export's own scope note rather than silently omitted.

Verified end-to-end against the live shared DB before committing:
built a real export, uploaded it, generated a signed URL, and sent a
real test email — all succeeded.
2026-08-11 11:53:27 +02:00
admin 328e54c68e feat(admin): show Clerk profile data and add a Team-messaging channel to the users panel
Gitea #6 (parts 1+2): admin.listUsers now batch-fetches email/name from
Clerk (users only stored the Clerk userId locally, everything else
lives in Clerk) instead of showing a bare userId. Adds
admin.startTeamConversation, which lazily creates a "PawFeed Team"
system Pet under ADMIN_OWNER_ID and reuses the existing pet-to-pet
Conversation/Message model instead of a parallel admin-inbox system —
the admin panel switches ActivePet to Team and opens the ordinary
/messages/[conversationId] thread, so no new UI is needed on the
recipient's side.

Known limitation: only the literal ADMIN_OWNER_ID account can act as
Team, since assertPetOwnership requires an exact owner match.
Extending this to every AdminRole holder is left for a follow-up.

Part 3 (GDPR Art. 15 data export) is deliberately not included —
research only per the issue's own "erst prüfen dann bauen" note.
2026-08-11 11:06:42 +02:00
admin b6610ca7a7 feat(media): add Blurhash placeholders for images across feed, stories, and admin views
Gitea #1: prevents blank image flashes while photos load. Blurhash is
computed client-side at upload time (canvas downsampling + the
blurhash package) for posts, milestones, and stories, stored alongside
each PostImage/Story row, and decoded into a smooth color placeholder
via the new BlurImage component that cross-fades to the loaded photo.
Wired into every content-photo surface: feed cards, post detail zoom,
reposts, milestone cards, story viewer, explore/search grids,
notification thumbnails, profile grid, and the admin posts/reports
panels. Pet avatars and ad creatives intentionally excluded — separate
content pipelines with disproportionate effort for the payoff.
2026-08-11 10:03:08 +02:00
admin 17f560eb46 feat(legal): add mandatory upload-rights and data-processing consent to onboarding
Gitea #3: harden Datenschutz for the now-public site. Adds a blocking
first-pet-onboarding step (two checkboxes) confirming the owner holds
rights to uploaded photos/videos and consents to data transfer to
Clerk/Cloudflare/Mux/Supabase. Consent timestamps are stored separately
on Owner so either declaration can be re-requested independently later.
Also documents GlitchTip as a processor and adds an explicit consent
section to the Datenschutzerklärung.
2026-08-11 09:33:39 +02:00
admin f48d98cf3a fix(security): lock Mux upload CORS origin to the real domain in prod
cors_origin was hardcoded to "*" for all environments. Now restricted
to NEXT_PUBLIC_APP_URL when NODE_ENV=production; stays "*" in dev so
localhost uploads keep working.
2026-08-10 12:50:40 +02:00
admin f43541a4cc feat(security): add Redis-backed rate limiting and CSP/security headers
Public go-live hardening: Redis-backed rate limiting on the previously
invite-gated public surface (explore browsing, post/comment creation,
invite-cookie routes) plus CSP, HSTS, and standard security headers on
every response, now that the invite gate no longer filters traffic.
2026-08-10 07:57:56 +02:00
admin b4ef5bc195 feat(ads): track and display per-ad view counts for advertisers
Advertisers/admins had no way to see how many times an ad was actually
shown — only engagement counts (reactions/comments/reposts) existed.
Adds Advertisement.adViewCount, a denormalized counter matching the
existing engagement-counter pattern.

AdCard.tsx fires ads.recordView once per card, the first time it
scrolls ≥50% into view (react-intersection-observer's triggerOnce,
already a project dependency via FeedList's pagination sentinel) —
matches standard "viewable impression" semantics rather than counting
on mount (which would also count ads that render off-screen and are
never actually seen). Not deduplicated per pet, unlike reactions/reposts
— an impression counts every time an ad becomes visible, matching how
ad platforms report view counts.

Admin ads panel (/p/[secret]/ads) now shows view/reaction/comment/
repost counts inline on every ad row — the actual "results" advertisers
want from a placement, previously not surfaced anywhere despite already
existing on the Advertisement row for the other three counters.
2026-07-23 20:22:42 +02:00
admin 0e02d3da71 feat(admin): add posts-over-time and owner-growth charts to the dashboard
Two new chart panels on the admin dashboard, each with a day/week/month
granularity toggle:
- "Posts over time" — bar chart of new posts per bucket (trailing 30
  days / 12 weeks / 12 months, zero-filled so gaps show as zero, not
  missing points).
- "Owner growth" — line+area chart of the cumulative total registered
  owners, seeded from a pre-window baseline count so the curve starts
  at the correct absolute total instead of zero.

Backend: admin.getPostsOverTime/getOwnerGrowth bucket timestamps in
plain JS (UTC-consistent day/ISO-week-Monday/month-1st truncation) —
deliberately not raw SQL date_trunc, to avoid DB-session-timezone
ambiguity and keep this testable without hitting Postgres. Data volume
is small enough (Alpha-stage app) that fetching bare createdAt columns
and bucketing in memory is the simpler, lower-risk choice over adding
the codebase's first raw-SQL query.

Frontend: new src/components/admin/ (TimeSeriesChart, GranularityToggle)
following the dataviz skill — thin 2px marks, rounded bar corners,
recessive gridlines, hover crosshair+tooltip on both variants, single
brand-orange hue since each chart is one series (no legend needed).
Admin surface is fixed dark (zinc-950), so colors are hardcoded rather
than light/dark-aware, unlike the app-wide WeightChart this borrows its
SVG approach from.

Added 2 regression tests covering zero-fill bucketing and the
cumulative-with-baseline math. prisma-mock.ts gained owner.findMany/
owner.count (previously missing).
2026-07-23 19:59:36 +02:00
admin bb1515cb93 feat(onboarding): add community-guidelines acknowledgment step before pet creation
New users landed directly on pet creation with no prompt to acknowledge
community guidelines — the existing LegalUpdateBanner/LegalDocumentVersion
mechanism only ever ran *after* the first pet existed (since the Owner
row wasn't created until pets.create's owner.upsert), and turned out to
be entirely dormant (zero LegalDocumentVersion rows had ever been
published in prod).

Adds a step 0 to /onboarding/pet: OnboardingGuidelines queries the same
legal.getPendingUpdate the in-app update banner uses and shows the
latest published summary before letting the user continue to pet
creation. Reuses the existing LegalDocumentVersion/admin-publish
infrastructure (/p/[secret]/legal) instead of hardcoding copy, so
content stays editable without a deploy. If nothing has been published
yet, the step is skipped automatically — no dead-end for a fresh
deployment.

legal.acknowledge switched from owner.update to owner.upsert since it
can now be called before the Owner row exists (pre-first-pet).

Deliberately did NOT add a cookie-consent checkbox: Datenschutz §5
already discloses that only strictly-necessary session cookies are used
and no tracking/ad cookies are set without explicit consent — under
GDPR/ePrivacy, strictly-necessary cookies don't require active consent,
only disclosure. The guidelines step links to both Nutzungsbedingungen
and Datenschutz instead of a redundant separate consent mechanism.

Side effect (intentional): existing owners who never explicitly
acknowledged anything will see the same content once via the in-app
LegalUpdateBanner the next time a version is published, since it's the
same underlying data model.
2026-07-23 19:37:40 +02:00