Files
gerbeur/api/services/category-service.ts
khannurien c3594e4c27 v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:16:52 +00:00

316 lines
9.5 KiB
TypeScript

/**
* Stores the categories and which dumps belong to them. The category routes
* manage and list them, the dump service files each dump it writes under its
* categories, and every service that lists dumps fills in their `categoryIds`.
*
* @module
*/
import { APIException } from "../lib/api-exception.ts";
import type {
Category,
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";
/**
* Throws a 400 unless `slug` is a valid category slug: a slug within a
* category's length, and none of the top-level paths other pages hold
* (reserved-slugs.ts), since a category's feed lives at the URL root.
*/
function validateCategorySlug(slug: string): void {
validateSlug(slug, VALIDATION.CATEGORY_SLUG_MAX);
if (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 at `slug`, for {@link listDumpsByCategory}.
*
* @throws {APIException} 404 when there is no such category.
*/
export function getCategoryBySlug(slug: string): Category {
const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug);
if (!row || !isCategoryRow(row)) {
throw new APIException(404, "Category not found");
}
return categoryRowToApi(row);
}
/**
* Returns the id of the category at `slug`, or undefined when there is none.
* The follows routes use it for their `?category=` feed filter.
*/
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;
}
/**
* Returns every category, by position then slug, for `GET /api/categories`.
*/
export function listCategories(): Category[] {
const rows = db.prepare(
`SELECT * FROM categories ORDER BY 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.
*/
export function createCategory(req: CreateCategoryRequest): Category {
validateCategorySlug(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,
};
}
/**
* 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.
*/
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);
}
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 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. The dump service
* runs it before it writes a dump row or stores an uploaded file, so a bad id
* fails the request before anything is written.
*
* @throws {APIException} 400 when an id names no category.
*/
export function assertCategoriesExist(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(400, "One or more categories do not exist");
}
}
}
/**
* Replaces the categories of dump `dumpId` with `categoryIds`, duplicates
* dropped. An empty list takes the dump out of every category. Called inside
* the dump service's transaction, it joins that one.
*
* @throws {APIException} 400 when an id names no category.
*/
export function setDumpCategories(
dumpId: string,
categoryIds: string[],
): void {
assertCategoriesExist(categoryIds);
const unique = [...new Set(categoryIds)];
transaction(() => {
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);
}
});
}
/**
* Sets `categoryIds` on each of `dumps` in place, in one query. The dump row
* doesn't carry them, so every service that returns dumps calls this first.
*/
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<string, string[]>();
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) ?? [];
}
}
/**
* Returns a page of the dumps in the 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<Dump> {
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 + 1, offset);
const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds(items);
return pageOf(items, limit);
}