Consent is bound to the channel it was given for: approving a post on X says
nothing about Instagram. Publishing state moves from the SocialMilestone row
into SocialMilestonePost, one row per channel, where a missing row means no
consent. The dialog asks per channel, shows the text each one will publish and
keeps a separate handle for each; Instagram captions end in hashtags because a
link there is not clickable.
Also fixes three problems in the existing X path:
- A QR code already past several thresholds produced one prompt per threshold,
and since the post quotes the current scan count, every one of them would
have published the same number. Only the highest threshold is announced now.
- Detection ran after every unique scan and re-read the QR code's full scan
history just to hit skipDuplicates. Known milestones are filtered first.
- A failed post stayed failed forever because the consent dialog only opens
once. The queue now retries three times on its own, spaces first attempts by
SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per
channel with restart and revoke.
The worker no longer renders the card itself; it downloads the image the app
renders at /s/m/<token>/og, which also serves the new square and portrait
formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and
SOCIAL_WORKER_CHANNELS both name it.
Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before
deploying this version.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runbook explained every step but had no way to just work through it. Adds a checklist
and all commands in one block up front, with the prose below as the reference for what a
step does and how it fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Standalone instructions for whoever sets up testmodul.qrmaster.net on the production
server. Written to be followed without prior context: explicit paths, an upfront list of
what must not be touched, and a stop condition in step 2 if the host URLs point at
production, which would send staging clicks into the live app.
Contains no credentials - .env.test is handed over separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The staging database now runs as qrmaster_test instead of reusing the production name.
Container and volume already kept the two apart, but a hand-typed psql session against
two databases both called `qrmaster` looks identical on either side - the distinct name is
what makes the wrong window obvious before a DELETE lands in it.
The base compose file hardcodes `pg_isready -d qrmaster` in the db healthcheck, so the
overlay has to override the probe as well. Without it the container stays unhealthy and web
never starts, because it waits on service_healthy.
Verified against `docker compose config`: staging resolves to qrmaster_test in POSTGRES_DB,
DATABASE_URL and the healthcheck, while production still resolves to qrmaster.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The staging stack is configured through .env.test, which holds its own database password
and secrets. It was not covered by the existing .env rules.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for testmodul.qrmaster.net, a second stack running the `test` branch on a real
qrmaster.net subdomain.
Production scopes its session cookie to .qrmaster.net, so the browser sends it to every
subdomain including staging. With both environments naming the cookie `userId`, the
browser holds two cookies of the same name and cookies.get() picks one arbitrarily -
staging logins would look randomly signed-out. AUTH_COOKIE_NAME lets staging pick
`userId_test` instead. Production keeps the `userId` default; changing it there would
invalidate every existing session.
Wired getAuthCookieName() into the six places that named the cookie literally. The account
deletion route now expires both the host-only and the domain-scoped variant like the logout
route already does, instead of a single cookies().delete() that would leave the other one
behind.
NEXT_PUBLIC_WWW_URL and NEXT_PUBLIC_APP_URL become build ARGs so the same image can be
built pointing at the staging host - the defaults keep a plain production build byte
identical to before. Like COOKIE_DOMAIN these must exist at build time, because process.env
is inlined into the Edge middleware bundle.
robots.ts now serves Disallow-all unless NEXT_PUBLIC_INDEXABLE is true. Staging otherwise
returns the production robots.txt and invites crawlers to index a duplicate of www.
docker-compose.test.yml is the staging overlay. Two things it must get right, both verified
against `docker compose config`:
- db and redis need `networks: !override`. Compose MERGES the networks mapping from the base
file, and since qrmaster-network is external and shared, a plain list left them attached
to it - `db` would then resolve to two containers and staging could read and write the
production database.
- The web entrypoint is replaced so `prisma migrate deploy` never runs. prisma/migrations
stopped in April 2026 and the schema has moved on through manual SQL since, so applying
them to a fresh database would build a stale schema. Staging gets its schema from
`pg_dump --schema-only` against production instead.
Verified: tsc clean, production build succeeds, and the merged compose config confirms
staging keeps db/redis off the shared network while production resolves unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splits the two hostnames across one deployment. No files move: the Next app still
serves every route on both hosts, and the middleware decides per host which paths it
owns and 301s the rest. /login and /signup stay on www - all 82 marketing CTAs point
at /signup, which carries a hard canonical to www plus ad traffic.
src/lib/hosts.ts is the single source of truth for the boundary (APP_PATH_PREFIXES,
isAppPath, wwwUrl, appUrl, urlForPath). The middleware and every absolute-URL builder
read from it so they cannot drift apart.
- Split the overloaded NEXT_PUBLIC_APP_URL into a www and an app origin. It previously
fed both public URLs and in-app URLs, so any single value was wrong somewhere. Most
important: QRCodeCard encodes this origin into the QR code the user downloads and
prints, so it must stay on www.
- Route Stripe return URLs, email links and OAuth redirects per path rather than
against one origin, so /dashboard lands on app and /pricing on www.
- Cross the host boundary once, after a successful login: the router cannot push across
origins, so that jump needs a full load. The user arrives signed in because the
session cookie is scoped to COOKIE_DOMAIN.
- Keep the app host out of search indexes: X-Robots-Tag on every response plus a
Disallow-all robots.txt via rewrite, and /sitemap.xml redirects to www.
- Point the TikTok callback fallback at www explicitly. It used to read
NEXT_PUBLIC_APP_URL, whose meaning changed here, and only the apex domain is
verified with TikTok.
Host splitting is inert while both origins are equal, so development is unaffected.
Verified: tsc clean, production build succeeds including the Edge middleware bundle,
and the path-to-host mapping is unit-checked (prefix traps like /created and
/settings-guide stay on www, query strings do not break matching).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for moving the app to app.qrmaster.net: the session has to survive the
host change from www.qrmaster.net to app.qrmaster.net.
- Add COOKIE_DOMAIN and apply it to the auth, CSRF, attribution and OAuth flow
cookies. Honoured only in production, because browsers reject dotted domains on
localhost - a prod .env copied into a dev environment would otherwise break
every login instead of just ignoring the value.
- Expire both the host-only and the domain-scoped variant on logout. Next's
ResponseCookies is keyed by cookie name and rewrites the entire set-cookie
header from its internal map on every set(), so the two variants must be
appended manually - otherwise one overwrites the other and the surviving stale
cookie keeps the user signed in.
- Pass COOKIE_DOMAIN as both build arg and runtime env: process.env is inlined
into the Edge middleware bundle, so a runtime-only value would leave the
middleware and the route handlers disagreeing about the cookie scope.
No behaviour change while COOKIE_DOMAIN is unset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>