diff --git a/CLAUDE.md b/CLAUDE.md index 596307e..74047a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ OSU campus social app for small-group hangouts ("pods"), clubs, messaging, and a - **Verification gate:** almost everything requires a verified OSU email (`requireVerifiedAuth`). The App Store reviewer account bypasses it — don't break that path. - **Deep links:** `oval://pod/:podId` etc., configured in `App.tsx`; push notification payloads carry a `url` that feeds the same config. - **Age rating is 18+** and moderation features (block, report, delete account, data export) are App Store review-critical. Never weaken them casually. +- **Terms version is a two-sided deploy.** `CURRENT_TERMS_VERSION` exists in both `backend/src/config/legal.ts` and `frontend/src/constants/legal.ts`. Bumping one without the other 400s every client on the terms screen with no way past it — this has broken a release twice. Old versions stay valid via `PREVIOUS_TERMS_VERSIONS`; deploy the backend before shipping the build. Full procedure: `docs/PLAYBOOK.md` §8. ## Workflow expectations diff --git a/backend/package.json b/backend/package.json index 761d5f2..329056f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "seed:screenshots": "ts-node prisma/seed-screenshots.ts", "cleanup:screenshots": "ts-node prisma/cleanup-screenshots.ts", "reseed:screenshots": "./prisma/reseed-screenshots.sh", + "check:screenshots": "ts-node prisma/check-screenshot-seed.ts", "test": "vitest run", "test:watch": "vitest", "postinstall": "prisma generate" diff --git a/backend/prisma/check-screenshot-seed.ts b/backend/prisma/check-screenshot-seed.ts new file mode 100644 index 0000000..e09226a --- /dev/null +++ b/backend/prisma/check-screenshot-seed.ts @@ -0,0 +1,112 @@ +/// +/** + * Read-only diagnostic for the screenshot seed. + * + * Answers: which database am I pointed at, did the activity-catalog migration + * land there, is any seed data actually present, and does my own account see it? + * Writes nothing — safe to run against production. + * + * npx ts-node prisma/check-screenshot-seed.ts + * CHECK_EMAIL=you@osu.edu npx ts-node prisma/check-screenshot-seed.ts + */ +import { PrismaClient } from '@prisma/client'; +import * as fs from 'fs'; +import * as path from 'path'; + +const prisma = new PrismaClient(); +const MANIFEST_PATH = path.join(__dirname, '.screenshot-seed-manifest.json'); + +function describeTarget() { + try { + const u = new URL(process.env.DATABASE_URL ?? ''); + const user = decodeURIComponent(u.username || ''); + const ref = user.includes('.') ? user.split('.').slice(1).join('.') : '(none)'; + return { host: u.host, ref }; + } catch { + return { host: '(unparseable DATABASE_URL)', ref: '(unknown)' }; + } +} + +async function main() { + const t = describeTarget(); + console.log('=== TARGET ==='); + console.log(' host :', t.host); + console.log(' project ref :', t.ref); + console.log(' (compare this ref to the DATABASE_URL in your Vercel project settings)'); + + console.log('\n=== MIGRATION STATE ==='); + const cols: Array<{ column_name: string }> = await prisma.$queryRawUnsafe( + `SELECT column_name FROM information_schema.columns WHERE table_name = 'Activity'` + ); + const names = cols.map((c) => c.column_name); + const hasCatalogV2 = ['isActive', 'artworkKey', 'sortOrder'].every((c) => names.includes(c)); + console.log(' activity_catalog_v2 applied :', hasCatalogV2 ? 'YES' : 'NO <-- seed will throw without this'); + + if (hasCatalogV2) { + const needed = ['frisbee', 'trivia', 'study-group', 'grab-coffee-tea', 'casual-hangout', 'pickup-soccer']; + const found = await prisma.activity.findMany({ + where: { artworkKey: { in: needed }, isActive: true }, + select: { artworkKey: true }, + }); + const missing = needed.filter((k) => !found.some((f) => f.artworkKey === k)); + console.log(' activities the seed needs :', `${found.length}/${needed.length} present`); + if (missing.length) console.log(' MISSING :', missing.join(', ')); + } + + console.log('\n=== WHAT IS ACTUALLY IN THIS DB ==='); + const seedUsers = await prisma.user.count({ where: { email: { startsWith: 'oval-seed-' } } }); + console.log(' demo users (oval-seed-*) :', seedUsers); + console.log(' users (total) :', await prisma.user.count()); + console.log(' pods (total) :', await prisma.pod.count()); + console.log(' clubs (total) :', await prisma.club.count()); + console.log(' club members (total) :', await prisma.clubMember.count()); + console.log(' friendships (total) :', await prisma.friendship.count()); + + console.log('\n=== MANIFEST vs DB ==='); + if (!fs.existsSync(MANIFEST_PATH)) { + console.log(' no active manifest on disk (seed has not run, or cleanup already archived it)'); + } else { + const m = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); + console.log(' manifest createdAt :', m.createdAt); + const live = await prisma.user.count({ where: { id: { in: m.users ?? [] } } }); + console.log(' manifest users still in DB :', `${live}/${(m.users ?? []).length}`); + const livePods = await prisma.pod.count({ where: { id: { in: m.pods ?? [] } } }); + console.log(' manifest pods still in DB :', `${livePods}/${(m.pods ?? []).length}`); + const liveClubs = await prisma.club.count({ where: { id: { in: m.clubs ?? [] } } }); + console.log(' manifest clubs still in DB :', `${liveClubs}/${(m.clubs ?? []).length}`); + } + + const email = (process.env.CHECK_EMAIL ?? 'vanbibber.21@osu.edu').trim().toLowerCase(); + console.log(`\n=== ACCOUNT: ${email} ===`); + const me = await prisma.user.findUnique({ + where: { email }, + select: { + id: true, + email: true, + accountStatus: true, + verifiedUniversity: true, + _count: { select: { clubMemberships: true } }, + }, + }); + if (!me) { + console.log(' NOT FOUND in this database <-- the app you are testing uses a different DB'); + } else { + console.log(' id :', me.id); + console.log(' accountStatus :', me.accountStatus); + console.log(' verifiedUniversity :', me.verifiedUniversity); + console.log(' club memberships :', me._count.clubMemberships); + const friends = await prisma.friendship.count({ + where: { OR: [{ userAId: me.id }, { userBId: me.id }] }, + }); + console.log(' friendships :', friends); + } +} + +main() + .catch((e) => { + console.error('\n[check] failed:', e.message); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/config/legal.ts b/backend/src/config/legal.ts index b8f3e5e..02a3f75 100644 --- a/backend/src/config/legal.ts +++ b/backend/src/config/legal.ts @@ -1 +1,39 @@ +/** + * Terms version tracking. + * + * CURRENT_TERMS_VERSION is what a freshly-updated client displays and what the + * app re-prompts against. ACCEPTED_TERMS_VERSIONS is what the API will still + * take from clients that haven't updated yet. + * + * WHY THE ALLOWLIST: the App Store build and the backend deploy are never + * simultaneous. If the API only accepted the current version, then between the + * two deploys every affected client would 400 on /auth/register and + * /auth/accept-terms — an inescapable wall on the terms screen, since the app + * re-prompts whenever the stored version differs. This has broken a release + * twice. See docs/PLAYBOOK.md → "Bumping the terms version". + * + * The version a user actually accepted is recorded as-submitted, never + * overwritten with CURRENT_TERMS_VERSION — a user on an older build saw the + * older text, and the record needs to reflect that. + * + * WHEN BUMPING: move the old value into PREVIOUS_TERMS_VERSIONS in the same + * commit. Drop entries only once that build is out of circulation (App Store + * Connect → force-upgrade cutoff), not before. + */ export const CURRENT_TERMS_VERSION = '2026-08-05'; + +/** + * Older versions still honored during the upgrade window, newest first. + * '2026-06-20' ships in v1.1, which is live on the App Store. + */ +export const PREVIOUS_TERMS_VERSIONS = ['2026-06-20'] as const; + +export const ACCEPTED_TERMS_VERSIONS: readonly string[] = [ + CURRENT_TERMS_VERSION, + ...PREVIOUS_TERMS_VERSIONS, +]; + +/** True if `version` is a version the API will accept from a client. */ +export function isAcceptedTermsVersion(version: unknown): version is string { + return typeof version === 'string' && ACCEPTED_TERMS_VERSIONS.includes(version.trim()); +} diff --git a/backend/src/routes/auth.test.ts b/backend/src/routes/auth.test.ts index 3198a71..deba72e 100644 --- a/backend/src/routes/auth.test.ts +++ b/backend/src/routes/auth.test.ts @@ -3,7 +3,7 @@ import request from 'supertest'; import app from '../server'; import prisma from '../prisma'; import { hashOneTimeCode } from '../lib/oneTimeCodes'; -import { CURRENT_TERMS_VERSION } from '../config/legal'; +import { CURRENT_TERMS_VERSION, PREVIOUS_TERMS_VERSIONS } from '../config/legal'; const TEST_DOMAIN = '@osu.edu'; const VALID_PROFILE = { @@ -58,6 +58,43 @@ describe('POST /auth/register', () => { .expect(400); }); + // Regression: the App Store build and the backend deploy are never + // simultaneous. A client on the previously-shipped terms version must still + // be able to register, or the terms screen becomes an inescapable wall + // between the two deploys. See docs/PLAYBOOK.md → "Bumping the terms version". + it.each(PREVIOUS_TERMS_VERSIONS)( + 'still registers a client on previous terms version %s', + async (oldVersion) => { + const email = `oldterms-test-register-${Date.now()}${TEST_DOMAIN}`; + const res = await request(app) + .post('/auth/register') + .send({ + name: 'Old Build', + email, + password: 'password123', + ...VALID_PROFILE, + termsVersion: oldVersion, + }) + .expect(201); + + // Records what that build actually displayed, not the current version. + expect(res.body.user.termsVersion).toBe(oldVersion); + } + ); + + it('rejects an unrecognized terms version', async () => { + await request(app) + .post('/auth/register') + .send({ + name: 'Bogus Terms', + email: `bogusterms-test-register-${Date.now()}${TEST_DOMAIN}`, + password: 'password123', + ...VALID_PROFILE, + termsVersion: '1999-01-01', + }) + .expect(400); + }); + it('accepts @buckeyemail.osu.edu email', async () => { const email = `buckeye-test-register-${Date.now()}@buckeyemail.osu.edu`; const res = await request(app) @@ -452,4 +489,43 @@ describe('POST /auth/accept-terms', () => { expect(response.body.user.termsVersion).toBe(CURRENT_TERMS_VERSION); expect(response.body.user.ageAttestedAt).toEqual(expect.any(String)); }); + + it.each(PREVIOUS_TERMS_VERSIONS)( + 'still accepts a client on previous terms version %s', + async (oldVersion) => { + const email = `accept-old-terms-${Date.now()}${TEST_DOMAIN}`; + const registration = await request(app) + .post('/auth/register') + .send({ name: 'Old Client', email, password: 'password123', ...VALID_PROFILE }) + .expect(201); + + await prisma.user.update({ + where: { email }, + data: { termsVersion: null, termsAcceptedAt: null, ageAttestedAt: null }, + }); + + const response = await request(app) + .post('/auth/accept-terms') + .set('Authorization', `Bearer ${registration.body.token}`) + .send({ termsAccepted: true, ageConfirmed: true, termsVersion: oldVersion }) + .expect(200); + + // The older build showed the older text, so that is what gets recorded. + expect(response.body.user.termsVersion).toBe(oldVersion); + } + ); + + it('rejects an unrecognized terms version', async () => { + const email = `accept-bogus-terms-${Date.now()}${TEST_DOMAIN}`; + const registration = await request(app) + .post('/auth/register') + .send({ name: 'Bogus Client', email, password: 'password123', ...VALID_PROFILE }) + .expect(201); + + await request(app) + .post('/auth/accept-terms') + .set('Authorization', `Bearer ${registration.body.token}`) + .send({ termsAccepted: true, ageConfirmed: true, termsVersion: 'not-a-version' }) + .expect(400); + }); }); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index ea1f2a9..a28acda 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -5,7 +5,7 @@ import { requireAuth, AuthRequest } from '../middleware/auth'; import { sendPasswordResetEmail, sendVerificationEmail } from '../lib/emailService'; import { getFullName, normalizeNameParts } from '../lib/userNames'; import { moderateTextContent } from '../lib/contentModeration'; -import { CURRENT_TERMS_VERSION } from '../config/legal'; +import { CURRENT_TERMS_VERSION, isAcceptedTermsVersion } from '../config/legal'; import { issueAuthToken } from '../lib/authSession'; import { consumeDurableRateLimit } from '../lib/durableRateLimit'; import { hashEmailIdentity, normalizeEmail } from '../lib/identity'; @@ -152,7 +152,7 @@ router.post('/register', async (req: Request, res: Response): Promise => { return; } - if (!termsAccepted || !ageConfirmed || termsVersion !== CURRENT_TERMS_VERSION) { + if (!termsAccepted || !ageConfirmed || !isAcceptedTermsVersion(termsVersion)) { res.status(400).json({ error: 'You must confirm you are 18 or older and accept the current Terms and Community Guidelines.', }); @@ -207,7 +207,9 @@ router.post('/register', async (req: Request, res: Response): Promise => { emailVerifyExpiry: expiry, classYear, major, - termsVersion: CURRENT_TERMS_VERSION, + // Record what the client actually displayed, not CURRENT_TERMS_VERSION — + // an older build showed older text. + termsVersion, termsAcceptedAt: new Date(), ageAttestedAt: new Date(), }, @@ -541,10 +543,13 @@ router.post('/resend-verification', requireAuth, async (req: AuthRequest, res: R // POST /auth/accept-terms router.post('/accept-terms', requireAuth, async (req: AuthRequest, res: Response): Promise => { + const submittedVersion = + typeof req.body?.termsVersion === 'string' ? req.body.termsVersion.trim() : ''; + if ( req.body?.termsAccepted !== true || req.body?.ageConfirmed !== true || - req.body?.termsVersion !== CURRENT_TERMS_VERSION + !isAcceptedTermsVersion(submittedVersion) ) { res.status(400).json({ error: 'Accept the current Terms and confirm you are 18 or older.' }); return; @@ -554,7 +559,9 @@ router.post('/accept-terms', requireAuth, async (req: AuthRequest, res: Response const updated = await prisma.user.update({ where: { id: req.user!.userId }, data: { - termsVersion: CURRENT_TERMS_VERSION, + // Record what the client actually displayed, not CURRENT_TERMS_VERSION — + // an older build showed older text. + termsVersion: submittedVersion, termsAcceptedAt: new Date(), ageAttestedAt: new Date(), }, diff --git a/docs/PLAYBOOK.md b/docs/PLAYBOOK.md index bbb632e..af44728 100644 --- a/docs/PLAYBOOK.md +++ b/docs/PLAYBOOK.md @@ -30,7 +30,26 @@ Copy-paste starting points for recurring tasks. Each recipe lists the context to > Read `INCIDENT_RESPONSE.md`. Check `/health` (touches the DB). Common causes: paused Supabase DB, missing env var (validated at boot by `productionEnv.ts` — check Vercel logs for the throw), stale cron scheduler, CORS_ORIGIN mismatch. Timezone bugs: remember everything is America/New_York. -## 8. Design/UX pass +## 8. Bumping the terms version + +> **This has broken a release twice. Read before touching `legal.ts`.** +> +> The version string lives in two places that must agree: `backend/src/config/legal.ts` and `frontend/src/constants/legal.ts`. The app re-prompts whenever a user's stored `termsVersion` differs from the frontend constant (`AuthContext.tsx`), and `/auth/register` + `/auth/accept-terms` validate what the client submits. So a client whose version the API doesn't recognize gets a 400 on the terms screen **with no way past it** — it blocks existing users, not just new signups. +> +> The App Store build and the Vercel deploy are never simultaneous, so some client is always on the old version. That's what `ACCEPTED_TERMS_VERSIONS` is for. +> +> **Procedure:** +> +> 1. Update the terms text (`landing/src/components/TermsOfUse.jsx`, `PrivacyPolicy.jsx`, `CommunityGuidelines.jsx`). +> 2. In `backend/src/config/legal.ts`: set the new `CURRENT_TERMS_VERSION` **and move the old value into `PREVIOUS_TERMS_VERSIONS` in the same commit.** +> 3. Set the identical string in `frontend/src/constants/legal.ts`. +> 4. `cd backend && npm test -- auth` — the `PREVIOUS_TERMS_VERSIONS` cases must pass. +> 5. **Deploy the backend first, then build the app.** Never the reverse: a build that ships ahead of the deploy sends a version the API has never heard of. +> 6. Verify against production before submitting: register a throwaway account on the new build, and confirm the currently-live App Store build can still accept terms. +> +> Prune `PREVIOUS_TERMS_VERSIONS` only once the build carrying that version is out of circulation, not at the next bump. The accepted version is recorded as-submitted rather than forced to `CURRENT_TERMS_VERSION`, because a user on an older build genuinely saw older text. + +## 9. Design/UX pass > Read `AUDIT_2026.md` Part 2 (D1–D7) and the Lumen 2.0 token table before proposing changes; wireframes live in `docs/wireframes-2026/` (regenerate: `node gen-wireframes.mjs .`). Constraints: blur only on chrome (dock/sheets), solid card surfaces, WCAG AA contrast, OSU scarlet accent.