Files
gerbeur/api/services/category-service.ts
khannurien 0d4e344d77 v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:31:14 +00:00

344 lines
10 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 { APIErrorCode } from "../../shared/api.ts";
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, isValidSlugFormat } from "../lib/reserved-slugs.ts";
import { VALIDATION } from "../../shared/validation.ts";
/**
* Throws a 400 unless `slug` is well-formed, short enough and not reserved,
* with a message for each case.
*/
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;
}
/**
* 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(
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;
}
/**
* 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(APIErrorCode.NOT_FOUND, 404, "Category not found");
}
return categoryRowToApi(row);
}
/**
* Returns the category at `slug`, for `GET /api/categories/:slug`.
*
* @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(APIErrorCode.NOT_FOUND, 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 rows.filter(isCategoryRow).map(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 {
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,
};
}
/**
* 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) {
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,
};
}
/**
* 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(APIErrorCode.NOT_FOUND, 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(
APIErrorCode.BAD_REQUEST,
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/categories/:slug/dumps`. 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 = rows.filter(isDumpRow).map(dumpRowToApi);
attachCategoryIds(items);
return pageOf(items, limit);
}