Files
gerbeur/api/services/category-service.ts
khannurien 308a8299f3 v4: every live update reaches every page that shows what it changed — the client kept each kind of event in one "latest" slot, so two arriving before a render lost the first (two notifications of one dump going public showed one); they now go through a subscription, useWSEvent, and each reaches every subscriber. A playlist made private sent its new title and description to everyone, and kept them on a visitor's page; playlists now follow the dumps' rule, broadcastChange: others get playlist_deleted, and playlist_created when it goes public again. A dump deleted or turned private sends its playlists' dumps again, so their counts and pages follow. Comments on a private dump reach its poster's other tabs. Reading the notifications clears the bell in every tab (notifications_read), and a category change makes every client fetch the categories again (categories_changed). The dump page gives way to a notice when its dump is deleted or made private. A profile gains the user's new dumps, the followed feed those of the users followed and the playlists followed, the search results follow edits and deletions, the index's category feed drops a dump edited out of it, and a renamed user's name follows on cards and comments. After a reconnection, every live list fetches the pages it holds again (usePagedList's resync), and the playlist, the profile, the dump, the categories and the follows are fetched again. The position memory of usePositionAwareSync is now inside the list sync hooks, so a returning item is put back without depending on the order the hooks are called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:34:49 +00:00

395 lines
12 KiB
TypeScript

/**
* 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 { broadcastCategoriesChanged } from "./ws-service.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<CategoryKind, { table: string; column: string }> = {
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 users routes filter a profile's lists with it, 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. Every client is told to fetch the
* categories again, as after the two writes below.
*
* @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);
}
broadcastCategoriesChanged();
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);
}
broadcastCategoriesChanged();
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");
}
broadcastCategoriesChanged();
}
/**
* 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<string, string[]>();
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<Dump> {
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);
}