/playlists is the new page of every public playlist, newest first (GET /api/playlists), and /playlists/<slug> a playlist category's, with a menu of the categories, linked from the user menu as "All playlists"; `playlists` is reserved as a dump category's slug, and one that had it is renamed. A profile's Playlists tab narrows to a playlist category with the same menu as its Dumps tab (?category=), and so do the Dumps and Playlists tabs of /search, each with its own kind (category and playlistCategory for GET /api/search), another tab dropping it. The category service takes the kind throughout, so a dump can't be filed under a playlist category nor the other way round, and categoryJoin is the one filter join the dump, follow and playlist services share. useCategories(kind) gives one kind's set from the one fetch, CategoryChips replaces the chips the dump card and page each drew, useCategoryFilter keeps a list to its category as the socket brings more, and useCategorySelection holds a form's picked categories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
390 lines
12 KiB
TypeScript
390 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 { 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 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<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);
|
|
}
|