/** * Stores the categories and which dumps and playlists belong to them, a set * of categories for each {@link CategoryKind}. The category routes manage and * list them, the dump and playlist services file each dump or playlist they * write under its categories, and every service that lists dumps or playlists * fills in their `categoryIds` and filters them by category with * {@link categoryJoin}. * * @module */ import { APIException } from "../lib/api-exception.ts"; import type { Category, CategoryKind, CreateCategoryRequest, UpdateCategoryRequest, } from "../../shared/category.ts"; import type { Dump } from "../../shared/dump.ts"; import { categoryRowToApi, isCategoryRow } from "../model/category.ts"; import { db, transaction } from "../db/connection.ts"; import { DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED, dumpRowToApi, isDumpRow, } from "../model/dump.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { isReservedSlug } from "../lib/reserved-slugs.ts"; import { validateSlug } from "../lib/slugify.ts"; import { VALIDATION } from "../../shared/validation.ts"; import { parseRows } from "../model/rows.ts"; /** * Names, for each kind, the table that files things under its categories and * the column there of the thing filed. */ const LINKS: Record = { dump: { table: "dump_categories", column: "dump_id" }, playlist: { table: "playlist_categories", column: "playlist_id" }, }; /** * Throws a 400 unless `slug` is a valid slug for a category of `kind`: a slug * within a category's length, and for a dump category none of the top-level * paths other pages hold (reserved-slugs.ts), since its feed lives at the URL * root. A playlist category's lives under `/playlists/`. */ function validateCategorySlug(slug: string, kind: CategoryKind): void { validateSlug(slug, VALIDATION.CATEGORY_SLUG_MAX); if (kind === "dump" && isReservedSlug(slug)) { throw new APIException(400, `Slug "${slug}" is reserved`); } } function requireName(name: string): string { const trimmed = name.trim(); if (!trimmed) { throw new APIException(400, "Name is required"); } if (trimmed.length > VALIDATION.CATEGORY_NAME_MAX) { throw new APIException( 400, `Name must be at most ${VALIDATION.CATEGORY_NAME_MAX} characters`, ); } return trimmed; } /** * Rethrows a UNIQUE violation from SQLite as a 409 naming the slug or name * that is taken, and any other error as is. SQLite's message names the * column, so this matches on it. */ 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(409, `Name "${name}" is already in use`); } if (msg.includes("UNIQUE") || msg.includes("unique")) { throw new APIException(409, `Slug "${slug}" is already in use`); } throw err as Error; } /** * Returns category `id`, for {@link updateCategory}. * * @throws {APIException} 404 when there is no such category. */ export function getCategoryById(id: string): Category { const row = db.prepare(`SELECT * FROM categories WHERE id = ?;`).get(id); if (!row || !isCategoryRow(row)) { throw new APIException(404, "Category not found"); } return categoryRowToApi(row); } /** * Returns the category of `kind` at `slug`, for {@link listDumpsByCategory} * and the list of every public playlist. * * @throws {APIException} 404 when there is no such category. */ export function getCategoryBySlug(kind: CategoryKind, slug: string): Category { const row = db.prepare( `SELECT * FROM categories WHERE kind = ? AND slug = ?;`, ).get(kind, slug); if (!row || !isCategoryRow(row)) { throw new APIException(404, "Category not found"); } return categoryRowToApi(row); } /** * Returns the id of the category of `kind` at `slug`, or undefined when there * is none. */ export function getCategoryIdBySlug( kind: CategoryKind, slug: string, ): string | undefined { const row = db.prepare( `SELECT id FROM categories WHERE kind = ? AND slug = ?;`, ).get(kind, slug); return row && typeof (row as { id?: unknown }).id === "string" ? (row as { id: string }).id : undefined; } /** * Returns the id of the category of `kind` whose slug the `name` parameter of * `params`, `?category=` by default, gives, undefined without one, or * "missing" when it names none, so that the list comes back empty rather than * unfiltered. The follows routes filter the index's "followed" tab with it, * the users routes a profile's lists, and the search route its results. */ export function resolveCategoryFilter( kind: CategoryKind, params: URLSearchParams, name = "category", ): string | "missing" | undefined { const slug = params.get(name); if (!slug) return undefined; return getCategoryIdBySlug(kind, slug) ?? "missing"; } /** * Returns the join that keeps only the rows of `alias`, a dump or playlist as * `kind` says, filed under `categoryId`, with its parameters, or nothing * without one. A query puts the join after its `FROM` and the parameters * before its own. */ export function categoryJoin( kind: CategoryKind, alias: string, categoryId?: string, ): [string, string[]] { if (!categoryId) return ["", []]; const { table, column } = LINKS[kind]; return [ `INNER JOIN ${table} cat ON cat.${column} = ${alias}.id AND cat.category_id = ?`, [categoryId], ]; } /** * Returns every category, of both kinds, by kind, position then slug, for * `GET /api/categories`. */ export function listCategories(): Category[] { const rows = db.prepare( `SELECT * FROM categories ORDER BY kind ASC, position ASC, slug ASC;`, ).all(); return parseRows(rows, isCategoryRow, categoryRowToApi); } /** * Creates a category, for `POST /api/categories`. The name is stored trimmed, * and the position defaults to 0. * * @throws {APIException} 400 when the slug or name is invalid, 409 when * either is taken within its kind. */ export function createCategory(req: CreateCategoryRequest): Category { validateCategorySlug(req.slug, req.kind); const name = requireName(req.name); const id = crypto.randomUUID(); const createdAt = new Date(); try { db.prepare( `INSERT INTO categories (id, kind, slug, name, position, created_at) VALUES (?, ?, ?, ?, ?, ?);`, ).run( id, req.kind, req.slug, name, req.position ?? 0, createdAt.toISOString(), ); } catch (err) { uniqueConflict(err, req.slug, name); } return { id, kind: req.kind, slug: req.slug, name, position: req.position ?? 0, createdAt, }; } /** * Applies `req` to category `id`, for `PUT /api/categories/:id`, and returns * the category as it now is. Fields `req` leaves out keep their value. * * @throws {APIException} 404 when there is no such category, 400 when the new * slug or name is invalid, 409 when either is taken within its kind. */ 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) { validateCategorySlug(req.slug, existing.kind); } 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, }; } /** * Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps or * playlists stay, and lose it from their `categoryIds`. * * @throws {APIException} 404 when there is no such category. */ export function deleteCategory(id: string): void { const result = db.prepare(`DELETE FROM categories WHERE id = ?;`).run(id); if (result.changes === 0) { throw new APIException(404, "Category not found"); } } /** * Checks that every id in `categoryIds` names a category of `kind`, so that a * dump is never filed under a playlist category, nor the other way round. The * dump and playlist services run it before they write a row or store an * uploaded file, so a bad id fails the request before anything is written. * * @throws {APIException} 400 when an id names no category of `kind`. */ export function assertCategoriesExist( kind: CategoryKind, 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 kind = ? AND id IN (${placeholders});`, ).get(kind, ...unique) as { count: number } | undefined; if ((found?.count ?? 0) !== unique.length) { throw new APIException(400, "One or more categories do not exist"); } } } /** * Replaces the categories of the dump or playlist `id`, as `kind` says, with * `categoryIds`, duplicates dropped. An empty list takes it out of every * category. Called inside the dump or playlist service's transaction, it * joins that one. * * @throws {APIException} 400 when an id names no category of `kind`. */ export function setCategories( kind: CategoryKind, id: string, categoryIds: string[], ): void { assertCategoriesExist(kind, categoryIds); const unique = [...new Set(categoryIds)]; const { table, column } = LINKS[kind]; transaction(() => { db.prepare(`DELETE FROM ${table} WHERE ${column} = ?;`).run(id); const insert = db.prepare( `INSERT INTO ${table} (${column}, category_id) VALUES (?, ?);`, ); for (const categoryId of unique) { insert.run(id, categoryId); } }); } /** * Sets `categoryIds` on each of `items`, dumps or playlists as `kind` says, in * place, in one query. Their rows don't carry them, so every service that * returns dumps or playlists calls this first. */ export function attachCategoryIds( kind: CategoryKind, items: { id: string; categoryIds?: string[] }[], ): void { if (items.length === 0) return; const { table, column } = LINKS[kind]; const ids = items.map((d) => d.id); const placeholders = ids.map(() => "?").join(", "); const rows = db.prepare( `SELECT ${column} as id, category_id FROM ${table} WHERE ${column} IN (${placeholders});`, ).all(...ids) as Array<{ id: string; category_id: string }>; const byItem = new Map(); for (const { id, category_id } of rows) { const list = byItem.get(id) ?? []; list.push(category_id); byItem.set(id, list); } for (const item of items) { item.categoryIds = byItem.get(item.id) ?? []; } } /** * Returns a page of the dumps in the dump category at `slug`, newest first, * for `GET /api/dumps?category=`. Private dumps are left out, except * `requestingUserId`'s own. * * @throws {APIException} 404 when there is no such category. */ export function listDumpsByCategory( slug: string, page: number, limit: number, requestingUserId?: string, ): Page { const category = getCategoryBySlug("dump", 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 [catJoin, catParams] = categoryJoin("dump", "d", category.id); const rows = db.prepare( `SELECT ${SELECT_COLS_ALIASED} FROM dumps d ${catJoin} WHERE ${privacyClause} ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`, ).all(...catParams, ...privacyParams, limit + 1, offset); const items = parseRows(rows, isDumpRow, dumpRowToApi); attachCategoryIds("dump", items); return pageOf(items, limit); }