395 lines
12 KiB
TypeScript
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);
|
|
}
|