Tier model: registered=basic (recipes, 12 photos, QR), PRO=verified or admin-activated (HSL, tools/gradient mask, RAW, export > source longest edge); admin users table gets Activated Pro column

This commit is contained in:
2026-09-30 11:50:28 +07:00
parent 35d8d57984
commit 74c0b2747f
10 changed files with 264 additions and 122 deletions
+26 -4
View File
@@ -172,6 +172,17 @@ export const serializeSlots = (slots: readonly PhotoSlot[]): string =>
}
}
// PRO, the operator's own switch: the admin ticks it in the users table and the
// account reads the studio's PRO features from then on, proven address or not.
// It only ever adds to what a verified account already has — unticking it
// cannot take the tier away from an address that has been proven.
{
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
if (!cols.some((c) => c.name === 'pro')) {
db.exec(`ALTER TABLE users ADD COLUMN pro INTEGER NOT NULL DEFAULT 0`);
}
}
// The mailed code, added once visitors were expected to prove an address
// without leaving the page. Rows minted before it keep working as links: their
// `code` is NULL, which no typed guess can match (see verifyEmailCode).
@@ -228,6 +239,8 @@ export const PHOTO_HISTORY_MAX = 3;
// `avatar` is the stored file name, or null for "no picture".
// `emailVerified` is 0/1 from SQLite; the route layer turns it into the
// `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.
export type User = {
id: number;
email: string;
@@ -235,6 +248,7 @@ export type User = {
blocked: number;
deletedAt: string | null;
emailVerified: number;
pro: number;
};
export type Recipe = {
id: number;
@@ -271,7 +285,7 @@ export function createUser(email: string, password: string): User | null {
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 };
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0, pro: 0 };
} catch (err) {
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
throw err;
@@ -281,14 +295,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, password_hash FROM users WHERE email = ?',
'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro, 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 FROM users WHERE id = ?')
.prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, pro FROM users WHERE id = ?')
.get(id) as User | undefined;
}
@@ -589,6 +603,7 @@ export type AdminUser = {
avatar: string | null;
blocked: number;
deletedAt: string | null;
pro: number;
};
export function listUsersWithCounts(): AdminUser[] {
@@ -596,7 +611,7 @@ 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, COUNT(photos.id) AS photos
users.deleted_at AS deletedAt, users.pro AS pro, COUNT(photos.id) AS photos
FROM users LEFT JOIN photos ON photos.user_id = users.id
GROUP BY users.id
ORDER BY users.id`,
@@ -613,6 +628,13 @@ export function setUserBlocked(id: number, blocked: boolean): boolean {
return info.changes > 0;
}
// The PRO grant, ticked or unticked from the users table. No session sweep: the
// tier is read off the row on every request, so the next one already sees it.
export function setUserPro(id: number, pro: boolean): boolean {
const info = db.prepare('UPDATE users SET pro = ? WHERE id = ?').run(pro ? 1 : 0, id);
return info.changes > 0;
}
export function setUserRemoved(id: number, removed: boolean): boolean {
const info = db
.prepare('UPDATE users SET deleted_at = ? WHERE id = ?')