All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s
270 lines
7.5 KiB
TypeScript
270 lines
7.5 KiB
TypeScript
import {
|
|
APIErrorCode,
|
|
APIException,
|
|
type Category,
|
|
type CreateCategoryRequest,
|
|
type Dump,
|
|
type UpdateCategoryRequest,
|
|
} from "../model/interfaces.ts";
|
|
import {
|
|
categoryRowToApi,
|
|
db,
|
|
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
|
|
dumpRowToApi,
|
|
isCategoryRow,
|
|
isDumpRow,
|
|
} from "../model/db.ts";
|
|
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
|
|
import { VALIDATION } from "../config.ts";
|
|
|
|
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;
|
|
}
|
|
|
|
// Maps SQLite UNIQUE violations to a friendly 409 naming the offending field.
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/** Resolve a slug to its category id, or undefined if no such category. */
|
|
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;
|
|
}
|
|
|
|
export function listCategories(): Category[] {
|
|
const rows = db.prepare(
|
|
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
|
|
).all();
|
|
return rows.filter(isCategoryRow).map(categoryRowToApi);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace the set of categories a dump belongs to. Unknown category ids are
|
|
* rejected. An empty list clears all category assignments.
|
|
*/
|
|
export function setDumpCategories(
|
|
dumpId: string,
|
|
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",
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** Fill `categoryIds` on each dump with a single batched lookup. */
|
|
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) ?? [];
|
|
}
|
|
}
|
|
|
|
export function listDumpsByCategory(
|
|
slug: string,
|
|
page: number,
|
|
limit: number,
|
|
requestingUserId?: string,
|
|
): { items: Dump[]; total: number } {
|
|
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, offset);
|
|
|
|
const totalRow = db.prepare(
|
|
`SELECT COUNT(*) as count
|
|
FROM dumps d
|
|
INNER JOIN dump_categories dc ON dc.dump_id = d.id
|
|
WHERE dc.category_id = ? AND ${privacyClause};`,
|
|
).get(category.id, ...privacyParams) as { count: number } | undefined;
|
|
|
|
const items = rows.filter(isDumpRow).map(dumpRowToApi);
|
|
attachCategoryIds(items);
|
|
return { items, total: totalRow?.count ?? 0 };
|
|
}
|