Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
112 changes: 112 additions & 0 deletions backend/prisma/check-screenshot-seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/// <reference types="node" />
/**
* 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();
});
38 changes: 38 additions & 0 deletions backend/src/config/legal.ts
Original file line number Diff line number Diff line change
@@ -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());
}
78 changes: 77 additions & 1 deletion backend/src/routes/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
});
});
17 changes: 12 additions & 5 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -152,7 +152,7 @@ router.post('/register', async (req: Request, res: Response): Promise<void> => {
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.',
});
Expand Down Expand Up @@ -207,7 +207,9 @@ router.post('/register', async (req: Request, res: Response): Promise<void> => {
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(),
},
Expand Down Expand Up @@ -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<void> => {
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;
Expand All @@ -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(),
},
Expand Down
21 changes: 20 additions & 1 deletion docs/PLAYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading