feat(project): init
This commit is contained in:
999
lib/db.ts
Normal file
999
lib/db.ts
Normal file
@@ -0,0 +1,999 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import Database from "better-sqlite3";
|
||||
import { appConfig } from "./config";
|
||||
import {
|
||||
buildStoredImageDownloadUrl,
|
||||
buildStoredImageUrl,
|
||||
} from "@/lib/image-storage";
|
||||
import type {
|
||||
ImageHistoryItem,
|
||||
GenerationJobStatus,
|
||||
SessionRecord,
|
||||
SettingsSnapshot,
|
||||
StoredGeneratedImageInput,
|
||||
StoredImageAsset,
|
||||
UsageEventMeta,
|
||||
UserRecord,
|
||||
UserRole,
|
||||
UserUsageSummary,
|
||||
} from "@/lib/types";
|
||||
|
||||
type SqliteDatabase = InstanceType<typeof Database>;
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: UserRole;
|
||||
monthly_quota: number | null;
|
||||
is_active: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface UserSummaryRow extends UserRow {
|
||||
used_this_period?: number | null;
|
||||
}
|
||||
|
||||
interface SettingRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface UsageRow {
|
||||
used_units?: number | null;
|
||||
}
|
||||
|
||||
interface TableInfoRow {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface StoredImageAssetRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
file_name: string;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
storage_key: string;
|
||||
}
|
||||
|
||||
interface ImageHistoryRow extends StoredImageAssetRow {
|
||||
job_id: string;
|
||||
username: string;
|
||||
prompt: string;
|
||||
revised_prompt: string | null;
|
||||
model: string | null;
|
||||
size: string | null;
|
||||
quality: string | null;
|
||||
output_format: string | null;
|
||||
background: string | null;
|
||||
moderation: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __imagePromptStudioDb: SqliteDatabase | undefined;
|
||||
}
|
||||
|
||||
function initializeDatabase(): SqliteDatabase {
|
||||
const databaseDirectory = path.dirname(appConfig.databasePath);
|
||||
|
||||
fs.mkdirSync(databaseDirectory, { recursive: true });
|
||||
|
||||
const database = new Database(appConfig.databasePath);
|
||||
|
||||
database.pragma("journal_mode = WAL");
|
||||
database.pragma("foreign_keys = ON");
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
|
||||
monthly_quota INTEGER,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
units INTEGER NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
meta_json TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS generation_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
|
||||
image_count INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER,
|
||||
model TEXT,
|
||||
size TEXT,
|
||||
quality TEXT,
|
||||
output_format TEXT,
|
||||
background TEXT,
|
||||
moderation TEXT,
|
||||
output_compression INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS generated_images (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
revised_prompt TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES generation_jobs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_user_period ON usage_events(user_id, period_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_generation_jobs_user_id ON generation_jobs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_generated_images_user_created_at ON generated_images(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_generated_images_job_id ON generated_images(job_id);
|
||||
`);
|
||||
|
||||
ensureColumnExists(database, "generation_jobs", "model", "TEXT");
|
||||
ensureColumnExists(database, "generation_jobs", "size", "TEXT");
|
||||
ensureColumnExists(database, "generation_jobs", "quality", "TEXT");
|
||||
ensureColumnExists(database, "generation_jobs", "output_format", "TEXT");
|
||||
ensureColumnExists(database, "generation_jobs", "background", "TEXT");
|
||||
ensureColumnExists(database, "generation_jobs", "moderation", "TEXT");
|
||||
ensureColumnExists(
|
||||
database,
|
||||
"generation_jobs",
|
||||
"output_compression",
|
||||
"INTEGER",
|
||||
);
|
||||
|
||||
upsertSettingRecord(
|
||||
database,
|
||||
"default_monthly_quota",
|
||||
String(appConfig.defaultMonthlyQuota),
|
||||
);
|
||||
ensureAdminUser(database);
|
||||
|
||||
return database;
|
||||
}
|
||||
|
||||
function ensureAdminUser(database: SqliteDatabase): void {
|
||||
const now = new Date().toISOString();
|
||||
const existingAdmin = database
|
||||
.prepare("SELECT id FROM users WHERE username = ?")
|
||||
.get(appConfig.adminUsername) as { id: string } | undefined;
|
||||
|
||||
const passwordHash = bcrypt.hashSync(appConfig.adminPassword, 12);
|
||||
|
||||
if (existingAdmin) {
|
||||
if (!appConfig.adminSyncOnBoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET
|
||||
password_hash = ?,
|
||||
role = 'admin',
|
||||
monthly_quota = NULL,
|
||||
is_active = 1,
|
||||
updated_at = ?
|
||||
WHERE username = ?
|
||||
`,
|
||||
)
|
||||
.run(passwordHash, now, appConfig.adminUsername);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT OR IGNORE INTO users (
|
||||
id,
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
monthly_quota,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, 'admin', NULL, 1, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(randomUUID(), appConfig.adminUsername, passwordHash, now, now);
|
||||
}
|
||||
|
||||
function upsertSettingRecord(
|
||||
database: SqliteDatabase,
|
||||
key: string,
|
||||
value: string,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
)
|
||||
.run(key, value, new Date().toISOString());
|
||||
}
|
||||
|
||||
function ensureColumnExists(
|
||||
database: SqliteDatabase,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
columnSql: string,
|
||||
): void {
|
||||
const columns = database
|
||||
.prepare(`PRAGMA table_info(${tableName})`)
|
||||
.all() as TableInfoRow[];
|
||||
|
||||
if (columns.some((column) => column.name === columnName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
database.exec(
|
||||
`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnSql}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!globalThis.__imagePromptStudioDb) {
|
||||
globalThis.__imagePromptStudioDb = initializeDatabase();
|
||||
}
|
||||
|
||||
export const db = globalThis.__imagePromptStudioDb as SqliteDatabase;
|
||||
|
||||
export function syncAdminUserFromConfig(): void {
|
||||
ensureAdminUser(db);
|
||||
}
|
||||
|
||||
export function normalizeUsername(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function getUserById(id: string): UserRecord | null {
|
||||
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as
|
||||
| UserRow
|
||||
| undefined;
|
||||
|
||||
return mapUserRecord(row);
|
||||
}
|
||||
|
||||
export function getUserByUsername(username: string): UserRecord | null {
|
||||
const row = db
|
||||
.prepare("SELECT * FROM users WHERE username = ?")
|
||||
.get(normalizeUsername(username)) as UserRow | undefined;
|
||||
|
||||
return mapUserRecord(row);
|
||||
}
|
||||
|
||||
export function listUserSummaries(periodKey: string): UserUsageSummary[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
u.*,
|
||||
COALESCE(SUM(CASE WHEN ue.period_key = ? THEN ue.units ELSE 0 END), 0) AS used_this_period
|
||||
FROM users u
|
||||
LEFT JOIN usage_events ue ON ue.user_id = u.id
|
||||
GROUP BY u.id
|
||||
ORDER BY CASE u.role WHEN 'admin' THEN 0 ELSE 1 END, u.created_at ASC
|
||||
`,
|
||||
)
|
||||
.all(periodKey) as UserSummaryRow[];
|
||||
|
||||
return rows.map((row) => {
|
||||
const user = mapUserRecord(row);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Failed to map user record.");
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
usedThisPeriod: Number(row.used_this_period ?? 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createUserRecord({
|
||||
username,
|
||||
passwordHash,
|
||||
role,
|
||||
monthlyQuota,
|
||||
}: {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
role: UserRole;
|
||||
monthlyQuota: number | string | null | undefined;
|
||||
}): UserRecord {
|
||||
const now = new Date().toISOString();
|
||||
const id = randomUUID();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO users (
|
||||
id,
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
monthly_quota,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 1, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
normalizeUsername(username),
|
||||
passwordHash,
|
||||
role,
|
||||
normalizeMonthlyQuota(monthlyQuota),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
const user = getUserById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Failed to create user record.");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export function updateUserRecord(
|
||||
id: string,
|
||||
updates: {
|
||||
role?: UserRole;
|
||||
monthlyQuota?: number | string | null;
|
||||
isActive?: boolean;
|
||||
passwordHash?: string;
|
||||
},
|
||||
): UserRecord | null {
|
||||
const currentUser = getUserById(id);
|
||||
|
||||
if (!currentUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextUser = {
|
||||
...currentUser,
|
||||
role: updates.role ?? currentUser.role,
|
||||
monthlyQuota:
|
||||
updates.monthlyQuota === undefined
|
||||
? currentUser.monthlyQuota
|
||||
: normalizeMonthlyQuota(updates.monthlyQuota),
|
||||
isActive:
|
||||
updates.isActive === undefined
|
||||
? currentUser.isActive
|
||||
: Boolean(updates.isActive),
|
||||
passwordHash: updates.passwordHash ?? currentUser.passwordHash,
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET
|
||||
role = ?,
|
||||
monthly_quota = ?,
|
||||
is_active = ?,
|
||||
password_hash = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
).run(
|
||||
nextUser.role,
|
||||
nextUser.monthlyQuota,
|
||||
nextUser.isActive ? 1 : 0,
|
||||
nextUser.passwordHash,
|
||||
new Date().toISOString(),
|
||||
id,
|
||||
);
|
||||
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
export function createSessionRecord({
|
||||
sessionId,
|
||||
userId,
|
||||
expiresAt,
|
||||
}: SessionRecord): void {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO sessions (id, user_id, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`,
|
||||
).run(sessionId, userId, expiresAt, new Date().toISOString());
|
||||
}
|
||||
|
||||
export function deleteSessionRecord(sessionId: string): void {
|
||||
db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
|
||||
}
|
||||
|
||||
export function deleteExpiredSessions(): void {
|
||||
db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getSessionUser(sessionId: string): UserRecord | null {
|
||||
deleteExpiredSessions();
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT u.*
|
||||
FROM sessions s
|
||||
INNER JOIN users u ON u.id = s.user_id
|
||||
WHERE s.id = ? AND s.expires_at > ?
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(sessionId, new Date().toISOString()) as UserRow | undefined;
|
||||
|
||||
return mapUserRecord(row);
|
||||
}
|
||||
|
||||
export function getSettingsSnapshot(): SettingsSnapshot {
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM settings")
|
||||
.all() as SettingRow[];
|
||||
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||
|
||||
return {
|
||||
defaultMonthlyQuota: Number.parseInt(
|
||||
settings.default_monthly_quota ?? String(appConfig.defaultMonthlyQuota),
|
||||
10,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function setDefaultMonthlyQuota(
|
||||
value: number | string,
|
||||
): SettingsSnapshot {
|
||||
upsertSettingRecord(
|
||||
db,
|
||||
"default_monthly_quota",
|
||||
String(normalizeQuotaInput(value)),
|
||||
);
|
||||
|
||||
return getSettingsSnapshot();
|
||||
}
|
||||
|
||||
export function getUsageForUser(userId: string, periodKey: string): number {
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COALESCE(SUM(units), 0) AS used_units
|
||||
FROM usage_events
|
||||
WHERE user_id = ? AND period_key = ?
|
||||
`,
|
||||
)
|
||||
.get(userId, periodKey) as UsageRow | undefined;
|
||||
|
||||
return Number(row?.used_units ?? 0);
|
||||
}
|
||||
|
||||
export function recordUsageEvent({
|
||||
userId,
|
||||
units,
|
||||
periodKey,
|
||||
meta,
|
||||
}: {
|
||||
userId: string;
|
||||
units: number;
|
||||
periodKey: string;
|
||||
meta?: UsageEventMeta | null;
|
||||
}): void {
|
||||
insertUsageEvent(db, {
|
||||
userId,
|
||||
units,
|
||||
periodKey,
|
||||
meta,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export function recordCompletedGeneration({
|
||||
jobId = randomUUID(),
|
||||
userId,
|
||||
prompt,
|
||||
imageCount,
|
||||
durationMs,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
outputFormat,
|
||||
background,
|
||||
moderation,
|
||||
outputCompression = null,
|
||||
periodKey,
|
||||
usageMeta,
|
||||
images,
|
||||
}: {
|
||||
jobId?: string;
|
||||
userId: string;
|
||||
prompt: string;
|
||||
imageCount: number;
|
||||
durationMs: number | null;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: string;
|
||||
outputFormat: string;
|
||||
background: string;
|
||||
moderation: string;
|
||||
outputCompression?: number | null;
|
||||
periodKey: string;
|
||||
usageMeta?: UsageEventMeta | null;
|
||||
images: StoredGeneratedImageInput[];
|
||||
}): string {
|
||||
const now = new Date().toISOString();
|
||||
const commitGeneration = db.transaction(() => {
|
||||
insertGenerationJob(db, {
|
||||
id: jobId,
|
||||
userId,
|
||||
prompt,
|
||||
status: "success",
|
||||
imageCount,
|
||||
durationMs,
|
||||
errorMessage: null,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
outputFormat,
|
||||
background,
|
||||
moderation,
|
||||
outputCompression,
|
||||
createdAt: now,
|
||||
});
|
||||
insertGeneratedImages(db, {
|
||||
jobId,
|
||||
userId,
|
||||
images,
|
||||
createdAt: now,
|
||||
});
|
||||
insertUsageEvent(db, {
|
||||
userId,
|
||||
units: imageCount,
|
||||
periodKey,
|
||||
meta: usageMeta,
|
||||
createdAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
commitGeneration();
|
||||
|
||||
return jobId;
|
||||
}
|
||||
|
||||
export function listImageHistoryForViewer({
|
||||
viewerId,
|
||||
viewerRole,
|
||||
}: {
|
||||
viewerId: string;
|
||||
viewerRole: UserRole;
|
||||
}): ImageHistoryItem[] {
|
||||
const rows =
|
||||
viewerRole === "admin"
|
||||
? (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
gi.id,
|
||||
gi.job_id,
|
||||
gi.user_id,
|
||||
gi.file_name,
|
||||
gi.storage_key,
|
||||
gi.mime_type,
|
||||
gi.file_size,
|
||||
gi.revised_prompt,
|
||||
gi.created_at,
|
||||
u.username,
|
||||
gj.prompt,
|
||||
gj.model,
|
||||
gj.size,
|
||||
gj.quality,
|
||||
gj.output_format,
|
||||
gj.background,
|
||||
gj.moderation
|
||||
FROM generated_images gi
|
||||
INNER JOIN generation_jobs gj ON gj.id = gi.job_id
|
||||
INNER JOIN users u ON u.id = gi.user_id
|
||||
ORDER BY gi.created_at DESC
|
||||
`,
|
||||
)
|
||||
.all() as ImageHistoryRow[])
|
||||
: (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
gi.id,
|
||||
gi.job_id,
|
||||
gi.user_id,
|
||||
gi.file_name,
|
||||
gi.storage_key,
|
||||
gi.mime_type,
|
||||
gi.file_size,
|
||||
gi.revised_prompt,
|
||||
gi.created_at,
|
||||
u.username,
|
||||
gj.prompt,
|
||||
gj.model,
|
||||
gj.size,
|
||||
gj.quality,
|
||||
gj.output_format,
|
||||
gj.background,
|
||||
gj.moderation
|
||||
FROM generated_images gi
|
||||
INNER JOIN generation_jobs gj ON gj.id = gi.job_id
|
||||
INNER JOIN users u ON u.id = gi.user_id
|
||||
WHERE gi.user_id = ?
|
||||
ORDER BY gi.created_at DESC
|
||||
`,
|
||||
)
|
||||
.all(viewerId) as ImageHistoryRow[]);
|
||||
|
||||
return rows.map(mapImageHistoryItem);
|
||||
}
|
||||
|
||||
export function getStoredImageAssetForViewer({
|
||||
viewerId,
|
||||
viewerRole,
|
||||
imageId,
|
||||
}: {
|
||||
viewerId: string;
|
||||
viewerRole: UserRole;
|
||||
imageId: string;
|
||||
}): StoredImageAsset | null {
|
||||
const row =
|
||||
viewerRole === "admin"
|
||||
? (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
file_name,
|
||||
mime_type,
|
||||
file_size,
|
||||
storage_key
|
||||
FROM generated_images
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(imageId) as StoredImageAssetRow | undefined)
|
||||
: (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
file_name,
|
||||
mime_type,
|
||||
file_size,
|
||||
storage_key
|
||||
FROM generated_images
|
||||
WHERE id = ? AND user_id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(imageId, viewerId) as StoredImageAssetRow | undefined);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
fileName: row.file_name,
|
||||
mimeType: row.mime_type,
|
||||
fileSize: Number(row.file_size),
|
||||
storageKey: row.storage_key,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordGenerationJob({
|
||||
userId,
|
||||
prompt,
|
||||
status,
|
||||
imageCount = 0,
|
||||
durationMs = null,
|
||||
errorMessage = null,
|
||||
model = null,
|
||||
size = null,
|
||||
quality = null,
|
||||
outputFormat = null,
|
||||
background = null,
|
||||
moderation = null,
|
||||
outputCompression = null,
|
||||
}: {
|
||||
userId: string;
|
||||
prompt: string;
|
||||
status: GenerationJobStatus;
|
||||
imageCount?: number;
|
||||
durationMs?: number | null;
|
||||
errorMessage?: string | null;
|
||||
model?: string | null;
|
||||
size?: string | null;
|
||||
quality?: string | null;
|
||||
outputFormat?: string | null;
|
||||
background?: string | null;
|
||||
moderation?: string | null;
|
||||
outputCompression?: number | null;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
|
||||
insertGenerationJob(db, {
|
||||
id,
|
||||
userId,
|
||||
prompt,
|
||||
status,
|
||||
imageCount,
|
||||
durationMs,
|
||||
errorMessage,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
outputFormat,
|
||||
background,
|
||||
moderation,
|
||||
outputCompression,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function insertUsageEvent(
|
||||
database: SqliteDatabase,
|
||||
{
|
||||
userId,
|
||||
units,
|
||||
periodKey,
|
||||
meta,
|
||||
createdAt,
|
||||
}: {
|
||||
userId: string;
|
||||
units: number;
|
||||
periodKey: string;
|
||||
meta?: UsageEventMeta | null;
|
||||
createdAt: string;
|
||||
},
|
||||
): void {
|
||||
database.prepare(
|
||||
`
|
||||
INSERT INTO usage_events (id, user_id, units, period_key, created_at, meta_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
randomUUID(),
|
||||
userId,
|
||||
units,
|
||||
periodKey,
|
||||
createdAt,
|
||||
meta ? JSON.stringify(meta) : null,
|
||||
);
|
||||
}
|
||||
|
||||
function insertGenerationJob(
|
||||
database: SqliteDatabase,
|
||||
{
|
||||
id,
|
||||
userId,
|
||||
prompt,
|
||||
status,
|
||||
imageCount,
|
||||
durationMs,
|
||||
errorMessage,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
outputFormat,
|
||||
background,
|
||||
moderation,
|
||||
outputCompression,
|
||||
createdAt,
|
||||
}: {
|
||||
id: string;
|
||||
userId: string;
|
||||
prompt: string;
|
||||
status: GenerationJobStatus;
|
||||
imageCount: number;
|
||||
durationMs: number | null;
|
||||
errorMessage: string | null;
|
||||
model: string | null;
|
||||
size: string | null;
|
||||
quality: string | null;
|
||||
outputFormat: string | null;
|
||||
background: string | null;
|
||||
moderation: string | null;
|
||||
outputCompression: number | null;
|
||||
createdAt: string;
|
||||
},
|
||||
): void {
|
||||
database.prepare(
|
||||
`
|
||||
INSERT INTO generation_jobs (
|
||||
id,
|
||||
user_id,
|
||||
prompt,
|
||||
status,
|
||||
image_count,
|
||||
duration_ms,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
output_format,
|
||||
background,
|
||||
moderation,
|
||||
output_compression,
|
||||
error_message,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
userId,
|
||||
prompt,
|
||||
status,
|
||||
imageCount,
|
||||
durationMs,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
outputFormat,
|
||||
background,
|
||||
moderation,
|
||||
outputCompression,
|
||||
errorMessage,
|
||||
createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
function insertGeneratedImages(
|
||||
database: SqliteDatabase,
|
||||
{
|
||||
jobId,
|
||||
userId,
|
||||
images,
|
||||
createdAt,
|
||||
}: {
|
||||
jobId: string;
|
||||
userId: string;
|
||||
images: StoredGeneratedImageInput[];
|
||||
createdAt: string;
|
||||
},
|
||||
): void {
|
||||
const statement = database.prepare(
|
||||
`
|
||||
INSERT INTO generated_images (
|
||||
id,
|
||||
job_id,
|
||||
user_id,
|
||||
file_name,
|
||||
storage_key,
|
||||
mime_type,
|
||||
file_size,
|
||||
revised_prompt,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
|
||||
for (const image of images) {
|
||||
statement.run(
|
||||
image.id,
|
||||
jobId,
|
||||
userId,
|
||||
image.fileName,
|
||||
image.storageKey,
|
||||
image.mimeType,
|
||||
image.fileSize,
|
||||
image.revisedPrompt,
|
||||
createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapImageHistoryItem(row: ImageHistoryRow): ImageHistoryItem {
|
||||
return {
|
||||
id: row.id,
|
||||
jobId: row.job_id,
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
prompt: row.prompt,
|
||||
revisedPrompt: row.revised_prompt,
|
||||
model: row.model,
|
||||
size: row.size,
|
||||
quality: row.quality,
|
||||
outputFormat: row.output_format,
|
||||
background: row.background,
|
||||
moderation: row.moderation,
|
||||
fileName: row.file_name,
|
||||
mimeType: row.mime_type,
|
||||
fileSize: Number(row.file_size),
|
||||
createdAt: row.created_at,
|
||||
imageUrl: buildStoredImageUrl(row.id),
|
||||
downloadUrl: buildStoredImageDownloadUrl(row.id),
|
||||
};
|
||||
}
|
||||
|
||||
function mapUserRecord(row: UserRow | null | undefined): UserRecord | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
role: row.role,
|
||||
monthlyQuota:
|
||||
row.monthly_quota === null || row.monthly_quota === undefined
|
||||
? null
|
||||
: Number(row.monthly_quota),
|
||||
isActive: Boolean(row.is_active),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMonthlyQuota(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeQuotaInput(value);
|
||||
}
|
||||
|
||||
function normalizeQuotaInput(value: unknown): number {
|
||||
const numericValue = Number.parseInt(String(value ?? ""), 10);
|
||||
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return numericValue;
|
||||
}
|
||||
Reference in New Issue
Block a user