import { APIErrorCode, APIException, type Category, type CreateCategoryRequest, type Dump, type UpdateCategoryRequest, } from "../model/interfaces.ts"; import { categoryRowToApi, db, DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED, dumpRowToApi, isCategoryRow, isDumpRow, } from "../model/db.ts"; import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts"; import { VALIDATION } from "../config.ts"; function validateSlug(slug: string): void { if (!isValidSlugFormat(slug)) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "Slug must be lowercase letters, numbers and hyphens", ); } if (slug.length > VALIDATION.CATEGORY_SLUG_MAX) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, `Slug must be at most ${VALIDATION.CATEGORY_SLUG_MAX} characters`, ); } if (isReservedSlug(slug)) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, `Slug "${slug}" is reserved`, ); } } function requireName(name: string): string { const trimmed = name.trim(); if (!trimmed) { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Name is required"); } if (trimmed.length > VALIDATION.CATEGORY_NAME_MAX) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, `Name must be at most ${VALIDATION.CATEGORY_NAME_MAX} characters`, ); } return trimmed; } // Maps SQLite UNIQUE violations to a friendly 409 naming the offending field. function uniqueConflict(err: unknown, slug: string, name: string): never { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("categories.name")) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 409, `Name "${name}" is already in use`, ); } if (msg.includes("UNIQUE") || msg.includes("unique")) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 409, `Slug "${slug}" is already in use`, ); } throw err as Error; } export function getCategoryById(id: string): Category { const row = db.prepare(`SELECT * FROM categories WHERE id = ?;`).get(id); if (!row || !isCategoryRow(row)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found"); } return categoryRowToApi(row); } export function getCategoryBySlug(slug: string): Category { const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug); if (!row || !isCategoryRow(row)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found"); } return categoryRowToApi(row); } /** Resolve a slug to its category id, or undefined if no such category. */ export function getCategoryIdBySlug(slug: string): string | undefined { const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug); return row && typeof (row as { id?: unknown }).id === "string" ? (row as { id: string }).id : undefined; } export function listCategories(): Category[] { const rows = db.prepare( `SELECT * FROM categories ORDER BY position ASC, slug ASC;`, ).all(); return rows.filter(isCategoryRow).map(categoryRowToApi); } export function createCategory(req: CreateCategoryRequest): Category { validateSlug(req.slug); const name = requireName(req.name); const id = crypto.randomUUID(); const createdAt = new Date(); try { db.prepare( `INSERT INTO categories (id, slug, name, position, created_at) VALUES (?, ?, ?, ?, ?);`, ).run( id, req.slug, name, req.position ?? 0, createdAt.toISOString(), ); } catch (err) { uniqueConflict(err, req.slug, name); } return { id, slug: req.slug, name, position: req.position ?? 0, createdAt, }; } export function updateCategory( id: string, req: UpdateCategoryRequest, ): Category { const existing = getCategoryById(id); const newSlug = req.slug ?? existing.slug; if (req.slug !== undefined && req.slug !== existing.slug) { validateSlug(req.slug); } const newName = req.name !== undefined ? requireName(req.name) : existing.name; const newPosition = req.position ?? existing.position; const now = new Date(); try { db.prepare( `UPDATE categories SET slug = ?, name = ?, position = ?, updated_at = ? WHERE id = ?;`, ).run( newSlug, newName, newPosition, now.toISOString(), id, ); } catch (err) { uniqueConflict(err, newSlug, newName); } return { ...existing, slug: newSlug, name: newName, position: newPosition, updatedAt: now, }; } export function deleteCategory(id: string): void { const result = db.prepare(`DELETE FROM categories WHERE id = ?;`).run(id); if (result.changes === 0) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found"); } } /** * Replace the set of categories a dump belongs to. Unknown category ids are * rejected. An empty list clears all category assignments. */ export function setDumpCategories( dumpId: string, categoryIds: string[], ): void { const unique = [...new Set(categoryIds)]; if (unique.length > 0) { const placeholders = unique.map(() => "?").join(", "); const found = db.prepare( `SELECT COUNT(*) as count FROM categories WHERE id IN (${placeholders});`, ).get(...unique) as { count: number } | undefined; if ((found?.count ?? 0) !== unique.length) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, "One or more categories do not exist", ); } } db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId); const insert = db.prepare( `INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`, ); for (const categoryId of unique) { insert.run(dumpId, categoryId); } } /** Fill `categoryIds` on each dump with a single batched lookup. */ export function attachCategoryIds(dumps: Dump[]): void { if (dumps.length === 0) return; const ids = dumps.map((d) => d.id); const placeholders = ids.map(() => "?").join(", "); const rows = db.prepare( `SELECT dump_id, category_id FROM dump_categories WHERE dump_id IN (${placeholders});`, ).all(...ids) as Array<{ dump_id: string; category_id: string }>; const byDump = new Map(); for (const { dump_id, category_id } of rows) { const list = byDump.get(dump_id) ?? []; list.push(category_id); byDump.set(dump_id, list); } for (const dump of dumps) { dump.categoryIds = byDump.get(dump.id) ?? []; } } export function listDumpsByCategory( slug: string, page: number, limit: number, requestingUserId?: string, ): { items: Dump[]; total: number } { const category = getCategoryBySlug(slug); const offset = (page - 1) * limit; const privacyClause = requestingUserId ? "(d.is_private = 0 OR d.user_id = ?)" : "d.is_private = 0"; const privacyParams = requestingUserId ? [requestingUserId] : []; const rows = db.prepare( `SELECT ${SELECT_COLS_ALIASED} FROM dumps d INNER JOIN dump_categories dc ON dc.dump_id = d.id WHERE dc.category_id = ? AND ${privacyClause} ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`, ).all(category.id, ...privacyParams, limit, offset); const totalRow = db.prepare( `SELECT COUNT(*) as count FROM dumps d INNER JOIN dump_categories dc ON dc.dump_id = d.id WHERE dc.category_id = ? AND ${privacyClause};`, ).get(category.id, ...privacyParams) as { count: number } | undefined; const items = rows.filter(isDumpRow).map(dumpRowToApi); attachCategoryIds(items); return { items, total: totalRow?.count ?? 0 }; }