feat(db)!: SnapOtter 2.0 phase 1 foundation: postgres, migrator, compose stack (#216)

* feat(infra): add dev compose stack with postgres and redis

* fix(infra): comment dev env defaults until wired; harden dev compose restart and start_period

* chore(deps): add pg driver and testcontainers for postgres migration

* feat(db): translate schema to drizzle pg-core (timestamptz, boolean, pgEnum, jsonb)

Schema translation (apps/api/src/db/schema.ts):
- sqlite-core -> pg-core, all 10 tables preserved 1:1
- integer(mode:'timestamp') -> timestamp({ withTimezone: true })
- integer(mode:'boolean') -> boolean
- jobs.status text enum -> pgEnum('job_status') with same 4 values
- 7 columns changed from text to jsonb: jobs.inputFiles, jobs.settings,
  pipelines.steps, apiKeys.permissions, roles.permissions,
  auditLog.details, userFiles.toolChain
- settings.value stays text, jobs.error stays text, jobs.progress stays real

jsonb call-site sweep (removed JSON.stringify on writes, JSON.parse on reads):
- apps/api/src/routes/roles.ts: permissions read/write (3 sites)
- apps/api/src/routes/api-keys.ts: permissions write + read (2 sites)
- apps/api/src/routes/audit-log.ts: details read (1 site)
- apps/api/src/routes/pipeline.ts: steps write + read (2 sites)
- apps/api/src/routes/progress.ts: inputFiles write (2 sites)
- apps/api/src/routes/tool-factory.ts: toolChain read + write (2 sites)
- apps/api/src/routes/user-files.ts: toolChain read + write (4 sites)
- apps/api/src/permissions.ts: roles.permissions read (1 site)
- apps/api/src/lib/audit.ts: details write (1 site)
- apps/api/src/plugins/auth.ts: apiKeys.permissions read (1 site)

* refactor(db): type jsonb columns via $type and note raw CTE conversion requirements

* feat(db): archive sqlite migrations and generate postgres baseline

* chore(db): dockerignore legacy migrations, add archive breadcrumb, fix trailing newline

* feat(db): pg pool connection, advisory-locked boot migrations, DATABASE_URL config

* fix(db): friendly fatal on unreachable postgres, idempotent closeDb, lock-key convention note

* refactor(db): async drizzle calls in plugins, lib, permissions

* fix(api): analytics never throws, typed permission guard, single-query session invalidation

* refactor(db): async drizzle calls across all routes and bootstrap

Convert every route file and index.ts from sync SQLite drizzle
patterns to async node-postgres drizzle:

- .all() removed (bare await on select)
- .get() converted to destructured [row] = await ...
- .run() removed (bare await on insert/update/delete)
- .changes replaced with .rowCount (null-guarded) in progress.ts
- sqlite import removed from user-files.ts; raw CTEs converted to
  await db.execute(sql`...`) with postgres-dialect recursive CTEs
- ChainRow types updated: tool_chain is parsed jsonb (string[] | null),
  created_at is Date (timestamptz) with no * 1000 conversion
- All requirePermission() guard calls awaited (security: unawaited
  async guard returns truthy Promise, bypassing permission check)
- All hasEffectivePermission() and getPermissions() calls awaited
- All auditLog() calls awaited (preserves write-before-response order)
- trackEvent() and captureException() left un-awaited (fire-and-forget
  by design, guaranteed never-throw)
- ensureAnonymousUser(), startCleanupCron(), recoverStaleJobs() awaited
  in bootstrap sequence
- ensureInstanceId() and ensureDefaultSettings() made async

Files converted: 14 (index.ts + 12 route files + tools/index.ts)

* fix(db): await async checkStorageQuota in user-files upload/save routes

* fix(db): await checkStorageQuota in save-result route (missed second call site)

* feat(db): sqlite-to-postgres migrator with CLI and first-boot import

* fix(db): migrator error context, honest force semantics, boot-hook fatal, null-variance tests

* test: run suite against per-file postgres databases via testcontainers

- Add tests/global-setup.ts: spins up a Postgres testcontainer,
  creates a migrated template database once per vitest run.
- Rewrite tests/setup/per-fork-env.ts: each test file (forks pool)
  clones the template into its own database via CREATE DATABASE ...
  TEMPLATE, preserving the same per-file isolation granularity.
- Update vitest.config.ts: add globalSetup, pg alias, update comment.
- Fix tests/integration/test-server.ts: remove DB_PATH mkdir, async
  runMigrations, async db operations, remove SQLite WAL checkpoint.
- Fix 21 unit test db/index mocks: add pool and closeDb exports.
- Fix 8 unit test files: add async/await for now-async permission,
  audit, and analytics functions.
- Fix 18 integration test files: convert sync .run()/.all()/.get()
  to async drizzle patterns, add async to callbacks.
- Production change: apps/api/src/routes/teams.ts: cast COUNT(*)
  to ::int so Postgres returns a number instead of bigint string.

* fix(db): seed built-in roles, reject NUL bytes, cast COUNT, serialize job persists

- Seed built-in roles (admin, editor, user) at boot via ensureBuiltinRoles()
  with onConflictDoNothing, restoring data that legacy SQLite migration 0007
  provided via INSERT statements (the pg baseline is DDL-only).
- Reject NUL bytes in login credentials with 401 (postgres rejects \x00 in
  text columns; valid usernames never contain NUL, matching 1.x behavior).
- Cast COUNT(*)::int in user-files, audit-log, and roles listing queries so
  postgres returns a JS number instead of bigint-as-string.
- Serialize fire-and-forget job progress DB writes per jobId so the final
  "completed" status is never overwritten by a late-arriving "processing"
  write (race condition exposed by async postgres round-trips).

* test: fix teams race, seed roles in test server, poll for job status

- Add missing await to resetTeams() in teams PUT beforeEach (the async
  delete raced with the subsequent insert under postgres).
- Call ensureBuiltinRoles() in test server bootstrap so integration tests
  have the same built-in roles as production.
- Replace fixed 100ms flushPersist delay with a polling helper that waits
  for terminal job status, eliminating timing-dependent failures caused by
  postgres network round-trip latency.

* test: make heic temp-file cleanup assertion resilient to concurrent workers

Use a set-based diff instead of raw file count when checking that
decodeHeic cleans up temp files. Other concurrent test workers can
create heic-in-*/heic-out-* files in the shared tmpdir, inflating the
"after" count and causing spurious failures under full-suite load.

* fix(db): align builtin-role seed to post-0010 legacy state; test polish

* feat(docker): three-container compose (app, postgres, redis) with boot wait and migrations

* fix(docker): set TEST_DATABASE_URL so containerized tests skip testcontainers

* chore(docker): test compose project name, clearer 1.x upgrade comment, unref probe timer

* feat(enterprise): enforce D15 license boundary; move s3 storage into packages/enterprise

* fix(enterprise): restore lazy aws-sdk loading; community installs load no s3 code at boot

* fix(enterprise): boundary check catches dynamic imports; document getS3 concurrency

* feat(db)!: SnapOtter 2.0 phase 1 foundation: postgres, migrator, compose stack

BREAKING CHANGE: SQLite is no longer the runtime database. Deployments now
require Postgres (and Redis, used from phase 2). Existing installs migrate
with SQLITE_MIGRATE_PATH or 'pnpm --filter @snapotter/api migrate:sqlite'.

* fix(ci): postgres service + fresh e2e database per run; ignore unfixable torch CVE-2025-3000
This commit is contained in:
SnapOtter
2026-06-13 10:15:23 +08:00
committed by GitHub
parent 4ec39c556f
commit 1c724d5d21
124 changed files with 3891 additions and 1703 deletions
+8 -9
View File
@@ -23,11 +23,10 @@ export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
};
}
const row = db
const [row] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, "instance_id"))
.get();
.where(eq(schema.settings.key, "instance_id"));
return {
enabled: true,
@@ -56,28 +55,28 @@ export async function analyticsRoutes(app: FastifyInstance): Promise<void> {
if (body.remindLater) {
const remindAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
db.update(schema.users)
await db
.update(schema.users)
.set({
analyticsEnabled: null,
analyticsConsentShownAt: now,
analyticsConsentRemindAt: remindAt,
updatedAt: now,
})
.where(eq(schema.users.id, user.id))
.run();
.where(eq(schema.users.id, user.id));
return reply.send({ ok: true, analyticsEnabled: null });
}
const enabled = body.enabled === true;
db.update(schema.users)
await db
.update(schema.users)
.set({
analyticsEnabled: enabled,
analyticsConsentShownAt: now,
analyticsConsentRemindAt: null,
updatedAt: now,
})
.where(eq(schema.users.id, user.id))
.run();
.where(eq(schema.users.id, user.id));
return reply.send({ ok: true, analyticsEnabled: enabled });
});
}
+20 -24
View File
@@ -38,7 +38,7 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
let scopedPermissions: string[] | null = null;
if (body.permissions && body.permissions.length > 0) {
const userPerms = getPermissions(user.role);
const userPerms = await getPermissions(user.role);
const permSet = new Set<string>(userPerms);
const invalid = body.permissions.filter((p) => !permSet.has(p));
if (invalid.length > 0) {
@@ -73,22 +73,20 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
const id = randomUUID();
try {
db.insert(schema.apiKeys)
.values({
id,
userId: user.id,
keyHash,
keyPrefix,
name,
permissions: scopedPermissions ? JSON.stringify(scopedPermissions) : null,
expiresAt,
})
.run();
await db.insert(schema.apiKeys).values({
id,
userId: user.id,
keyHash,
keyPrefix,
name,
permissions: scopedPermissions,
expiresAt,
});
} catch {
return reply.status(409).send({ error: "Failed to create API key" });
}
auditLog(request.log, "API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name });
await auditLog(request.log, "API_KEY_CREATED", { userId: user.id, keyId: id, keyName: name });
// Return the raw key ONCE — it cannot be retrieved again
return reply.status(201).send({
@@ -114,19 +112,18 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
lastUsedAt: schema.apiKeys.lastUsedAt,
expiresAt: schema.apiKeys.expiresAt,
};
const keys = hasEffectivePermission(user, "apikeys:all")
? db.select(selectFields).from(schema.apiKeys).all()
: db
const keys = (await hasEffectivePermission(user, "apikeys:all"))
? await db.select(selectFields).from(schema.apiKeys)
: await db
.select(selectFields)
.from(schema.apiKeys)
.where(eq(schema.apiKeys.userId, user.id))
.all();
.where(eq(schema.apiKeys.userId, user.id));
return reply.send({
apiKeys: keys.map((k) => ({
id: k.id,
name: k.name,
permissions: k.permissions ? JSON.parse(k.permissions) : null,
permissions: k.permissions ?? null,
createdAt: k.createdAt.toISOString(),
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
expiresAt: k.expiresAt?.toISOString() ?? null,
@@ -144,11 +141,10 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
const { id } = request.params;
// Ensure the key belongs to the requesting user
const existing = db
const [existing] = await db
.select()
.from(schema.apiKeys)
.where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.userId, user.id)))
.get();
.where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.userId, user.id)));
if (!existing) {
return reply.status(404).send({
@@ -157,9 +153,9 @@ export async function apiKeyRoutes(app: FastifyInstance): Promise<void> {
});
}
db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)).run();
await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id));
auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id });
await auditLog(request.log, "API_KEY_DELETED", { userId: user.id, keyId: id });
return reply.send({ ok: true });
},
+7 -9
View File
@@ -18,7 +18,7 @@ export async function auditLogRoutes(app: FastifyInstance): Promise<void> {
}>,
reply: FastifyReply,
) => {
const user = requirePermission("audit:read")(request, reply);
const user = await requirePermission("audit:read")(request, reply);
if (!user) return;
const page = Math.max(1, parseInt(request.query.page ?? "1", 10) || 1);
@@ -45,20 +45,18 @@ export async function auditLogRoutes(app: FastifyInstance): Promise<void> {
const where = conditions.length > 0 ? and(...conditions) : undefined;
const entries = db
const entries = await db
.select()
.from(schema.auditLog)
.where(where)
.orderBy(desc(schema.auditLog.createdAt))
.limit(limit)
.offset(offset)
.all();
.offset(offset);
const countResult = db
.select({ count: sql<number>`count(*)` })
const [countResult] = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.auditLog)
.where(where)
.get();
.where(where);
return reply.send({
entries: entries.map((e) => ({
@@ -68,7 +66,7 @@ export async function auditLogRoutes(app: FastifyInstance): Promise<void> {
action: e.action,
targetType: e.targetType,
targetId: e.targetId,
details: e.details ? JSON.parse(e.details) : null,
details: e.details ?? null,
ipAddress: e.ipAddress,
createdAt: e.createdAt.toISOString(),
})),
+2 -3
View File
@@ -4,11 +4,10 @@ import { db, schema } from "../db/index.js";
export async function configRoutes(app: FastifyInstance): Promise<void> {
app.get("/api/v1/config/locale", async (_request, reply) => {
const row = db
const [row] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, "defaultLocale"))
.get();
.where(eq(schema.settings.key, "defaultLocale"));
return reply.send({ defaultLocale: row?.value ?? "en" });
});
}
+3 -3
View File
@@ -129,7 +129,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
app.post(
"/api/v1/admin/features/:bundleId/install",
async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => {
const admin = requirePermission("features:manage")(request, reply);
const admin = await requirePermission("features:manage")(request, reply);
if (!admin) return;
const { bundleId } = request.params;
@@ -280,7 +280,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
app.post(
"/api/v1/admin/features/:bundleId/uninstall",
async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => {
const admin = requirePermission("features:manage")(request, reply);
const admin = await requirePermission("features:manage")(request, reply);
if (!admin) return;
const { bundleId } = request.params;
@@ -355,7 +355,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
app.get(
"/api/v1/admin/features/disk-usage",
async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requirePermission("features:manage")(request, reply);
const admin = await requirePermission("features:manage")(request, reply);
if (!admin) return;
const totalBytes = getDirSize(getAiDir());
+16 -15
View File
@@ -352,15 +352,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
const id = randomUUID();
try {
db.insert(schema.pipelines)
.values({
id,
userId: user.id,
name,
description: description ?? null,
steps: JSON.stringify(steps),
})
.run();
await db.insert(schema.pipelines).values({
id,
userId: user.id,
name,
description: description ?? null,
steps,
});
} catch {
return reply.status(409).send({ error: "Failed to save pipeline" });
}
@@ -384,8 +382,8 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
if (!user) return;
// Admins see all pipelines; regular users see their own + legacy (no owner)
const allRows = db.select().from(schema.pipelines).all();
const rows = hasEffectivePermission(user, "pipelines:all")
const allRows = await db.select().from(schema.pipelines);
const rows = (await hasEffectivePermission(user, "pipelines:all"))
? allRows
: allRows.filter((row) => !row.userId || row.userId === user.id);
@@ -393,7 +391,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
id: row.id,
name: row.name,
description: row.description,
steps: JSON.parse(row.steps),
steps: row.steps,
createdAt: row.createdAt.toISOString(),
}));
@@ -413,7 +411,10 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
const { id } = request.params;
const existing = db.select().from(schema.pipelines).where(eq(schema.pipelines.id, id)).get();
const [existing] = await db
.select()
.from(schema.pipelines)
.where(eq(schema.pipelines.id, id));
if (!existing) {
return reply.status(404).send({ error: "Pipeline not found" });
@@ -423,12 +424,12 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
if (
existing.userId &&
existing.userId !== user.id &&
!hasEffectivePermission(user, "pipelines:all")
!(await hasEffectivePermission(user, "pipelines:all"))
) {
return reply.status(403).send({ error: "Not authorized to delete this pipeline" });
}
db.delete(schema.pipelines).where(eq(schema.pipelines.id, id)).run();
await db.delete(schema.pipelines).where(eq(schema.pipelines.id, id));
return reply.send({ ok: true });
},
+63 -44
View File
@@ -47,18 +47,43 @@ const listeners = new Map<string, Set<(data: JobProgress | SingleFileProgress) =
// ── DB persistence helpers ──────────────────────────────────────────
function persistJobProgress(progress: JobProgress): void {
/**
* Per-job serialization queues. Fire-and-forget persist calls for the same
* jobId must run sequentially so that the final "completed" write is never
* overwritten by a late-arriving "processing" write. Without this, the
* async Postgres round-trips can re-order concurrent writes.
*/
// TODO(phase-2): delete when progress persistence moves to BullMQ job events.
const persistQueues = new Map<string, Promise<void>>();
/** Await any pending persist writes for a specific job (used by tests). */
export async function drainPersistQueue(jobId: string): Promise<void> {
const pending = persistQueues.get(jobId);
if (pending) await pending;
}
function enqueuePersist(jobId: string, fn: () => Promise<void>): void {
const prev = persistQueues.get(jobId) ?? Promise.resolve();
const next = prev.then(fn, fn); // run even if prior rejected
persistQueues.set(jobId, next);
// Clean up the map entry once the queue drains
next.then(() => {
if (persistQueues.get(jobId) === next) persistQueues.delete(jobId);
});
}
async function persistJobProgress(progress: JobProgress): Promise<void> {
try {
const completionRatio =
progress.totalFiles > 0 ? progress.completedFiles / progress.totalFiles : 0;
const existing = db
const [existing] = await db
.select({ id: schema.jobs.id })
.from(schema.jobs)
.where(eq(schema.jobs.id, progress.jobId))
.get();
.where(eq(schema.jobs.id, progress.jobId));
if (existing) {
db.update(schema.jobs)
await db
.update(schema.jobs)
.set({
status: progress.status,
progress: completionRatio,
@@ -66,26 +91,25 @@ function persistJobProgress(progress: JobProgress): void {
completedAt:
progress.status === "completed" || progress.status === "failed" ? new Date() : null,
})
.where(eq(schema.jobs.id, progress.jobId))
.run();
.where(eq(schema.jobs.id, progress.jobId));
} else {
db.insert(schema.jobs)
.values({
id: progress.jobId,
type: "batch",
status: progress.status,
progress: completionRatio,
inputFiles: JSON.stringify({ totalFiles: progress.totalFiles }),
error: progress.errors.length > 0 ? JSON.stringify(progress.errors) : null,
})
.run();
await db.insert(schema.jobs).values({
id: progress.jobId,
type: "batch",
status: progress.status,
progress: completionRatio,
inputFiles: { totalFiles: progress.totalFiles },
error: progress.errors.length > 0 ? JSON.stringify(progress.errors) : null,
});
}
} catch {
// DB persistence is best-effort; don't break real-time SSE
}
}
function persistSingleFileProgress(progress: Omit<SingleFileProgress, "type">): void {
async function persistSingleFileProgress(
progress: Omit<SingleFileProgress, "type">,
): Promise<void> {
try {
const status =
progress.phase === "complete"
@@ -93,33 +117,30 @@ function persistSingleFileProgress(progress: Omit<SingleFileProgress, "type">):
: progress.phase === "failed"
? "failed"
: "processing";
const existing = db
const [existing] = await db
.select({ id: schema.jobs.id })
.from(schema.jobs)
.where(eq(schema.jobs.id, progress.jobId))
.get();
.where(eq(schema.jobs.id, progress.jobId));
if (existing) {
db.update(schema.jobs)
await db
.update(schema.jobs)
.set({
status,
progress: progress.percent / 100,
error: progress.error ?? null,
completedAt: status === "completed" || status === "failed" ? new Date() : null,
})
.where(eq(schema.jobs.id, progress.jobId))
.run();
.where(eq(schema.jobs.id, progress.jobId));
} else {
db.insert(schema.jobs)
.values({
id: progress.jobId,
type: "single",
status,
progress: progress.percent / 100,
inputFiles: "[]",
error: progress.error ?? null,
})
.run();
await db.insert(schema.jobs).values({
id: progress.jobId,
type: "single",
status,
progress: progress.percent / 100,
inputFiles: [],
error: progress.error ?? null,
});
}
} catch {
// Best-effort
@@ -130,27 +151,25 @@ function persistSingleFileProgress(progress: Omit<SingleFileProgress, "type">):
* Mark any jobs left in "processing" or "queued" state as failed.
* Called once at startup to recover from unclean shutdown.
*/
export function recoverStaleJobs(): void {
export async function recoverStaleJobs(): Promise<void> {
try {
const result = db
const result = await db
.update(schema.jobs)
.set({
status: "failed",
error: "Server restarted while job was in progress",
completedAt: new Date(),
})
.where(eq(schema.jobs.status, "processing"))
.run();
const result2 = db
.where(eq(schema.jobs.status, "processing"));
const result2 = await db
.update(schema.jobs)
.set({
status: "failed",
error: "Server restarted while job was queued",
completedAt: new Date(),
})
.where(eq(schema.jobs.status, "queued"))
.run();
const total = result.changes + result2.changes;
.where(eq(schema.jobs.status, "queued"));
const total = (result.rowCount ?? 0) + (result2.rowCount ?? 0);
if (total > 0) {
console.log(`Recovered ${total} stale jobs from previous run`);
}
@@ -166,7 +185,7 @@ export function recoverStaleJobs(): void {
*/
export function updateJobProgress(progress: JobProgress): void {
jobProgressStore.set(progress.jobId, progress);
persistJobProgress(progress);
enqueuePersist(progress.jobId, () => persistJobProgress(progress));
// Notify all SSE listeners (add type: "batch" so the frontend can distinguish
// batch events from single-file events in the shared SSE stream)
const subs = listeners.get(progress.jobId);
@@ -187,7 +206,7 @@ export function updateJobProgress(progress: JobProgress): void {
export function updateSingleFileProgress(progress: Omit<SingleFileProgress, "type">): void {
const event: SingleFileProgress = { ...progress, type: "single" };
persistSingleFileProgress(progress);
enqueuePersist(progress.jobId, () => persistSingleFileProgress(progress));
if (progress.phase === "complete" || progress.phase === "failed") {
if (singleFileCompletions.size >= 10_000) {
+33 -36
View File
@@ -53,18 +53,17 @@ const updateRoleSchema = z.object({
export async function rolesRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/roles — List all roles (requires audit:read to view)
app.get("/api/v1/roles", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("audit:read")(request, reply);
const user = await requirePermission("audit:read")(request, reply);
if (!user) return;
const roles = db.select().from(schema.roles).all();
const userCounts = db
const roles = await db.select().from(schema.roles);
const userCounts = await db
.select({
role: schema.users.role,
count: sql<number>`COUNT(*)`,
count: sql<number>`COUNT(*)::int`,
})
.from(schema.users)
.groupBy(schema.users.role)
.all();
.groupBy(schema.users.role);
const countMap = new Map(userCounts.map((r) => [r.role, r.count]));
return reply.send({
@@ -72,7 +71,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
id: r.id,
name: r.name,
description: r.description,
permissions: JSON.parse(r.permissions),
permissions: r.permissions,
isBuiltin: r.isBuiltin,
userCount: countMap.get(r.name) ?? 0,
createdAt: r.createdAt.toISOString(),
@@ -83,7 +82,7 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/roles — Create custom role
app.post("/api/v1/roles", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("users:manage")(request, reply);
const user = await requirePermission("users:manage")(request, reply);
if (!user) return;
const parsed = createRoleSchema.safeParse(request.body);
@@ -102,24 +101,22 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
.send({ error: `Invalid permissions: ${invalid.join(", ")}`, code: "VALIDATION_ERROR" });
}
const existing = db.select().from(schema.roles).where(eq(schema.roles.name, name)).get();
const [existing] = await db.select().from(schema.roles).where(eq(schema.roles.name, name));
if (existing) {
return reply.status(409).send({ error: "Role name already exists", code: "CONFLICT" });
}
const id = randomUUID();
db.insert(schema.roles)
.values({
id,
name,
description: description?.trim() ?? "",
permissions: JSON.stringify(permissions),
isBuiltin: false,
createdBy: user.id,
})
.run();
await db.insert(schema.roles).values({
id,
name,
description: description?.trim() ?? "",
permissions,
isBuiltin: false,
createdBy: user.id,
});
auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name });
await auditLog(request.log, "ROLE_CREATED", { adminId: user.id, roleId: id, roleName: name });
return reply.status(201).send({
id,
@@ -134,11 +131,11 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
app.put(
"/api/v1/roles/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requirePermission("users:manage")(request, reply);
const user = await requirePermission("users:manage")(request, reply);
if (!user) return;
const { id } = request.params;
const role = db.select().from(schema.roles).where(eq(schema.roles.id, id)).get();
const [role] = await db.select().from(schema.roles).where(eq(schema.roles.id, id));
if (!role) {
return reply.status(404).send({ error: "Role not found", code: "NOT_FOUND" });
}
@@ -159,15 +156,15 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (body.name) {
const dup = db.select().from(schema.roles).where(eq(schema.roles.name, body.name)).get();
const [dup] = await db.select().from(schema.roles).where(eq(schema.roles.name, body.name));
if (dup && dup.id !== id) {
return reply.status(409).send({ error: "Role name already exists", code: "CONFLICT" });
}
// Update users on old role name to new name
db.update(schema.users)
await db
.update(schema.users)
.set({ role: body.name })
.where(eq(schema.users.role, role.name))
.run();
.where(eq(schema.users.role, role.name));
updates.name = body.name;
}
if (body.description !== undefined) {
@@ -181,11 +178,11 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
code: "VALIDATION_ERROR",
});
}
updates.permissions = JSON.stringify(body.permissions);
updates.permissions = body.permissions;
}
db.update(schema.roles).set(updates).where(eq(schema.roles.id, id)).run();
auditLog(request.log, "ROLE_UPDATED", { adminId: user.id, roleId: id });
await db.update(schema.roles).set(updates).where(eq(schema.roles.id, id));
await auditLog(request.log, "ROLE_UPDATED", { adminId: user.id, roleId: id });
return reply.send({ ok: true });
},
@@ -195,11 +192,11 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
app.delete(
"/api/v1/roles/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requirePermission("users:manage")(request, reply);
const user = await requirePermission("users:manage")(request, reply);
if (!user) return;
const { id } = request.params;
const role = db.select().from(schema.roles).where(eq(schema.roles.id, id)).get();
const [role] = await db.select().from(schema.roles).where(eq(schema.roles.id, id));
if (!role) {
return reply.status(404).send({ error: "Role not found", code: "NOT_FOUND" });
}
@@ -209,13 +206,13 @@ export async function rolesRoutes(app: FastifyInstance): Promise<void> {
.send({ error: "Cannot delete built-in roles", code: "VALIDATION_ERROR" });
}
db.update(schema.users)
await db
.update(schema.users)
.set({ role: "user", updatedAt: new Date() })
.where(eq(schema.users.role, role.name))
.run();
.where(eq(schema.users.role, role.name));
db.delete(schema.roles).where(eq(schema.roles.id, id)).run();
auditLog(request.log, "ROLE_DELETED", {
await db.delete(schema.roles).where(eq(schema.roles.id, id));
await auditLog(request.log, "ROLE_DELETED", {
adminId: user.id,
roleId: id,
roleName: role.name,
+12 -9
View File
@@ -27,7 +27,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
if (!user) return;
const isAdmin = user.role === "admin";
const rows = db.select().from(schema.settings).all();
const rows = await db.select().from(schema.settings);
const settings: Record<string, string> = {};
for (const row of rows) {
@@ -40,7 +40,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
// PUT /api/v1/settings — Save settings (admin only)
app.put("/api/v1/settings", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requirePermission("settings:write")(request, reply);
const admin = await requirePermission("settings:write")(request, reply);
if (!admin) return;
const parsed = settingsBodySchema.safeParse(request.body);
@@ -75,20 +75,23 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
for (const { key, strValue } of entries) {
// Upsert: insert or update on conflict
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
const [existing] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, key));
if (existing) {
db.update(schema.settings)
await db
.update(schema.settings)
.set({ value: strValue, updatedAt: now })
.where(eq(schema.settings.key, key))
.run();
.where(eq(schema.settings.key, key));
} else {
db.insert(schema.settings).values({ key, value: strValue }).run();
await db.insert(schema.settings).values({ key, value: strValue });
}
}
if (entries.length > 0) {
auditLog(request.log, "SETTINGS_UPDATED", {
await auditLog(request.log, "SETTINGS_UPDATED", {
adminId: admin.id,
username: admin.username,
keys: entries.map((e) => e.key),
@@ -111,7 +114,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
return reply.status(403).send({ error: "Forbidden", code: "FORBIDDEN" });
}
const row = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
const [row] = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
if (!row) {
return reply.status(404).send({
+19 -23
View File
@@ -29,18 +29,17 @@ const teamNameSchema = z.object({
export async function teamsRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/teams — List all teams with member count (admin only)
app.get("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requirePermission("teams:manage")(request, reply);
const user = await requirePermission("teams:manage")(request, reply);
if (!user) return;
const teams = db
const teams = await db
.select({
id: schema.teams.id,
name: schema.teams.name,
memberCount: sql<number>`(SELECT COUNT(*) FROM users WHERE users.team = ${schema.teams.id})`,
memberCount: sql<number>`(SELECT COUNT(*)::int FROM users WHERE users.team = ${schema.teams.id})`,
createdAt: schema.teams.createdAt,
})
.from(schema.teams)
.all();
.from(schema.teams);
return reply.send({
teams: teams.map((t) => ({
@@ -52,7 +51,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
// POST /api/v1/teams — Create team (admin only)
app.post("/api/v1/teams", async (request: FastifyRequest, reply: FastifyReply) => {
const admin = requirePermission("teams:manage")(request, reply);
const admin = await requirePermission("teams:manage")(request, reply);
if (!admin) return;
const parsed = teamNameSchema.safeParse(request.body);
@@ -65,11 +64,10 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
const trimmedName = parsed.data.name;
// Check for duplicate name (case-insensitive)
const existing = db
const [existing] = await db
.select()
.from(schema.teams)
.where(sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName})`)
.get();
.where(sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName})`);
if (existing) {
return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" });
@@ -77,7 +75,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
const id = randomUUID();
db.insert(schema.teams).values({ id, name: trimmedName }).run();
await db.insert(schema.teams).values({ id, name: trimmedName });
return reply.status(201).send({ id, name: trimmedName });
});
@@ -86,12 +84,12 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
app.put(
"/api/v1/teams/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requirePermission("teams:manage")(request, reply);
const admin = await requirePermission("teams:manage")(request, reply);
if (!admin) return;
const { id } = request.params;
const team = db.select().from(schema.teams).where(eq(schema.teams.id, id)).get();
const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, id));
if (!team) {
return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" });
}
@@ -106,19 +104,18 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
const trimmedName = parsed.data.name;
// Check for duplicate name (case-insensitive), excluding current team
const duplicate = db
const [duplicate] = await db
.select()
.from(schema.teams)
.where(
sql`LOWER(${schema.teams.name}) = LOWER(${trimmedName}) AND ${schema.teams.id} != ${id}`,
)
.get();
);
if (duplicate) {
return reply.status(409).send({ error: "Team name already exists", code: "CONFLICT" });
}
db.update(schema.teams).set({ name: trimmedName }).where(eq(schema.teams.id, id)).run();
await db.update(schema.teams).set({ name: trimmedName }).where(eq(schema.teams.id, id));
return reply.send({ ok: true });
},
@@ -128,12 +125,12 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
app.delete(
"/api/v1/teams/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = requirePermission("teams:manage")(request, reply);
const admin = await requirePermission("teams:manage")(request, reply);
if (!admin) return;
const { id } = request.params;
const team = db.select().from(schema.teams).where(eq(schema.teams.id, id)).get();
const [team] = await db.select().from(schema.teams).where(eq(schema.teams.id, id));
if (!team) {
return reply.status(404).send({ error: "Team not found", code: "NOT_FOUND" });
}
@@ -147,11 +144,10 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
}
// Cannot delete a team that has members
const memberCount = db
.select({ count: sql<number>`COUNT(*)` })
const [memberCount] = await db
.select({ count: sql<number>`COUNT(*)::int` })
.from(schema.users)
.where(eq(schema.users.team, id))
.get();
.where(eq(schema.users.team, id));
if (memberCount && memberCount.count > 0) {
return reply.status(400).send({
@@ -160,7 +156,7 @@ export async function teamsRoutes(app: FastifyInstance): Promise<void> {
});
}
db.delete(schema.teams).where(eq(schema.teams.id, id)).run();
await db.delete(schema.teams).where(eq(schema.teams.id, id));
return reply.send({ ok: true });
},
+16 -19
View File
@@ -457,14 +457,13 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
if (fileId) {
try {
const { saveFile } = await import("../lib/file-storage.js");
const parent = db
const [parent] = await db
.select()
.from(schema.userFiles)
.where(eq(schema.userFiles.id, fileId))
.get();
.where(eq(schema.userFiles.id, fileId));
if (parent) {
const newVersion = parent.version + 1;
const parentChain: string[] = parent.toolChain ? JSON.parse(parent.toolChain) : [];
const parentChain: string[] = parent.toolChain ?? [];
const newToolChain = [...parentChain, config.toolId];
const storedName = await saveFile(result.buffer, result.filename);
// Get image dimensions from the processed output
@@ -478,21 +477,19 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
// dimensions are non-critical
}
const newId = randomUUID();
db.insert(schema.userFiles)
.values({
id: newId,
userId: parent.userId,
originalName: result.filename,
storedName,
mimeType: result.contentType,
size: result.buffer.length,
width,
height,
version: newVersion,
parentId: fileId,
toolChain: JSON.stringify(newToolChain),
})
.run();
await db.insert(schema.userFiles).values({
id: newId,
userId: parent.userId,
originalName: result.filename,
storedName,
mimeType: result.contentType,
size: result.buffer.length,
width,
height,
version: newVersion,
parentId: fileId,
toolChain: newToolChain,
});
savedFileId = newId;
}
} catch (saveErr) {
+4 -6
View File
@@ -65,19 +65,17 @@ import { registerWatermarkText } from "./watermark-text.js";
*/
export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
// Read disabled tools from settings
const disabledRow = db
const [disabledRow] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, "disabledTools"))
.get();
.where(eq(schema.settings.key, "disabledTools"));
const disabledTools: string[] = disabledRow ? JSON.parse(disabledRow.value) : [];
// Read experimental flag
const expRow = db
const [expRow] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, "enableExperimentalTools"))
.get();
.where(eq(schema.settings.key, "enableExperimentalTools"));
const enableExperimental = expRow?.value === "true";
// Get experimental tool IDs from shared constants
+119 -116
View File
@@ -16,7 +16,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { env } from "../config.js";
import { db, schema, sqlite } from "../db/index.js";
import { db, schema } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import {
deleteStoredFile,
@@ -76,7 +76,7 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) {
height: row.height,
version: row.version,
parentId: row.parentId,
toolChain: row.toolChain ? JSON.parse(row.toolChain) : [],
toolChain: row.toolChain ?? [],
createdAt: row.createdAt.toISOString(),
};
}
@@ -85,14 +85,13 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) {
* Check whether a user has exceeded their storage quota.
* Returns the total bytes used, or throws if the quota is exceeded.
*/
function checkStorageQuota(userId: string | null): void {
async function checkStorageQuota(userId: string | null): Promise<void> {
if (!userId || env.MAX_STORAGE_PER_USER_MB <= 0) return;
const result = db
const [result] = await db
.select({ total: sql<number>`coalesce(sum(${schema.userFiles.size}), 0)` })
.from(schema.userFiles)
.where(eq(schema.userFiles.userId, userId))
.get();
.where(eq(schema.userFiles.userId, userId));
const usedBytes = result?.total ?? 0;
const limitBytes = env.MAX_STORAGE_PER_USER_MB * 1024 * 1024;
@@ -145,7 +144,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
const conditions = [latestCondition];
// Users without files:all only see their own files
if (!hasEffectivePermission(user, "files:all")) {
if (!(await hasEffectivePermission(user, "files:all"))) {
conditions.push(eq(schema.userFiles.userId, user.id));
}
@@ -154,21 +153,19 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
conditions.push(like(schema.userFiles.originalName, `%${escaped}%`));
}
const rows = db
const rows = await db
.select()
.from(schema.userFiles)
.where(and(...conditions))
.orderBy(desc(schema.userFiles.createdAt))
.limit(limit)
.offset(offset)
.all();
.offset(offset);
// Total count (for pagination)
const countResult = db
.select({ count: sql<number>`count(*)` })
const [countResult] = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.userFiles)
.where(and(...conditions))
.get();
.where(and(...conditions));
return reply.send({
files: rows.map(serializeFile),
@@ -194,7 +191,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
// Enforce per-user storage quota before accepting uploads
try {
checkStorageQuota(userId);
await checkStorageQuota(userId);
} catch (err) {
const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413;
return reply.status(statusCode).send({ error: (err as Error).message });
@@ -236,26 +233,24 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
// Create DB record
const id = randomUUID();
try {
db.insert(schema.userFiles)
.values({
id,
userId,
originalName: safeName,
storedName,
mimeType,
size: safeBuffer.length,
width: validation.width,
height: validation.height,
version: 1,
parentId: null,
toolChain: null,
})
.run();
await db.insert(schema.userFiles).values({
id,
userId,
originalName: safeName,
storedName,
mimeType,
size: safeBuffer.length,
width: validation.width,
height: validation.height,
version: 1,
parentId: null,
toolChain: null,
});
} catch {
return reply.status(409).send({ error: "Failed to save file record" });
}
const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
const [row] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
if (row) created.push(serializeFile(row));
}
@@ -264,7 +259,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
return reply.status(400).send({ error: "No valid files uploaded" });
}
auditLog(request.log, "FILE_UPLOADED", {
await auditLog(request.log, "FILE_UPLOADED", {
userId,
count: created.length,
files: created.map((f) => f.originalName),
@@ -288,15 +283,22 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
const { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) {
if (
!file ||
(file.userId !== user.id && !(await hasEffectivePermission(user, "files:all")))
) {
return reply.status(404).send({ error: "File not found" });
}
// Walk the full version chain using a recursive CTE.
// First find the root ancestor, then collect all descendants.
interface ChainRow {
//
// node-postgres returns:
// tool_chain as parsed jsonb (string[] | null) - do NOT JSON.parse
// created_at as Date (timestamptz) - use directly, no * 1000
type ChainRow = {
id: string;
original_name: string;
mime_type: string;
@@ -305,35 +307,34 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
height: number | null;
version: number;
parent_id: string | null;
tool_chain: string | null;
created_at: number;
}
tool_chain: string[] | null;
created_at: Date;
};
const chainRows = sqlite
.prepare(`
WITH RECURSIVE
ancestors(id, parent_id) AS (
SELECT id, parent_id FROM user_files WHERE id = ?
UNION ALL
SELECT uf.id, uf.parent_id FROM user_files uf
INNER JOIN ancestors a ON uf.id = a.parent_id
),
chain(id, original_name, mime_type, size, width, height,
version, parent_id, tool_chain, created_at) AS (
SELECT f.id, f.original_name, f.mime_type, f.size, f.width, f.height,
f.version, f.parent_id, f.tool_chain, f.created_at
FROM user_files f
WHERE f.id = (SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1)
UNION ALL
SELECT child.id, child.original_name, child.mime_type, child.size,
child.width, child.height, child.version, child.parent_id,
child.tool_chain, child.created_at
FROM user_files child
INNER JOIN chain c ON child.parent_id = c.id
)
SELECT * FROM chain ORDER BY version ASC
`)
.all(id) as ChainRow[];
const cteResult = await db.execute<ChainRow>(sql`
WITH RECURSIVE
ancestors(id, parent_id) AS (
SELECT id, parent_id FROM user_files WHERE id = ${id}
UNION ALL
SELECT uf.id, uf.parent_id FROM user_files uf
INNER JOIN ancestors a ON uf.id = a.parent_id
),
chain(id, original_name, mime_type, size, width, height,
version, parent_id, tool_chain, created_at) AS (
SELECT f.id, f.original_name, f.mime_type, f.size, f.width, f.height,
f.version, f.parent_id, f.tool_chain, f.created_at
FROM user_files f
WHERE f.id = (SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1)
UNION ALL
SELECT child.id, child.original_name, child.mime_type, child.size,
child.width, child.height, child.version, child.parent_id,
child.tool_chain, child.created_at
FROM user_files child
INNER JOIN chain c ON child.parent_id = c.id
)
SELECT * FROM chain ORDER BY version ASC
`);
const chainRows = cteResult.rows;
const versions = chainRows.map((r) => ({
id: r.id,
@@ -344,8 +345,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
height: r.height,
version: r.version,
parentId: r.parent_id,
toolChain: r.tool_chain ? JSON.parse(r.tool_chain) : [],
createdAt: new Date(r.created_at * 1000).toISOString(),
toolChain: r.tool_chain ?? [],
createdAt: new Date(r.created_at).toISOString(),
}));
return reply.send({
@@ -368,9 +369,12 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
const { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) {
if (
!file ||
(file.userId !== user.id && !(await hasEffectivePermission(user, "files:all")))
) {
return reply.status(404).send({ error: "File not found" });
}
@@ -404,9 +408,12 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
const { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) {
if (
!file ||
(file.userId !== user.id && !(await hasEffectivePermission(user, "files:all")))
) {
return reply.status(404).send({ error: "File not found" });
}
@@ -476,49 +483,48 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
let deletedCount = 0;
interface DeleteChainRow {
type DeleteChainRow = {
id: string;
stored_name: string;
}
};
for (const id of ids) {
// Ownership check: non-admin users can only delete their own files
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all")))
const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
if (!file || (file.userId !== user.id && !(await hasEffectivePermission(user, "files:all"))))
continue;
// Collect all files in the chain using a recursive CTE
const chainRows = sqlite
.prepare(`
WITH RECURSIVE chain(id, stored_name) AS (
SELECT f.id, f.stored_name
FROM user_files f
WHERE f.id = (
WITH RECURSIVE ancestors(id, parent_id) AS (
SELECT id, parent_id FROM user_files WHERE id = ?
UNION ALL
SELECT uf.id, uf.parent_id FROM user_files uf
INNER JOIN ancestors a ON uf.id = a.parent_id
)
SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1
)
const cteResult = await db.execute<DeleteChainRow>(sql`
WITH RECURSIVE chain(id, stored_name) AS (
SELECT f.id, f.stored_name
FROM user_files f
WHERE f.id = (
WITH RECURSIVE ancestors(id, parent_id) AS (
SELECT id, parent_id FROM user_files WHERE id = ${id}
UNION ALL
SELECT child.id, child.stored_name
FROM user_files child
INNER JOIN chain c ON child.parent_id = c.id
SELECT uf.id, uf.parent_id FROM user_files uf
INNER JOIN ancestors a ON uf.id = a.parent_id
)
SELECT id, stored_name FROM chain
`)
.all(id) as DeleteChainRow[];
SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1
)
UNION ALL
SELECT child.id, child.stored_name
FROM user_files child
INNER JOIN chain c ON child.parent_id = c.id
)
SELECT id, stored_name FROM chain
`);
const chainRows = cteResult.rows;
for (const row of chainRows) {
await deleteStoredFile(row.stored_name);
await deleteThumbnail(row.stored_name);
db.delete(schema.userFiles).where(eq(schema.userFiles.id, row.id)).run();
await db.delete(schema.userFiles).where(eq(schema.userFiles.id, row.id));
deletedCount++;
}
}
auditLog(request.log, "FILE_DELETED", { userId: user.id, count: deletedCount, ids });
await auditLog(request.log, "FILE_DELETED", { userId: user.id, count: deletedCount, ids });
return reply.send({ deleted: deletedCount });
});
@@ -538,7 +544,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
// Enforce per-user storage quota before saving results
try {
checkStorageQuota(userId);
await checkStorageQuota(userId);
} catch (err) {
const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413;
return reply.status(statusCode).send({ error: (err as Error).message });
@@ -582,11 +588,10 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
}
// Look up the parent to compute the next version and carry forward the tool chain
const parent = db
const [parent] = await db
.select()
.from(schema.userFiles)
.where(eq(schema.userFiles.id, parentId))
.get();
.where(eq(schema.userFiles.id, parentId));
if (!parent) {
return reply.status(404).send({ error: "Parent file not found" });
@@ -595,7 +600,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
const nextVersion = parent.version + 1;
// Build the tool chain: append the new toolId to the parent's chain
const existingChain: string[] = parent.toolChain ? JSON.parse(parent.toolChain) : [];
const existingChain: string[] = parent.toolChain ?? [];
const newChain = toolId ? [...existingChain, toolId] : existingChain;
// Determine the original filename (preserve parent's name, update extension)
@@ -614,26 +619,24 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
// Create DB record
const id = randomUUID();
try {
db.insert(schema.userFiles)
.values({
id,
userId,
originalName: resultName,
storedName,
mimeType,
size: safeResultBuffer.length,
width: validation.width,
height: validation.height,
version: nextVersion,
parentId,
toolChain: JSON.stringify(newChain),
})
.run();
await db.insert(schema.userFiles).values({
id,
userId,
originalName: resultName,
storedName,
mimeType,
size: safeResultBuffer.length,
width: validation.width,
height: validation.height,
version: nextVersion,
parentId,
toolChain: newChain,
});
} catch {
return reply.status(409).send({ error: "Failed to save result record" });
}
const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
const [row] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id));
return reply.status(201).send({ file: row ? serializeFile(row) : null });
});