PRO cutoff: an account that signed up before 2026-12-01 keeps the whole studio with its Activated Pro box ticked by the calendar, signups from the cutoff on start basic

This commit is contained in:
2026-09-30 12:22:59 +07:00
parent 61b1210a47
commit e0b8f14dfc
2 changed files with 30 additions and 16 deletions
+10 -5
View File
@@ -241,6 +241,7 @@ export const PHOTO_HISTORY_MAX = 3;
// `verified` the client reads. // `verified` the client reads.
// `pro` is the admin's own 0/1 grant — the "Activated Pro" box in the users // `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. // 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 = { export type User = {
id: number; id: number;
email: string; email: string;
@@ -249,6 +250,7 @@ export type User = {
deletedAt: string | null; deletedAt: string | null;
emailVerified: number; emailVerified: number;
pro: number; pro: number;
createdAt: string;
}; };
export type Recipe = { export type Recipe = {
id: number; id: number;
@@ -282,10 +284,11 @@ const now = () => new Date().toISOString();
export function createUser(email: string, password: string): User | null { export function createUser(email: string, password: string): User | null {
try { try {
const createdAt = now();
const info = db const info = db
.prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)') .prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)')
.run(email, hashPassword(password), now()); .run(email, hashPassword(password), createdAt);
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0, pro: 0 }; return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0, pro: 0, createdAt };
} catch (err) { } catch (err) {
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null; if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
throw err; 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 { export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined {
return db return db
.prepare( .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; .get(email) as (User & { password_hash: string }) | undefined;
} }
export function findUserById(id: number): User | undefined { export function findUserById(id: number): User | undefined {
return db 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; .get(id) as User | undefined;
} }
@@ -604,6 +607,7 @@ export type AdminUser = {
blocked: number; blocked: number;
deletedAt: string | null; deletedAt: string | null;
pro: number; pro: number;
emailVerified: number;
}; };
export function listUsersWithCounts(): AdminUser[] { export function listUsersWithCounts(): AdminUser[] {
@@ -611,7 +615,8 @@ export function listUsersWithCounts(): AdminUser[] {
.prepare( .prepare(
`SELECT users.id AS id, users.email AS email, users.created_at AS createdAt, `SELECT users.id AS id, users.email AS email, users.created_at AS createdAt,
users.avatar AS avatar, users.blocked AS blocked, 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 FROM users LEFT JOIN photos ON photos.user_id = users.id
GROUP BY users.id GROUP BY users.id
ORDER BY users.id`, ORDER BY users.id`,
+20 -11
View File
@@ -86,7 +86,7 @@ const ADMIN_EMAILS = new Set(
.map((s) => s.trim().toLowerCase()) .map((s) => s.trim().toLowerCase())
.filter(Boolean), .filter(Boolean),
); );
const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase()); const isAdmin = (user: Pick<User, 'email'>) => ADMIN_EMAILS.has(user.email.toLowerCase());
// What an account is worth. Being signed in is the basic tier and nothing more // 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 // 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 // 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 // construction, so no letter is needed and a broken relay cannot lock the
// operator out of their own site. // operator out of their own site.
const isVerified = (user: User) => user.emailVerified === 1 || isAdmin(user); const isVerified = (user: Pick<User, 'email' | 'emailVerified'>) => user.emailVerified === 1 || isAdmin(user);
// The PRO tier itself: a proven address, an admin, or a grant the operator // The one date in the tier model: an account that signed up before it keeps the
// ticked in the users table. The grant only ever adds — unticking an account // studio it was promised, so its "Activated Pro" box is ticked by the calendar
// that has already proven its address leaves it PRO, because the address is // rather than by the operator. Signups from the cutoff on start basic and the
// still the stronger proof of the two. // box is theirs to tick (or the address to prove).
const isPro = (user: User) => isVerified(user) || user.pro === 1; 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<User, 'email' | 'emailVerified' | 'pro' | 'createdAt'>) =>
isVerified(user) || user.pro === 1 || grandfathered(user.createdAt);
// The public shape of an account. `admin` is the allowlist's answer, so the // 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 // 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), admin: ADMIN_EMAILS.has(u.email),
blocked: !!u.blocked, blocked: !!u.blocked,
removed: !!u.deletedAt, removed: !!u.deletedAt,
// An allowlisted account is PRO by construction; its stored box is not // The box shows the truth of `isPro`, not the stored column: an
// what decides, so the table shows the truth of `isPro`. // allowlisted operator and a signup from before the cutoff are PRO
pro: !!u.pro || ADMIN_EMAILS.has(u.email.toLowerCase()), // 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); const row = listUsersWithCounts().find((u) => u.id === id);
return reply return reply
.status(200) .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) => { app.delete<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => {