- 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.
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>
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.
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.
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.
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.
- 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).
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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>
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>
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>
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>
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).
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.
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.
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.
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).
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.
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
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
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
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
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
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
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
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.
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
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.