diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 190b807..31c5134 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -241,6 +241,7 @@ export const PHOTO_HISTORY_MAX = 3; // `verified` the client reads. // `pro` is the admin's own 0/1 grant — the "Activated Pro" box in the users // table, and the only way an unproven address reaches the PRO tier. +// `createdAt` is the signup instant, which the PRO cutoff reads (see server.ts). export type User = { id: number; email: string; @@ -249,6 +250,7 @@ export type User = { deletedAt: string | null; emailVerified: number; pro: number; + createdAt: string; }; export type Recipe = { id: number; @@ -282,10 +284,11 @@ const now = () => new Date().toISOString(); export function createUser(email: string, password: string): User | null { try { + const createdAt = now(); const info = db .prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)') - .run(email, hashPassword(password), now()); - return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0, pro: 0 }; + .run(email, hashPassword(password), createdAt); + return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0, pro: 0, createdAt }; } catch (err) { if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null; throw err; @@ -295,14 +298,14 @@ export function createUser(email: string, password: string): User | null { export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined { return db .prepare( - 'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro, password_hash FROM users WHERE email = ?', + 'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro, created_at AS createdAt, password_hash FROM users WHERE email = ?', ) .get(email) as (User & { password_hash: string }) | undefined; } export function findUserById(id: number): User | undefined { return db - .prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro FROM users WHERE id = ?') + .prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro, created_at AS createdAt FROM users WHERE id = ?') .get(id) as User | undefined; } @@ -604,6 +607,7 @@ export type AdminUser = { blocked: number; deletedAt: string | null; pro: number; + emailVerified: number; }; export function listUsersWithCounts(): AdminUser[] { @@ -611,7 +615,8 @@ export function listUsersWithCounts(): AdminUser[] { .prepare( `SELECT users.id AS id, users.email AS email, users.created_at AS createdAt, users.avatar AS avatar, users.blocked AS blocked, - users.deleted_at AS deletedAt, users.pro AS pro, COUNT(photos.id) AS photos + users.deleted_at AS deletedAt, users.pro AS pro, + users.email_verified AS emailVerified, COUNT(photos.id) AS photos FROM users LEFT JOIN photos ON photos.user_id = users.id GROUP BY users.id ORDER BY users.id`, diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index eb3cf2a..b16db8c 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -86,7 +86,7 @@ const ADMIN_EMAILS = new Set( .map((s) => s.trim().toLowerCase()) .filter(Boolean), ); -const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase()); +const isAdmin = (user: Pick) => ADMIN_EMAILS.has(user.email.toLowerCase()); // What an account is worth. Being signed in is the basic tier and nothing more // is required of it (see requireMember): the account has its own recipes and @@ -94,13 +94,21 @@ const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase()); // carries on top. Admins come from the deployment's own allowlist — trusted by // construction, so no letter is needed and a broken relay cannot lock the // operator out of their own site. -const isVerified = (user: User) => user.emailVerified === 1 || isAdmin(user); +const isVerified = (user: Pick) => user.emailVerified === 1 || isAdmin(user); -// The PRO tier itself: a proven address, an admin, or a grant the operator -// ticked in the users table. The grant only ever adds — unticking an account -// that has already proven its address leaves it PRO, because the address is -// still the stronger proof of the two. -const isPro = (user: User) => isVerified(user) || user.pro === 1; +// The one date in the tier model: an account that signed up before it keeps the +// studio it was promised, so its "Activated Pro" box is ticked by the calendar +// rather than by the operator. Signups from the cutoff on start basic and the +// box is theirs to tick (or the address to prove). +const PRO_CUTOFF_MS = Date.parse('2026-12-01T00:00:00Z'); +const grandfathered = (createdAt: string) => Date.parse(createdAt) < PRO_CUTOFF_MS; + +// The PRO tier itself: a proven address, an admin, a grandfathered signup, or a +// grant the operator ticked in the users table. The grant only ever adds — +// unticking an account that has already proven its address leaves it PRO, +// because the address is still the stronger proof of the two. +const isPro = (user: Pick) => + isVerified(user) || user.pro === 1 || grandfathered(user.createdAt); // The public shape of an account. `admin` is the allowlist's answer, so the // client can decide whether to offer /admin without a second round trip — and @@ -1011,9 +1019,10 @@ app.get('/api/admin/users', async (req, reply) => { admin: ADMIN_EMAILS.has(u.email), blocked: !!u.blocked, removed: !!u.deletedAt, - // An allowlisted account is PRO by construction; its stored box is not - // what decides, so the table shows the truth of `isPro`. - pro: !!u.pro || ADMIN_EMAILS.has(u.email.toLowerCase()), + // The box shows the truth of `isPro`, not the stored column: an + // allowlisted operator and a signup from before the cutoff are PRO + // whatever the column says, and unticking them cannot take that away. + pro: isPro(u), })), }); }); @@ -1062,7 +1071,7 @@ app.patch<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) const row = listUsersWithCounts().find((u) => u.id === id); return reply .status(200) - .send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt, pro: !!row?.pro } }); + .send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt, pro: !!row && isPro(row) } }); }); app.delete<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => {