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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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.
Adds a subtle "payments processed by Stripe" indicator (official
wordmark SVG from Simple Icons, muted/low-opacity) above the
contribution-type selector on the Unterstützen page, so visitors know
which payment provider handles the transaction before choosing an
option.
Hiding the scrollbar (previous commit) removed the only desktop
affordance for moving a horizontally-scrolling, scrollbar-hidden row
— mouse wheel scrolls vertically by default and there's no drag
handle anymore. Adds useHorizontalScroll, which redirects vertical
wheel delta to scrollLeft while leaving trackpad horizontal gestures
and touch swipe untouched. Applied to the explore species tabs and
StoryTray, which had the identical latent issue.
overflow-x-auto with overflow-y left at its default resolves to auto
per the CSS overflow spec, so an irrelevant vertical scrollbar showed
up alongside the horizontal one. Adds overflow-y-hidden, plus a real
scrollbar-hide utility (@utility, Tailwind v4) for a cleaner look —
StoryTray.tsx already referenced this class name without it ever
being defined, so this fixes that dead reference too.
TabsList is inline-flex/w-fit with no overflow handling, so all 14+
species tabs squeezed into one line as the taxonomy grew instead of
scrolling. Wraps it in an overflow-x-auto container, scoped to the
Explore page only — the shared Tabs component is used elsewhere with
small, fixed tab counts that don't need this. Fixes Gitea #27.
Adds a dependency-free liveness endpoint (GET /api/health), a Docker
healthcheck against it, and docker/rolling-deploy.sh which rebuilds
the shared image once and restarts pawfeed-1/2/3 sequentially, gated
on each becoming healthy before moving to the next. Verified
zero-downtime via continuous curl monitoring through a full run
against production.
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.
Next.js standalone is single-process, so one container only used one
CPU core. A load test hit a hard ceiling at ~60-65 req/sec with one
core pegged at 133% while 3 cores sat idle. Now runs as pawfeed-1/2/3
behind an NPM least_conn upstream, verified via load test showing CPU
spread evenly across all three replicas.
Also caps the Prisma pg pool per-replica via DB_POOL_MAX so 3 replicas
stay well under the Supabase pooler's connection limit.
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.
Chinchilla joins the small-pet species as its own Species (breeds are
color varieties: Standard Grey, Beige, Black Velvet, etc.).
Aquarium models the tank/setup as the pet identity rather than an
individual fish, matching how aquarists actually showcase their hobby —
Breed is the predominant fish species kept (Betta, Guppy, Discus, Koi,
etc.), same pattern as Bird covering many bird types via Breed. Pure
seed-data addition, no schema/code changes needed.
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.
Two prior attempts today (aabb7d3, a070b37) both failed in production
despite each being verified in isolation — confirmed via DevTools that
the deployed DOM/CSS exactly matched what had tested correctly, yet the
real page still rendered short images pinned to the top.
The real, unfixable-from-outside root cause: CarouselContent's own ref
div (src/components/ui/carousel.tsx) is hardcoded to className=
"overflow-hidden" with no way to reach it via props, so it never gets an
explicit height — every centering attempt built on top of it (grid,
flex, items-center at various levels) still bottomed out on that div
having only an indefinite, percentage-against-an-auto-parent height,
which is a genuinely ambiguous CSS case browsers can resolve
inconsistently.
Fix: give CarouselContent a new optional viewportClassName prop that
reaches that specific div, and pass h-full through it from
PostDetailDialog. Carousel's own root div already has a definite height
(traced up through the dialog's fixed dvh values), so h-full on the ref
div now resolves to a real pixel value instead of falling back to auto —
eliminating the ambiguity instead of working around it. Carousel/
CarouselItem revert to plain h-full + flex items-center/justify-center,
no grid trick needed anymore.
Re-verified via an isolated static HTML/CSS repro (embla-free, no auth
needed) before deploying this time.
The items-center/justify-center I added to CarouselItem in the last
commit had no effect — verified by building an isolated static HTML repro
of the exact carousel.tsx DOM structure (embla-free, no auth needed) and
testing it directly in a browser, since PostDetailDialog itself sits
behind Clerk auth and can't be exercised locally.
Root cause was one level higher than CarouselItem: CarouselContent's own
ref div (src/components/ui/carousel.tsx) is hardcoded to
"overflow-hidden" with no height/flex class ever reaching it — nothing
propagates the media column's definite height down to it, so it renders
at its own auto/content height and just sits at the top of the Carousel
element's box via normal block flow. CarouselItem's centering was
already correctly placed, but had no gap to center within, since the
box it was centering into never itself moved off the top.
Fix: attach centering to the Carousel component's own outermost element
instead — the deepest point reachable via props, since CarouselContent's
outer div doesn't forward a className. Used CSS Grid (grid + items-center)
rather than flex specifically because grid's default justify-items:stretch
preserves the ref div's full width (required for embla's horizontal
scroll-snap math); flex's shrink-to-content default for un-stretched
items would likely have broken horizontal scrolling between images.
Verified via the isolated repro: short/wide image now centers vertically
within the media column, and the "always full width" marker stayed
100% wide across all three test cases (single tall, single wide, mixed
pair in one row).
Regression from today's earlier "fill + object-contain" rework: fixing
that removed CarouselItem's flex items-center/justify-center, which is
what centered images in the carousel — because the shadcn Carousel
primitive's inner ref div (src/components/ui/carousel.tsx) never gets an
explicit height (its outer wrapper is hardcoded to just "overflow-hidden",
no className passthrough), the img's h-full never resolves to anything
definite there, so object-contain silently falls back to the image's
natural aspect-ratio sizing instead of fill+letterbox+center. Without
items-center, a shorter image among differently-sized carousel images
just sat flush at the top of the (taller, shared) row instead of centered.
The single-image path was unaffected — its container chain does resolve
to a definite height, so object-contain there works as intended.
Opacity now animates via --paw-opacity in the same scroll-driven keyframe
as the scale/rotate transform, so each print actually fades in as it
scrolls into view instead of appearing at full (low) opacity immediately
and only growing in size. Positions reworked: irregular top gaps and
non-alternating side assignment instead of a near-uniform ladder, offsets
moved from hugging the viewport edge to flanking the centered text column,
and count bumped 13 -> 15 (+15%).
Opacity roughly doubled (0.06-0.08 -> 0.14-0.16) per user feedback that
the trail read as too faint. First print now skips the scroll-reveal
animation entirely and renders at rest immediately, signaling the trail
exists before the reader scrolls at all — the rest still stamp in on
scroll. Trail count bumped from 8 to 13 for denser coverage down the page.
- AnimatedNumber: stat tiles count up from 0 when scrolled into view
instead of appearing static, so "live numbers" actually feels live.
- Pull quote: the story's strongest line is pulled out into its own large
editorial-style callout between the hero paragraphs and the "Von Tier
zu Tier" section, giving the wall of text a visual beat. paragraph2
trimmed (DE+EN) so the line isn't read twice back to back.
- PawPrintTrail: a scattered trail of low-opacity paw prints behind the
story content, each "stamping" into place via a pure-CSS scroll-driven
animation (animation-timeline: view(), same technique as the existing
hero-parallax-icon on the landing page) — no JS, respects
prefers-reduced-motion.
Fixed a real bug while building the trail: -z-10 on its wrapper had no
local stacking context to be scoped to (the parent had `relative` but no
z-index), so it escaped past the page content and rendered behind the
document background — invisible regardless of opacity. Giving the page's
outer wrapper `z-0` fixes it.
Verified locally via dev server + Chrome DevTools MCP: light + dark theme,
375px mobile, quote/trail/stat-tile rendering all confirmed visually.
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.
Mobile (<640px): the sign-up button was ~50% offscreen. The header
crammed logo + theme toggle + full LanguageSwitcher (globe icon +
"Sprache:" label + 2 flag buttons) + sign-up pill into a narrow
viewport. Collapsed LanguageSwitcher to icon-only flags below sm,
tightened header padding/gaps, and reduced the sign-up button's
mobile padding. Verified via Chrome DevTools viewport emulation at
320px and 375px — button fully visible at both.
While verifying, found a second, seemingly pre-existing overflow at
exactly 768px (the md: breakpoint): the desktop nav links (Home/Über
uns/Impressum) plus the full-size right-side group don't fit in that
width, cutting off the sign-up button again and causing horizontal
scroll. Moved the nav links from md:flex to lg:flex so they only
appear once there's actually room (confirmed clean at 768px and
1024px+).
The old copy positioned sponsoring as a substitute for ads ("instead
of ads"), but the platform still runs ads when advertising partners
are available — sponsoring and ads aren't mutually exclusive. Reframe
around the actual motivation: PawFeed is entirely privately financed,
and sponsoring helps cover ongoing costs (server hardware, software
licenses, tooling).
Chasing this through width/height class combinations (w-fit/h-fit vs
w-auto/h-auto) kept trading one bug for another: centering broke height,
fixing height broke centering. Root cause: max-h-full/max-w-full on the
image are percentages, and every wrapper in the chain only ever had an
intrinsic (auto/fit-content) size — CSS treats percentages against an
intrinsically-sized parent as unresolvable, so which axis "won" depended
on incidental flex-stretch behavior rather than anything deliberate.
Replaced the shrink-wrap-to-image approach with fill-the-definite-parent:
wrapper and image now take w-full h-full of the already definitely-sized
media column (PostDetailDialog) / full-screen overlay (StoryViewer), and
object-contain (default centered) handles aspect-ratio scaling entirely
on its own — no percentage resolution against an ambiguous parent left
anywhere in the chain.
Not visually verified locally (auth-gated route) — needs a real check
against a portrait post/story after deploy.
h-fit (from the previous fix) disabled the flex cross-axis stretch that
h-auto triggers. That stretch is what gave the image wrapper a definite
height in the first place — without it, the image's max-h-full (a
percentage) had no definite reference to resolve against, so browsers
ignored it and the image rendered at its full natural height, getting
clipped at the bottom of the dialog.
Width and height turned out to need opposite fixes: w-fit for width
(explicit shrink-to-fit avoids the flex-item main-axis stretch that
caused the left-alignment bug), h-auto for height (keeps the cross-axis
stretch that max-h-full depends on).
The image wrapper used w-auto inside a flex container, which is
ambiguous for flex items — it could stretch to fill the available
width instead of shrinking to the image's actual rendered size. Not
visible for landscape images (little leftover space either way), but
portrait images left a wide gap and sat flush left instead of centered.
Switch to w-fit/h-fit (fit-content), which is unambiguous regardless of
flex context. Same wrapperClassName pattern in both files, so applied
to both.
Desktop sidebar already lists /unterstuetzen as a primary nav item, but
mobile had no way to reach it at all. The 5-slot bottom tab bar has no
room for a 6th destination, so it goes in the pet-switcher dropdown
next to the existing secondary actions (add pet, theme toggle) instead
of a second floating header icon.
router.push to the post owner's pet profile page (with a ?post= query
param picked up there) silently swapped out the notifications page in
the background. Closing the dialog then stranded the user on that
profile page instead of back on their notification list. Fetch the
post via posts.getById and open PostDetailDialog directly on the
notifications page instead — no navigation, no background page swap.
Drops the now-unused ?post= deep-link handling from ProfileTabs.
MessageThread's wrapper reserved its own pb-32 for the fixed mobile nav
on top of the h-[100dvh] sizing, while the app shell's <main> already
reserves the same space via its own pb-32. The combined mismatch made
<main> itself scroll as a rigid block instead of just the message list.
Switch to h-full so the thread fills main's actual available height.
Also nudge the floating mail shortcut up (top-3 -> top-1.5) so it's
vertically centered against the transparent feed header instead of
sitting visibly lower than it.
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.
aside now uses h-screen sticky top-0 overflow-y-auto -- same pattern as
the main app Sidebar.tsx -- so it stays in view while scrolling long
admin pages instead of scrolling away with the content.
- Sidebar: drop the /invite nav entry (invite-gate module is off,
INVITE_REQUIRED=false since the 2026-08-10 public launch)
- Admin layout: drop the same for the /invites management page
- Admin layout: add a "Zurueck" footer link back to /feed
Routes themselves are untouched -- only the nav entries pointing at them.
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>
Confirmed via extensive isolated reproduction (docker run, plain BuildKit
RUN step, and Node child_process.spawn -- all three succeed) that TLS and
the sentry-cli invocation itself are fine; GlitchTip's releases API
rejects the authenticated request with "CSRF check Failed" (403) even
with clean token auth and no cookies. That's a server-side GlitchTip
issue (separate Docker stack), not something fixable from this repo.
Keeping ca-certificates/SSL_CERT_FILE since they're correct regardless.
Diagnostic only -- narrowing down whether the sourcemap upload's SSL
error is real or a red herring next to the CSRF 403 seen in manual
reproduction. Will be removed once the root cause is confirmed.
ca-certificates alone didn't fully resolve sentry-cli's TLS handshake
against GlitchTip in the Docker builder stage -- explicitly pointing its
vendored OpenSSL at the installed CA bundle location is the documented
workaround for this class of Rust-binary-on-Alpine cert issue.
sentry-cli (Rust binary) failed the sourcemap upload with "unable to get
local issuer certificate" when talking to GlitchTip over TLS -- the
node:22-alpine builder image ships without a CA bundle. Confirmed via
verbose build log after the SENTRY_RELEASE fix resolved the prior
--release undefined failure.
.git is excluded from the Docker build context (.dockerignore), so
@sentry/nextjs can't auto-detect a release via git and falls back to the
literal string "undefined" for --release, which made the sourcemaps
upload command fail with exit code 1 (confirmed via verbose build log).
start.sh now resolves the short SHA on the host and passes it through
as a SENTRY_RELEASE build arg, same pattern as the other Sentry vars.