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:
@@ -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 = ?')
|
||||
|
||||
Reference in New Issue
Block a user