v4: playlists have categories of their own — a set apart from the dumps', which admins manage on /manage under "Playlist categories", next to "Dump categories". Each category now has a kind, dump or playlist, its slug and name unique within its kind only; migration 0025 rebuilds categories with the column, every existing one becoming a dump category with its dumps, and adds playlist_categories. A playlist's owner files it under any of them in the create and edit forms, and it shows them as chips on its card and its page, each leading to /playlists/<slug>.
/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>
This commit is contained in:
@@ -31,6 +31,7 @@ import { up as up0021PageUrls } from "./migrations/0021_page_urls.ts";
|
||||
import { up as up0022ReserveCommunity } from "./migrations/0022_reserve_community.ts";
|
||||
import { up as up0023MentionEveryone } from "./migrations/0023_mention_everyone.ts";
|
||||
import { up as up0024Federation } from "./migrations/0024_federation.ts";
|
||||
import { up as up0025PlaylistCategories } from "./migrations/0025_playlist_categories.ts";
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@@ -74,6 +75,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ name: "0022_reserve_community", up: up0022ReserveCommunity },
|
||||
{ name: "0023_mention_everyone", up: up0023MentionEveryone },
|
||||
{ name: "0024_federation", up: up0024Federation },
|
||||
{ name: "0025_playlist_categories", up: up0025PlaylistCategories },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
110
api/db/migrations/0025_playlist_categories.ts
Normal file
110
api/db/migrations/0025_playlist_categories.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Gives playlists categories of their own, a set apart from the dumps': each
|
||||
* category gets a `kind`, `dump` or `playlist`, its slug and name now unique
|
||||
* within its kind only, and `playlist_categories` files playlists under them.
|
||||
*
|
||||
* `/playlists` becomes the page of every public playlist, so its first segment
|
||||
* is reserved.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { renameReservedCategories } from "../../lib/reserved-slugs.ts";
|
||||
|
||||
/**
|
||||
* Run once by `runMigrations`. Every existing category becomes a dump
|
||||
* category, filing the same dumps.
|
||||
*
|
||||
* SQLite can't change a table's UNIQUE constraints in place, so `categories`
|
||||
* is rebuilt. `foreign_keys` can't be switched off inside the runner's
|
||||
* transaction, and dropping `categories` would then empty `dump_categories`
|
||||
* through its cascade, so both are read, dropped child first, made anew as in
|
||||
* schema.sql, and refilled. A database made from schema.sql already has the
|
||||
* `kind` column and is left as it is.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
const columns = new Set(
|
||||
(db.prepare(`PRAGMA table_info(categories);`).all() as { name: string }[])
|
||||
.map((c) => c.name),
|
||||
);
|
||||
|
||||
if (!columns.has("kind")) {
|
||||
const categories = db.prepare(
|
||||
`SELECT id, slug, name, position, created_at, updated_at FROM categories;`,
|
||||
).all() as {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}[];
|
||||
const links = db.prepare(
|
||||
`SELECT dump_id, category_id FROM dump_categories;`,
|
||||
).all() as { dump_id: string; category_id: string }[];
|
||||
|
||||
db.exec(`DROP TABLE dump_categories;`);
|
||||
db.exec(`DROP TABLE categories;`);
|
||||
|
||||
db.exec(
|
||||
`CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL DEFAULT 'dump' CHECK (kind IN ('dump', 'playlist')),
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
UNIQUE (kind, slug),
|
||||
UNIQUE (kind, name)
|
||||
);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE TABLE dump_categories (
|
||||
dump_id TEXT NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
PRIMARY KEY (dump_id, category_id),
|
||||
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX idx_dump_categories_category ON dump_categories(category_id);`,
|
||||
);
|
||||
|
||||
const insertCategory = db.prepare(
|
||||
`INSERT INTO categories (id, kind, slug, name, position, created_at, updated_at)
|
||||
VALUES (?, 'dump', ?, ?, ?, ?, ?);`,
|
||||
);
|
||||
for (const c of categories) {
|
||||
insertCategory.run(
|
||||
c.id,
|
||||
c.slug,
|
||||
c.name,
|
||||
c.position,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
);
|
||||
}
|
||||
const insertLink = db.prepare(
|
||||
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
|
||||
);
|
||||
for (const l of links) insertLink.run(l.dump_id, l.category_id);
|
||||
}
|
||||
|
||||
db.exec(
|
||||
`CREATE TABLE IF NOT EXISTS playlist_categories (
|
||||
playlist_id TEXT NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
PRIMARY KEY (playlist_id, category_id),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_playlist_categories_category ON playlist_categories(category_id);`,
|
||||
);
|
||||
|
||||
renameReservedCategories(db);
|
||||
}
|
||||
@@ -69,11 +69,14 @@ CREATE TABLE playlist_dumps (
|
||||
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL DEFAULT 'dump' CHECK (kind IN ('dump', 'playlist')),
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
updated_at TEXT,
|
||||
UNIQUE (kind, slug),
|
||||
UNIQUE (kind, name)
|
||||
);
|
||||
|
||||
CREATE TABLE dump_categories (
|
||||
@@ -84,6 +87,14 @@ CREATE TABLE dump_categories (
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE playlist_categories (
|
||||
playlist_id TEXT NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
PRIMARY KEY (playlist_id, category_id),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
dump_id TEXT NOT NULL,
|
||||
@@ -136,6 +147,7 @@ CREATE INDEX idx_comment_likes_user ON comment_likes(user_id);
|
||||
CREATE INDEX idx_dump_backlinks_to ON dump_backlinks(to_dump_id);
|
||||
CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_dump_id);
|
||||
CREATE INDEX idx_dump_categories_category ON dump_categories(category_id);
|
||||
CREATE INDEX idx_playlist_categories_category ON playlist_categories(category_id);
|
||||
|
||||
CREATE TABLE follows (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Checks which slugs a category may take. A category's feed lives at the URL
|
||||
* root (`/music`), so its slug must not shadow another top-level page. The
|
||||
* category service runs this check when a category is created or renamed, and
|
||||
* the migrations that reserve more words rename the categories that had one
|
||||
* with {@link renameReservedCategories}.
|
||||
* Checks which slugs a dump category may take. A dump category's feed lives at
|
||||
* the URL root (`/music`), so its slug must not shadow another top-level page.
|
||||
* The category service runs this check when a dump category is created or
|
||||
* renamed, and the migrations that reserve more words rename the categories
|
||||
* that had one with {@link renameReservedCategories}. A playlist category's
|
||||
* page is `/playlists/<slug>`, under a reserved word, so it reserves nothing.
|
||||
*
|
||||
* Dump and playlist slugs reserve nothing: they live under their owner's
|
||||
* `dumps/` and `playlists/`, where nothing else is named by a user. A user
|
||||
@@ -32,6 +33,7 @@ export const RESERVED_SLUGS: readonly string[] = [
|
||||
"settings",
|
||||
"manage",
|
||||
"community",
|
||||
"playlists",
|
||||
"authorize",
|
||||
"federation",
|
||||
"api",
|
||||
@@ -48,8 +50,13 @@ export function isReservedSlug(slug: string): boolean {
|
||||
/**
|
||||
* Renames each category whose slug is now reserved, such as `hot`, to the
|
||||
* first of `<slug>-2`, `<slug>-3`… that is free, and logs it. Migration 0021
|
||||
* runs it for v4's words, 0022 for `community`, and 0024 for `authorize` and
|
||||
* `federation`.
|
||||
* runs it for v4's words, 0022 for `community`, 0024 for `authorize` and
|
||||
* `federation`, and 0025 for `playlists`.
|
||||
*
|
||||
* It reads every category: until 0025 gave categories a `kind`, each was a
|
||||
* dump category, and 0025 makes no other. A later migration that reserves
|
||||
* more words must leave the playlist categories alone, and limit it to
|
||||
* `kind = 'dump'`, which the earlier ones can't, having no such column.
|
||||
*/
|
||||
export function renameReservedCategories(db: DatabaseSync): void {
|
||||
const rows = db.prepare(`SELECT id, slug FROM categories;`).all() as {
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { Category } from "../../shared/category.ts";
|
||||
import {
|
||||
type Category,
|
||||
type CategoryKind,
|
||||
isCategoryKind,
|
||||
} from "../../shared/category.ts";
|
||||
|
||||
/** Returned by the category service's `SELECT`s on `categories`. */
|
||||
export interface CategoryRow {
|
||||
id: string;
|
||||
kind: CategoryKind;
|
||||
slug: string;
|
||||
name: string;
|
||||
/** Sorts the categories, lowest first. */
|
||||
@@ -24,6 +29,7 @@ export interface CategoryRow {
|
||||
export function isCategoryRow(obj: unknown): obj is CategoryRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
"id" in obj && typeof obj.id === "string" &&
|
||||
"kind" in obj && isCategoryKind(obj.kind) &&
|
||||
"slug" in obj && typeof obj.slug === "string" &&
|
||||
"name" in obj && typeof obj.name === "string" &&
|
||||
"position" in obj && typeof obj.position === "number" &&
|
||||
@@ -36,6 +42,7 @@ export function isCategoryRow(obj: unknown): obj is CategoryRow {
|
||||
export function categoryRowToApi(row: CategoryRow): Category {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
position: row.position,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* Serves the categories dumps are filed under, and changes them for those with
|
||||
* `category:manage`. A category's feed is `GET /api/dumps?category=:slug`.
|
||||
* Serves the categories dumps and playlists are filed under, a set for each
|
||||
* kind, and changes them for those with `category:manage`. A dump category's
|
||||
* feed is `GET /api/dumps?category=:slug`, a playlist category's
|
||||
* `GET /api/playlists?category=:slug`.
|
||||
*
|
||||
* - `GET /api/categories` — public. Answers every category, in order.
|
||||
* - `POST /api/categories` — permission `category:manage`. Creates a category.
|
||||
* - `GET /api/categories` — public. Answers every category of both kinds, by
|
||||
* kind, then in order.
|
||||
* - `POST /api/categories` — permission `category:manage`. Creates a category
|
||||
* of the kind it names.
|
||||
* - `PUT /api/categories/:id` — permission `category:manage`. Updates a
|
||||
* category.
|
||||
* - `DELETE /api/categories/:id` — permission `category:manage`. Deletes a
|
||||
* category; its dumps stay.
|
||||
* category; its dumps or playlists stay.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -79,7 +79,7 @@ router.get("/feed", authMiddleware, (ctx) => {
|
||||
throw new APIException(400, "from must be users or playlists");
|
||||
}
|
||||
const { page, limit } = parsePagination(params);
|
||||
const categoryId = resolveCategoryFilter(params);
|
||||
const categoryId = resolveCategoryFilter("dump", params);
|
||||
const userId = ctx.state.user.userId;
|
||||
const { items, hasMore } = categoryId === "missing"
|
||||
? EMPTY_FEED
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
* playlist's id; its page reads it by its path, through
|
||||
* `GET /api/users/:key/playlists/:slug`.
|
||||
*
|
||||
* - `POST /api/playlists` — signed in. Creates a playlist.
|
||||
* - `GET /api/playlists?category=&page=&limit=` — public. Answers a page of
|
||||
* every public playlist, newest first, or only those in the playlist
|
||||
* category at `category`, which 404s when there is none.
|
||||
* - `POST /api/playlists` — signed in. Creates a playlist, filed under the
|
||||
* playlist categories it names.
|
||||
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
|
||||
* with the dumps the caller may see in it. A private one 404s but for its
|
||||
* owner.
|
||||
* - `PUT /api/playlists/:playlistId` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Updates its title, slug, description and visibility.
|
||||
* `playlist:moderate`. Updates its title, slug, description, visibility and
|
||||
* categories.
|
||||
* A new slug keeps the old one as an alias, 3 times a day at most but for
|
||||
* those with `playlist:moderate`.
|
||||
* - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with
|
||||
@@ -32,7 +37,7 @@
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { authenticateMedia, parseOptionalAuth } from "../lib/auth.ts";
|
||||
import { type APIResponse } from "../../shared/api.ts";
|
||||
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import {
|
||||
isCreatePlaylistRequest,
|
||||
@@ -49,6 +54,7 @@ import {
|
||||
getEditablePlaylist,
|
||||
getPlaylist,
|
||||
getPlaylistImageInfo,
|
||||
listPublicPlaylists,
|
||||
removeDumpFromPlaylist,
|
||||
reorderPlaylist,
|
||||
setPlaylistImage,
|
||||
@@ -60,6 +66,8 @@ import {
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
import { PLAYLIST_IMAGES_DIR } from "../config.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
import { getCategoryBySlug } from "../services/category-service.ts";
|
||||
|
||||
/**
|
||||
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
||||
@@ -69,6 +77,19 @@ const router: Router<AuthState> = new Router<AuthState>({
|
||||
prefix: "/api/playlists",
|
||||
});
|
||||
|
||||
router.get("/", (ctx) => {
|
||||
const params = ctx.request.url.searchParams;
|
||||
const { page, limit } = parsePagination(params);
|
||||
const slug = params.get("category");
|
||||
const categoryId = slug ? getCategoryBySlug("playlist", slug).id : undefined;
|
||||
const { items, hasMore } = listPublicPlaylists(page, limit, categoryId);
|
||||
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.post("/", authMiddleware, async (ctx) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isCreatePlaylistRequest(body)) {
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
* Only the dumps are paged. Private dumps and playlists are found only by
|
||||
* their owner.
|
||||
*
|
||||
* - `GET /api/search?q=&page=&limit=` — optional sign-in. Answers a page of the
|
||||
* matching dumps with their total, and up to 20 matching users and
|
||||
* playlists. A blank `q` finds nothing.
|
||||
* - `GET /api/search?q=&page=&limit=&category=&playlistCategory=` — optional
|
||||
* sign-in. Answers a page of the matching dumps with their total, and up to
|
||||
* 20 matching users and playlists. A blank `q` finds nothing. `category`
|
||||
* keeps the dumps in that dump category, `playlistCategory` the playlists in
|
||||
* that playlist category; a slug that names none finds nothing.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -16,6 +18,7 @@ import { parsePagination } from "../lib/pagination.ts";
|
||||
import { searchDumps } from "../services/dump-service.ts";
|
||||
import { searchUsersGlobal } from "../services/user-service.ts";
|
||||
import { searchPlaylists } from "../services/playlist-service.ts";
|
||||
import { resolveCategoryFilter } from "../services/category-service.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
import type { SearchResults } from "../../shared/search.ts";
|
||||
|
||||
@@ -44,14 +47,19 @@ router.get("/", async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { items: dumpItems, total: dumpTotal } = searchDumps(
|
||||
q,
|
||||
page,
|
||||
limit,
|
||||
requestingUserId,
|
||||
const dumpCategoryId = resolveCategoryFilter("dump", params);
|
||||
const playlistCategoryId = resolveCategoryFilter(
|
||||
"playlist",
|
||||
params,
|
||||
"playlistCategory",
|
||||
);
|
||||
const { items: dumpItems, total: dumpTotal } = dumpCategoryId === "missing"
|
||||
? { items: [], total: 0 }
|
||||
: searchDumps(q, page, limit, requestingUserId, dumpCategoryId);
|
||||
const users = searchUsersGlobal(q, 20);
|
||||
const playlists = searchPlaylists(q, 20, requestingUserId);
|
||||
const playlists = playlistCategoryId === "missing"
|
||||
? []
|
||||
: searchPlaylists(q, 20, requestingUserId, playlistCategoryId);
|
||||
|
||||
const responseBody: APIResponse<SearchResults> = {
|
||||
success: true,
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
* - `GET /api/users/:key/upvoted?page=&limit=&category=` — optional sign-in.
|
||||
* Answers a page of the dumps they upvoted, private ones only to
|
||||
* themselves, optionally in one category.
|
||||
* - `GET /api/users/:key/playlists?page=&limit=` — optional sign-in. Answers a
|
||||
* page of the playlists they made, the private ones only to themselves.
|
||||
* - `GET /api/users/:key/playlists?page=&limit=&category=` — optional
|
||||
* sign-in. Answers a page of the playlists they made, the private ones only
|
||||
* to themselves, optionally in one playlist category.
|
||||
* - `GET /api/users/:key/playlists/:slug` — optional sign-in. Answers their
|
||||
* playlist whose slug is or was `slug`, with its current slug and the dumps
|
||||
* the caller may see; a private one 404s but for its owner.
|
||||
@@ -163,7 +164,7 @@ router.get("/:key/dumps", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
const params = ctx.request.url.searchParams;
|
||||
const { page, limit } = parsePagination(params);
|
||||
const categoryId = resolveCategoryFilter(params);
|
||||
const categoryId = resolveCategoryFilter("dump", params);
|
||||
const includePrivate = requestingUserId === user.id;
|
||||
const { items, hasMore } = categoryId === "missing"
|
||||
? { items: [], hasMore: false }
|
||||
@@ -190,7 +191,7 @@ router.get("/:key/upvoted", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
const params = ctx.request.url.searchParams;
|
||||
const { page, limit } = parsePagination(params);
|
||||
const categoryId = resolveCategoryFilter(params);
|
||||
const categoryId = resolveCategoryFilter("dump", params);
|
||||
const { items, hasMore } = categoryId === "missing"
|
||||
? { items: [], hasMore: false }
|
||||
: getVotedDumpsByUser(user.id, page, limit, requestingUserId, categoryId);
|
||||
@@ -204,13 +205,12 @@ router.get("/:key/upvoted", async (ctx) => {
|
||||
router.get("/:key/playlists", async (ctx) => {
|
||||
const user = resolveUserKey(ctx.params.key);
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = listPlaylistsByUser(
|
||||
user.id,
|
||||
requestingUserId,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
const params = ctx.request.url.searchParams;
|
||||
const { page, limit } = parsePagination(params);
|
||||
const categoryId = resolveCategoryFilter("playlist", params);
|
||||
const { items, hasMore } = categoryId === "missing"
|
||||
? { items: [], hasMore: false }
|
||||
: listPlaylistsByUser(user.id, requestingUserId, page, limit, categoryId);
|
||||
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* 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`.
|
||||
* 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
|
||||
*/
|
||||
@@ -9,6 +12,7 @@
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type {
|
||||
Category,
|
||||
CategoryKind,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
} from "../../shared/category.ts";
|
||||
@@ -27,13 +31,23 @@ import { VALIDATION } from "../../shared/validation.ts";
|
||||
import { parseRows } from "../model/rows.ts";
|
||||
|
||||
/**
|
||||
* Throws a 400 unless `slug` is a valid category slug: a slug within a
|
||||
* category's length, and none of the top-level paths other pages hold
|
||||
* (reserved-slugs.ts), since a category's feed lives at the URL root.
|
||||
* Names, for each kind, the table that files things under its categories and
|
||||
* the column there of the thing filed.
|
||||
*/
|
||||
function validateCategorySlug(slug: string): void {
|
||||
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 (isReservedSlug(slug)) {
|
||||
if (kind === "dump" && isReservedSlug(slug)) {
|
||||
throw new APIException(400, `Slug "${slug}" is reserved`);
|
||||
}
|
||||
}
|
||||
@@ -82,12 +96,15 @@ export function getCategoryById(id: string): Category {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the category at `slug`, for {@link listDumpsByCategory}.
|
||||
* 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(slug: string): Category {
|
||||
const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug);
|
||||
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");
|
||||
}
|
||||
@@ -95,35 +112,64 @@ export function getCategoryBySlug(slug: string): Category {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of the category at `slug`, or undefined when there is none.
|
||||
* Returns the id of the category of `kind` at `slug`, or undefined when there
|
||||
* is none.
|
||||
*/
|
||||
export function getCategoryIdBySlug(slug: string): string | undefined {
|
||||
const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug);
|
||||
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 the `?category=` slug of `params` names,
|
||||
* 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, and the users routes a profile's lists.
|
||||
* 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("category");
|
||||
const slug = params.get(name);
|
||||
if (!slug) return undefined;
|
||||
return getCategoryIdBySlug(slug) ?? "missing";
|
||||
return getCategoryIdBySlug(kind, slug) ?? "missing";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every category, by position then slug, for `GET /api/categories`.
|
||||
* 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 position ASC, slug ASC;`,
|
||||
`SELECT * FROM categories ORDER BY kind ASC, position ASC, slug ASC;`,
|
||||
).all();
|
||||
return parseRows(rows, isCategoryRow, categoryRowToApi);
|
||||
}
|
||||
@@ -133,20 +179,21 @@ export function listCategories(): Category[] {
|
||||
* and the position defaults to 0.
|
||||
*
|
||||
* @throws {APIException} 400 when the slug or name is invalid, 409 when
|
||||
* either is taken.
|
||||
* either is taken within its kind.
|
||||
*/
|
||||
export function createCategory(req: CreateCategoryRequest): Category {
|
||||
validateCategorySlug(req.slug);
|
||||
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, slug, name, position, created_at)
|
||||
VALUES (?, ?, ?, ?, ?);`,
|
||||
`INSERT INTO categories (id, kind, slug, name, position, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
id,
|
||||
req.kind,
|
||||
req.slug,
|
||||
name,
|
||||
req.position ?? 0,
|
||||
@@ -158,6 +205,7 @@ export function createCategory(req: CreateCategoryRequest): Category {
|
||||
|
||||
return {
|
||||
id,
|
||||
kind: req.kind,
|
||||
slug: req.slug,
|
||||
name,
|
||||
position: req.position ?? 0,
|
||||
@@ -170,7 +218,7 @@ export function createCategory(req: CreateCategoryRequest): Category {
|
||||
* 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.
|
||||
* slug or name is invalid, 409 when either is taken within its kind.
|
||||
*/
|
||||
export function updateCategory(
|
||||
id: string,
|
||||
@@ -180,7 +228,7 @@ export function updateCategory(
|
||||
|
||||
const newSlug = req.slug ?? existing.slug;
|
||||
if (req.slug !== undefined && req.slug !== existing.slug) {
|
||||
validateCategorySlug(req.slug);
|
||||
validateCategorySlug(req.slug, existing.kind);
|
||||
}
|
||||
const newName = req.name !== undefined
|
||||
? requireName(req.name)
|
||||
@@ -212,8 +260,8 @@ export function updateCategory(
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps stay, and
|
||||
* lose it from their `categoryIds`.
|
||||
* 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.
|
||||
*/
|
||||
@@ -225,20 +273,25 @@ export function deleteCategory(id: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
* @throws {APIException} 400 when an id names no category of `kind`.
|
||||
*/
|
||||
export function assertCategoriesExist(categoryIds: string[]): void {
|
||||
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 id IN (${placeholders});`,
|
||||
).get(...unique) as { count: number } | undefined;
|
||||
`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");
|
||||
}
|
||||
@@ -246,56 +299,64 @@ export function assertCategoriesExist(categoryIds: string[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
* @throws {APIException} 400 when an id names no category of `kind`.
|
||||
*/
|
||||
export function setDumpCategories(
|
||||
dumpId: string,
|
||||
export function setCategories(
|
||||
kind: CategoryKind,
|
||||
id: string,
|
||||
categoryIds: string[],
|
||||
): void {
|
||||
assertCategoriesExist(categoryIds);
|
||||
assertCategoriesExist(kind, categoryIds);
|
||||
const unique = [...new Set(categoryIds)];
|
||||
const { table, column } = LINKS[kind];
|
||||
|
||||
transaction(() => {
|
||||
db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId);
|
||||
db.prepare(`DELETE FROM ${table} WHERE ${column} = ?;`).run(id);
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
|
||||
`INSERT INTO ${table} (${column}, category_id) VALUES (?, ?);`,
|
||||
);
|
||||
for (const categoryId of unique) {
|
||||
insert.run(dumpId, categoryId);
|
||||
insert.run(id, 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.
|
||||
* 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(dumps: Dump[]): void {
|
||||
if (dumps.length === 0) return;
|
||||
const ids = dumps.map((d) => d.id);
|
||||
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 dump_id, category_id FROM dump_categories WHERE dump_id IN (${placeholders});`,
|
||||
).all(...ids) as Array<{ dump_id: string; category_id: string }>;
|
||||
`SELECT ${column} as id, category_id FROM ${table} WHERE ${column} IN (${placeholders});`,
|
||||
).all(...ids) as Array<{ 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) ?? [];
|
||||
const byItem = new Map<string, string[]>();
|
||||
for (const { id, category_id } of rows) {
|
||||
const list = byItem.get(id) ?? [];
|
||||
list.push(category_id);
|
||||
byDump.set(dump_id, list);
|
||||
byItem.set(id, list);
|
||||
}
|
||||
for (const dump of dumps) {
|
||||
dump.categoryIds = byDump.get(dump.id) ?? [];
|
||||
for (const item of items) {
|
||||
item.categoryIds = byItem.get(item.id) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps in the category at `slug`, newest first, for
|
||||
* `GET /api/dumps?category=`. Private dumps are left out, except
|
||||
* 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.
|
||||
@@ -306,7 +367,7 @@ export function listDumpsByCategory(
|
||||
limit: number,
|
||||
requestingUserId?: string,
|
||||
): Page<Dump> {
|
||||
const category = getCategoryBySlug(slug);
|
||||
const category = getCategoryBySlug("dump", slug);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const privacyClause = requestingUserId
|
||||
@@ -314,15 +375,15 @@ export function listDumpsByCategory(
|
||||
: "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
|
||||
INNER JOIN dump_categories dc ON dc.dump_id = d.id
|
||||
WHERE dc.category_id = ? AND ${privacyClause}
|
||||
FROM dumps d ${catJoin}
|
||||
WHERE ${privacyClause}
|
||||
ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(category.id, ...privacyParams, limit + 1, offset);
|
||||
).all(...catParams, ...privacyParams, limit + 1, offset);
|
||||
|
||||
const items = parseRows(rows, isDumpRow, dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
attachCategoryIds("dump", items);
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ import { relinkBacklinks } from "./backlink-service.ts";
|
||||
import {
|
||||
assertCategoriesExist,
|
||||
attachCategoryIds,
|
||||
setDumpCategories,
|
||||
categoryJoin,
|
||||
setCategories,
|
||||
} from "./category-service.ts";
|
||||
import { LIKE, likePattern } from "../lib/like.ts";
|
||||
import { parseRows } from "../model/rows.ts";
|
||||
@@ -114,7 +115,7 @@ export async function createUrlDump(
|
||||
if (!isValidHttpUrl(request.url)) {
|
||||
throw new APIException(400, "Invalid URL");
|
||||
}
|
||||
assertCategoriesExist(request.categoryIds ?? []);
|
||||
assertCategoriesExist("dump", request.categoryIds ?? []);
|
||||
|
||||
const dumpId = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
@@ -144,7 +145,7 @@ export async function createUrlDump(
|
||||
richContent ? JSON.stringify(richContent) : null,
|
||||
isPrivate ? 1 : 0,
|
||||
);
|
||||
setDumpCategories(dumpId, categoryIds);
|
||||
setCategories("dump", dumpId, categoryIds);
|
||||
});
|
||||
|
||||
const dump: Dump = { ...fetchDump(dumpId), categoryIds };
|
||||
@@ -184,7 +185,7 @@ export async function createFileDump(
|
||||
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
|
||||
throw new APIException(400, "File too large (max 50 MB)");
|
||||
}
|
||||
assertCategoriesExist(categoryIds);
|
||||
assertCategoriesExist("dump", categoryIds);
|
||||
|
||||
const dumpId = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
@@ -215,7 +216,7 @@ export async function createFileDump(
|
||||
file.size,
|
||||
isPrivate ? 1 : 0,
|
||||
);
|
||||
setDumpCategories(dumpId, dedupedCategoryIds);
|
||||
setCategories("dump", dumpId, dedupedCategoryIds);
|
||||
});
|
||||
} catch (err) {
|
||||
// Leave no file behind when the write or the insert fails.
|
||||
@@ -262,7 +263,7 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
|
||||
if (dump.isPrivate && dump.userId !== requestingUserId) {
|
||||
throw new APIException(404, "Dump not found");
|
||||
}
|
||||
attachCategoryIds([dump]);
|
||||
attachCategoryIds("dump", [dump]);
|
||||
return dump;
|
||||
}
|
||||
|
||||
@@ -288,13 +289,15 @@ export function getDumpByPath(
|
||||
/**
|
||||
* Returns a page of the dumps `requestingUserId` may see whose title, note, or
|
||||
* previewed title or description contains `query`, newest first, with how many
|
||||
* match in all. `GET /api/search` calls it.
|
||||
* match in all, optionally only those in the dump category `categoryId`.
|
||||
* `GET /api/search` calls it.
|
||||
*/
|
||||
export function searchDumps(
|
||||
query: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
requestingUserId?: string,
|
||||
categoryId?: string,
|
||||
): { items: Dump[]; total: number } {
|
||||
if (!query.trim()) return { items: [], total: 0 };
|
||||
const pattern = likePattern(query);
|
||||
@@ -302,29 +305,28 @@ export function searchDumps(
|
||||
|
||||
const searchClause =
|
||||
`(title ${LIKE} OR comment ${LIKE} OR json_extract(rich_content,'$.title') ${LIKE} OR json_extract(rich_content,'$.description') ${LIKE})`;
|
||||
const searchParams = [pattern, pattern, pattern, pattern] as const;
|
||||
const privacyClause = requestingUserId
|
||||
? "(is_private = 0 OR user_id = ?)"
|
||||
: "is_private = 0";
|
||||
const [catJoin, catParams] = categoryJoin("dump", "dumps", categoryId);
|
||||
const params = [
|
||||
...catParams,
|
||||
...(requestingUserId ? [requestingUserId] : []),
|
||||
pattern,
|
||||
pattern,
|
||||
pattern,
|
||||
pattern,
|
||||
];
|
||||
|
||||
if (requestingUserId) {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(requestingUserId, ...searchParams, limit, offset);
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
|
||||
).get(requestingUserId, ...searchParams) as { count: number } | undefined;
|
||||
const items = parseRows(rows, isDumpRow, dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
} else {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(...searchParams, limit, offset);
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
|
||||
).get(...searchParams) as { count: number } | undefined;
|
||||
const items = parseRows(rows, isDumpRow, dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
}
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps ${catJoin} WHERE ${privacyClause} AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(...params, limit, offset);
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps ${catJoin} WHERE ${privacyClause} AND ${searchClause};`,
|
||||
).get(...params) as { count: number } | undefined;
|
||||
const items = parseRows(rows, isDumpRow, dumpRowToApi);
|
||||
attachCategoryIds("dump", items);
|
||||
return { items, total: totalRow?.count ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,7 +354,7 @@ export function listDumps(
|
||||
}
|
||||
|
||||
const items = rows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
attachCategoryIds("dump", items);
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
@@ -372,7 +374,7 @@ export function listTopDumps(from: Date, to: Date, limit: number): Dump[] {
|
||||
LIMIT ?;`,
|
||||
).all(from.toISOString(), to.toISOString(), limit);
|
||||
const items = parseRows(rows, isDumpRow, dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
attachCategoryIds("dump", items);
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -441,7 +443,7 @@ export async function updateDump(
|
||||
): Promise<Dump> {
|
||||
const dump = fetchDump(dumpId);
|
||||
if (request.categoryIds !== undefined) {
|
||||
assertCategoriesExist(request.categoryIds);
|
||||
assertCategoriesExist("dump", request.categoryIds);
|
||||
}
|
||||
if (dump.kind === "url" && !isValidHttpUrl(request.url ?? dump.url!)) {
|
||||
throw new APIException(400, "Invalid URL");
|
||||
@@ -479,10 +481,10 @@ export async function updateDump(
|
||||
);
|
||||
if (request.categoryIds !== undefined) {
|
||||
const ids = [...new Set(request.categoryIds)];
|
||||
setDumpCategories(dumpId, ids);
|
||||
setCategories("dump", dumpId, ids);
|
||||
updatedDump.categoryIds = ids;
|
||||
} else {
|
||||
attachCategoryIds([updatedDump]);
|
||||
attachCategoryIds("dump", [updatedDump]);
|
||||
}
|
||||
broadcastDumpChange(dump, updatedDump);
|
||||
notifyIfWentPublic(dump, updatedDump);
|
||||
@@ -550,10 +552,10 @@ export async function updateDump(
|
||||
|
||||
if (request.categoryIds !== undefined) {
|
||||
const ids = [...new Set(request.categoryIds)];
|
||||
setDumpCategories(dumpId, ids);
|
||||
setCategories("dump", dumpId, ids);
|
||||
updatedDump.categoryIds = ids;
|
||||
} else {
|
||||
attachCategoryIds([updatedDump]);
|
||||
attachCategoryIds("dump", [updatedDump]);
|
||||
}
|
||||
|
||||
broadcastDumpChange(dump, updatedDump);
|
||||
@@ -610,7 +612,7 @@ export async function replaceFileDump(
|
||||
if (dump.kind !== "file") {
|
||||
throw new APIException(400, "Not a file dump");
|
||||
}
|
||||
if (categoryIds !== undefined) assertCategoriesExist(categoryIds);
|
||||
if (categoryIds !== undefined) assertCategoriesExist("dump", categoryIds);
|
||||
if (slug !== undefined) changeSlug("dump", dump, slug, exempt);
|
||||
|
||||
// The new file is written beside the old one and renamed over it (atomic: a
|
||||
@@ -650,7 +652,7 @@ export async function replaceFileDump(
|
||||
dumpId,
|
||||
);
|
||||
if (categoryIds !== undefined) {
|
||||
setDumpCategories(dumpId, [...new Set(categoryIds)]);
|
||||
setCategories("dump", dumpId, [...new Set(categoryIds)]);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -664,7 +666,7 @@ export async function replaceFileDump(
|
||||
if (categoryIds !== undefined) {
|
||||
dump.categoryIds = [...new Set(categoryIds)];
|
||||
} else {
|
||||
attachCategoryIds([dump]);
|
||||
attachCategoryIds("dump", [dump]);
|
||||
}
|
||||
|
||||
if (comment) {
|
||||
@@ -690,20 +692,6 @@ export async function replaceFileDump(
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the join that keeps only the dumps `d` filed under `categoryId`,
|
||||
* with its parameters, or nothing without one. The lists of a user's dumps
|
||||
* filter by it.
|
||||
*/
|
||||
function categoryJoin(categoryId?: string): [string, string[]] {
|
||||
return categoryId
|
||||
? [
|
||||
"INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?",
|
||||
[categoryId],
|
||||
]
|
||||
: ["", []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of `userId`'s dumps, newest first, for
|
||||
* `GET /api/users/:key/dumps`, optionally only those in `categoryId`. The
|
||||
@@ -720,7 +708,7 @@ export function getDumpsByUser(
|
||||
): Page<Dump> {
|
||||
const offset = (page - 1) * limit;
|
||||
const privacyFilter = includePrivate ? "" : " AND d.is_private = 0";
|
||||
const [catJoin, catParams] = categoryJoin(categoryId);
|
||||
const [catJoin, catParams] = categoryJoin("dump", "d", categoryId);
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS_ALIASED} FROM dumps d ${catJoin}
|
||||
WHERE d.user_id = ?${privacyFilter}
|
||||
@@ -730,7 +718,7 @@ export function getDumpsByUser(
|
||||
throw new APIException(500, "Malformed dump data");
|
||||
}
|
||||
const items = rows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
attachCategoryIds("dump", items);
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
@@ -753,7 +741,7 @@ export function getVotedDumpsByUser(
|
||||
const privacyFilter = isOwn
|
||||
? "(d.is_private = 0 OR d.user_id = ?)"
|
||||
: "d.is_private = 0";
|
||||
const [catJoin, catParams] = categoryJoin(categoryId);
|
||||
const [catJoin, catParams] = categoryJoin("dump", "d", categoryId);
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS_ALIASED}
|
||||
FROM dumps d
|
||||
@@ -773,7 +761,7 @@ export function getVotedDumpsByUser(
|
||||
throw new APIException(500, "Malformed dump data");
|
||||
}
|
||||
const items = rows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
attachCategoryIds("dump", items);
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { isFollowRow } from "../model/follow.ts";
|
||||
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
|
||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||
import { attachCategoryIds } from "./category-service.ts";
|
||||
import { attachCategoryIds, categoryJoin } from "./category-service.ts";
|
||||
import { sendToUser } from "./ws-service.ts";
|
||||
|
||||
/**
|
||||
@@ -167,10 +167,7 @@ export function getFollowedUsersDumpFeed(
|
||||
): Page<Dump> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const catJoin = categoryId
|
||||
? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?"
|
||||
: "";
|
||||
const catParams = categoryId ? [categoryId] : [];
|
||||
const [catJoin, catParams] = categoryJoin("dump", "d", categoryId);
|
||||
|
||||
const rawRows = db.prepare(
|
||||
`SELECT ${SELECT_COLS_ALIASED}
|
||||
@@ -188,7 +185,7 @@ export function getFollowedUsersDumpFeed(
|
||||
throw new APIException(500, "Malformed dump data");
|
||||
}
|
||||
const items = userFeedRows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
attachCategoryIds("dump", items);
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
@@ -208,10 +205,7 @@ export function getFollowedPlaylistsDumpFeed(
|
||||
): Page<Dump> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const catJoin = categoryId
|
||||
? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?"
|
||||
: "";
|
||||
const catParams = categoryId ? [categoryId] : [];
|
||||
const [catJoin, catParams] = categoryJoin("dump", "d", categoryId);
|
||||
|
||||
const rawRows = db.prepare(
|
||||
`SELECT ${SELECT_COLS_ALIASED}
|
||||
@@ -232,7 +226,7 @@ export function getFollowedPlaylistsDumpFeed(
|
||||
throw new APIException(500, "Malformed dump data");
|
||||
}
|
||||
const items = rawRows.map(dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
attachCategoryIds("dump", items);
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
@@ -264,7 +258,9 @@ export function getFollowedPlaylistsByUser(
|
||||
if (!rawRows.every(isPlaylistRow)) {
|
||||
throw new APIException(500, "Malformed playlist data");
|
||||
}
|
||||
return pageOf(rawRows.map(playlistRowToApi), limit);
|
||||
const items = rawRows.map(playlistRowToApi);
|
||||
attachCategoryIds("playlist", items);
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,12 @@ import {
|
||||
} from "./notification-service.ts";
|
||||
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
import {
|
||||
assertCategoriesExist,
|
||||
attachCategoryIds,
|
||||
categoryJoin,
|
||||
setCategories,
|
||||
} from "./category-service.ts";
|
||||
import {
|
||||
isMemberVisibleTo,
|
||||
type PlaylistMember,
|
||||
@@ -53,6 +59,16 @@ const PLAYLIST_SELECT = `p.*, u.username as owner_username,
|
||||
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
||||
FROM playlists p JOIN users u ON u.id = p.user_id`;
|
||||
|
||||
/**
|
||||
* Converts the rows of a {@link PLAYLIST_SELECT} to playlists, their
|
||||
* categories filled in.
|
||||
*/
|
||||
function playlistsOf(rows: unknown[]): Playlist[] {
|
||||
const playlists = parseRows(rows, isPlaylistRow, playlistRowToApi);
|
||||
attachCategoryIds("playlist", playlists);
|
||||
return playlists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns playlist `playlistId`, private or not. A caller that shows it to
|
||||
* someone must check `isPublic` first, as the OG middleware does.
|
||||
@@ -65,7 +81,9 @@ export function getPlaylistById(playlistId: string): Playlist {
|
||||
if (!row || !isPlaylistRow(row)) {
|
||||
throw new APIException(404, "Playlist not found");
|
||||
}
|
||||
return playlistRowToApi(row);
|
||||
const playlist = playlistRowToApi(row);
|
||||
attachCategoryIds("playlist", [playlist]);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,9 +98,12 @@ export function getPlaylistByPath(userId: string, slug: string): Playlist {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a playlist owned by `userId`, for `POST /api/playlists`. If it is
|
||||
* public, it notifies the users its description mentions. It links the
|
||||
* attachments the description embeds and broadcasts `playlist_created`.
|
||||
* Creates a playlist owned by `userId`, for `POST /api/playlists`, filed under
|
||||
* the playlist categories `req` names. If it is public, it notifies the users
|
||||
* its description mentions. It links the attachments the description embeds
|
||||
* and broadcasts `playlist_created`.
|
||||
*
|
||||
* @throws {APIException} 400 when a category id names no playlist category.
|
||||
*/
|
||||
export function createPlaylist(
|
||||
req: CreatePlaylistRequest,
|
||||
@@ -91,6 +112,7 @@ export function createPlaylist(
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
const title = req.title.trim();
|
||||
assertCategoriesExist("playlist", req.categoryIds ?? []);
|
||||
transaction(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at)
|
||||
@@ -104,6 +126,9 @@ export function createPlaylist(
|
||||
req.isPublic ? 1 : 0,
|
||||
createdAt.toISOString(),
|
||||
);
|
||||
if (req.categoryIds?.length) {
|
||||
setCategories("playlist", id, req.categoryIds);
|
||||
}
|
||||
});
|
||||
const playlist = getPlaylistById(id);
|
||||
if (req.description) {
|
||||
@@ -157,27 +182,50 @@ export function getPlaylist(
|
||||
|
||||
/**
|
||||
* Returns up to `limit` playlists whose title or description contains
|
||||
* `query`, newest first, for `GET /api/search`. Private playlists are left
|
||||
* out, except `requestingUserId`'s own. A blank `query` finds nothing.
|
||||
* `query`, newest first, for `GET /api/search`, optionally only those in the
|
||||
* playlist category `categoryId`. Private playlists are left out, except
|
||||
* `requestingUserId`'s own. A blank `query` finds nothing.
|
||||
*/
|
||||
export function searchPlaylists(
|
||||
query: string,
|
||||
limit: number,
|
||||
requestingUserId?: string,
|
||||
categoryId?: string,
|
||||
): Playlist[] {
|
||||
if (!query.trim()) return [];
|
||||
const pattern = likePattern(query);
|
||||
const searchClause = `(p.title ${LIKE} OR p.description ${LIKE})`;
|
||||
const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId);
|
||||
|
||||
const rows = requestingUserId
|
||||
? db.prepare(
|
||||
`SELECT ${PLAYLIST_SELECT} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
||||
).all(requestingUserId, pattern, pattern, limit)
|
||||
`SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
||||
).all(...catParams, requestingUserId, pattern, pattern, limit)
|
||||
: db.prepare(
|
||||
`SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
||||
).all(pattern, pattern, limit);
|
||||
`SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
||||
).all(...catParams, pattern, pattern, limit);
|
||||
|
||||
return parseRows(rows, isPlaylistRow, playlistRowToApi);
|
||||
return playlistsOf(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of every public playlist, newest first, for
|
||||
* `GET /api/playlists`, optionally only those in the playlist category
|
||||
* `categoryId`. The client's `/playlists` page lists them.
|
||||
*/
|
||||
export function listPublicPlaylists(
|
||||
page: number,
|
||||
limit: number,
|
||||
categoryId?: string,
|
||||
): Page<Playlist> {
|
||||
const offset = (page - 1) * limit;
|
||||
const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId);
|
||||
const rows = db.prepare(
|
||||
`SELECT ${PLAYLIST_SELECT} ${catJoin}
|
||||
WHERE p.is_public = 1
|
||||
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(...catParams, limit + 1, offset);
|
||||
return pageOf(playlistsOf(rows), limit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,12 +239,13 @@ export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] {
|
||||
WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ?
|
||||
ORDER BY dump_count DESC, p.created_at DESC;`,
|
||||
).all(from.toISOString(), to.toISOString());
|
||||
return parseRows(rows, isPlaylistRow, playlistRowToApi);
|
||||
return playlistsOf(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of `userId`'s playlists, newest first, for
|
||||
* `GET /api/users/:key/playlists`. The private ones are listed only when
|
||||
* `GET /api/users/:key/playlists`, optionally only those in the playlist
|
||||
* category `categoryId`. The private ones are listed only when
|
||||
* `requestingUserId` is `userId`.
|
||||
*/
|
||||
export function listPlaylistsByUser(
|
||||
@@ -204,16 +253,19 @@ export function listPlaylistsByUser(
|
||||
requestingUserId: string | null,
|
||||
page: number,
|
||||
limit: number,
|
||||
categoryId?: string,
|
||||
): Page<Playlist> {
|
||||
const isOwner = requestingUserId === userId;
|
||||
const offset = (page - 1) * limit;
|
||||
const privacyFilter = isOwner ? "" : " AND p.is_public = 1";
|
||||
const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId);
|
||||
|
||||
const sql = isOwner
|
||||
? `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`
|
||||
: `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`;
|
||||
|
||||
const rows = db.prepare(sql).all(userId, limit + 1, offset);
|
||||
return pageOf(parseRows(rows, isPlaylistRow, playlistRowToApi), limit);
|
||||
const rows = db.prepare(
|
||||
`SELECT ${PLAYLIST_SELECT} ${catJoin}
|
||||
WHERE p.user_id = ?${privacyFilter}
|
||||
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(...catParams, userId, limit + 1, offset);
|
||||
return pageOf(playlistsOf(rows), limit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,8 +279,9 @@ export function listPlaylistsByUser(
|
||||
* attachments and broadcasts `playlist_updated`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist, 403 when the user
|
||||
* is neither its owner nor a moderator, 400 when the slug isn't valid, 409
|
||||
* when it is taken, 429 when it changed too often lately.
|
||||
* is neither its owner nor a moderator, 400 when the slug isn't valid or a
|
||||
* category id names no playlist category, 409 when the slug is taken, 429
|
||||
* when it changed too often lately.
|
||||
*/
|
||||
export function updatePlaylist(
|
||||
playlistId: string,
|
||||
@@ -250,20 +303,28 @@ export function updatePlaylist(
|
||||
? req.isPublic
|
||||
: playlist.isPublic;
|
||||
|
||||
if (req.categoryIds !== undefined) {
|
||||
assertCategoriesExist("playlist", req.categoryIds);
|
||||
}
|
||||
if (req.slug !== undefined) {
|
||||
changeSlug("playlist", playlist, req.slug, canModerate);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
db.prepare(
|
||||
`UPDATE playlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`,
|
||||
).run(
|
||||
newTitle,
|
||||
newDescription,
|
||||
newIsPublic ? 1 : 0,
|
||||
now.toISOString(),
|
||||
playlist.id,
|
||||
);
|
||||
transaction(() => {
|
||||
db.prepare(
|
||||
`UPDATE playlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`,
|
||||
).run(
|
||||
newTitle,
|
||||
newDescription,
|
||||
newIsPublic ? 1 : 0,
|
||||
now.toISOString(),
|
||||
playlist.id,
|
||||
);
|
||||
if (req.categoryIds !== undefined) {
|
||||
setCategories("playlist", playlist.id, req.categoryIds);
|
||||
}
|
||||
});
|
||||
|
||||
const updated: Playlist = {
|
||||
...playlist,
|
||||
@@ -272,6 +333,9 @@ export function updatePlaylist(
|
||||
description: newDescription ?? undefined,
|
||||
isPublic: newIsPublic,
|
||||
updatedAt: now,
|
||||
categoryIds: req.categoryIds !== undefined
|
||||
? [...new Set(req.categoryIds)]
|
||||
: playlist.categoryIds,
|
||||
};
|
||||
if (newDescription) {
|
||||
if (newIsPublic) {
|
||||
@@ -536,7 +600,7 @@ export function getPlaylistMembershipsForDump(
|
||||
ORDER BY p.created_at DESC;`,
|
||||
).all(dumpId, userId);
|
||||
|
||||
return rows.map((row) => {
|
||||
const memberships = rows.map((row) => {
|
||||
if (!isPlaylistRow(row)) {
|
||||
throw new APIException(500, "Malformed playlist data");
|
||||
}
|
||||
@@ -545,6 +609,8 @@ export function getPlaylistMembershipsForDump(
|
||||
hasDump: Boolean(row.has_dump),
|
||||
};
|
||||
});
|
||||
attachCategoryIds("playlist", memberships.map((m) => m.playlist));
|
||||
return memberships;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -564,7 +630,7 @@ export function getPlaylistsForDump(
|
||||
ORDER BY p.created_at DESC LIMIT 20;`,
|
||||
).all(dumpId, requestingUserId ?? "");
|
||||
|
||||
return parseRows(rows, isPlaylistRow, playlistRowToApi);
|
||||
return playlistsOf(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,14 +28,14 @@ Deno.test("runMigrations: a fresh database is left as schema.sql made it", () =>
|
||||
.get() as {
|
||||
n: number;
|
||||
};
|
||||
assertEquals(applied.n, 24);
|
||||
assertEquals(applied.n, 25);
|
||||
// Running again applies nothing.
|
||||
runMigrations(db);
|
||||
assertEquals(
|
||||
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
|
||||
n: number;
|
||||
}).n,
|
||||
24,
|
||||
25,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
108
api/test/db/migrations/0025_playlist_categories.test.ts
Normal file
108
api/test/db/migrations/0025_playlist_categories.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import "../../setup.ts";
|
||||
import { assertEquals, assertThrows } from "@std/assert";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { up as up0025 } from "../../../db/migrations/0025_playlist_categories.ts";
|
||||
|
||||
/** Returns a database with the tables 0025 changes, as 0006 made them. */
|
||||
function oldDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec(`PRAGMA foreign_keys = ON;`);
|
||||
db.exec(`
|
||||
CREATE TABLE dumps (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE playlists (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE TABLE dump_categories (
|
||||
dump_id TEXT NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
PRIMARY KEY (dump_id, category_id),
|
||||
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
Deno.test("migration 0025 keeps the categories as dump categories, with their dumps", () => {
|
||||
const db = oldDb();
|
||||
db.exec(`INSERT INTO dumps (id) VALUES ('d1'), ('d2');`);
|
||||
db.exec(
|
||||
`INSERT INTO categories (id, slug, name, position, created_at) VALUES
|
||||
('c1', 'music', 'Music', 2, '2026-01-01T00:00:00.000Z'),
|
||||
('c2', 'playlists', 'Playlists', 1, '2026-01-02T00:00:00.000Z');`,
|
||||
);
|
||||
db.exec(
|
||||
`INSERT INTO dump_categories (dump_id, category_id) VALUES
|
||||
('d1', 'c1'), ('d2', 'c1'), ('d2', 'c2');`,
|
||||
);
|
||||
|
||||
db.exec("BEGIN;");
|
||||
up0025(db);
|
||||
db.exec("COMMIT;");
|
||||
up0025(db); // idempotent
|
||||
|
||||
assertEquals(
|
||||
db.prepare(
|
||||
`SELECT id, kind, slug, name, position FROM categories ORDER BY id;`,
|
||||
).all().map((r) => ({ ...r })),
|
||||
[
|
||||
{ id: "c1", kind: "dump", slug: "music", name: "Music", position: 2 },
|
||||
// `playlists` is now the page of every playlist.
|
||||
{
|
||||
id: "c2",
|
||||
kind: "dump",
|
||||
slug: "playlists-2",
|
||||
name: "Playlists",
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
assertEquals(
|
||||
db.prepare(
|
||||
`SELECT dump_id, category_id FROM dump_categories ORDER BY dump_id, category_id;`,
|
||||
).all().map((r) => ({ ...r })),
|
||||
[
|
||||
{ dump_id: "d1", category_id: "c1" },
|
||||
{ dump_id: "d2", category_id: "c1" },
|
||||
{ dump_id: "d2", category_id: "c2" },
|
||||
],
|
||||
);
|
||||
|
||||
// A slug or name is unique within its kind only.
|
||||
db.exec(
|
||||
`INSERT INTO categories (id, kind, slug, name, created_at)
|
||||
VALUES ('p1', 'playlist', 'music', 'Music', '2026-01-03T00:00:00.000Z');`,
|
||||
);
|
||||
assertThrows(() =>
|
||||
db.exec(
|
||||
`INSERT INTO categories (id, kind, slug, name, created_at)
|
||||
VALUES ('p2', 'playlist', 'music', 'Other', '2026-01-03T00:00:00.000Z');`,
|
||||
)
|
||||
);
|
||||
|
||||
// The links still cascade.
|
||||
db.exec(`INSERT INTO playlists (id) VALUES ('pl1');`);
|
||||
db.exec(
|
||||
`INSERT INTO playlist_categories (playlist_id, category_id) VALUES ('pl1', 'p1');`,
|
||||
);
|
||||
db.exec(`DELETE FROM categories WHERE id IN ('c1', 'p1');`);
|
||||
assertEquals(
|
||||
(db.prepare(`SELECT COUNT(*) as n FROM dump_categories;`).get() as {
|
||||
n: number;
|
||||
}).n,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
(db.prepare(`SELECT COUNT(*) as n FROM playlist_categories;`).get() as {
|
||||
n: number;
|
||||
}).n,
|
||||
0,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "../services/email-service.ts";
|
||||
import type { APIError, APIResponse } from "../../shared/api.ts";
|
||||
import type { Role } from "../../shared/user.ts";
|
||||
import type { CategoryKind } from "../../shared/category.ts";
|
||||
|
||||
export { app, db, TEST_DIR };
|
||||
|
||||
@@ -40,6 +41,7 @@ const TABLES = [
|
||||
"federation_outbox",
|
||||
"dump_backlinks",
|
||||
"dump_categories",
|
||||
"playlist_categories",
|
||||
"playlist_dumps",
|
||||
"comment_likes",
|
||||
"comments",
|
||||
@@ -268,13 +270,19 @@ export function insertComment(
|
||||
}
|
||||
|
||||
export function insertCategory(
|
||||
opts: { slug: string; name?: string; position?: number },
|
||||
opts: {
|
||||
slug: string;
|
||||
name?: string;
|
||||
position?: number;
|
||||
kind?: CategoryKind;
|
||||
},
|
||||
): string {
|
||||
const id = crypto.randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO categories (id, slug, name, position, created_at) VALUES (?, ?, ?, ?, ?);`,
|
||||
`INSERT INTO categories (id, kind, slug, name, position, created_at) VALUES (?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
id,
|
||||
opts.kind ?? "dump",
|
||||
opts.slug,
|
||||
opts.name ?? opts.slug,
|
||||
opts.position ?? 0,
|
||||
|
||||
@@ -10,6 +10,7 @@ Deno.test("category slugs: the feed tabs, app pages and server paths are reserve
|
||||
"settings",
|
||||
"manage",
|
||||
"community",
|
||||
"playlists",
|
||||
"api",
|
||||
"ws",
|
||||
]
|
||||
@@ -17,7 +18,7 @@ Deno.test("category slugs: the feed tabs, app pages and server paths are reserve
|
||||
assert(isReservedSlug(word), word);
|
||||
}
|
||||
// v3's words are free again: nothing lives at /dumps or /users now.
|
||||
for (const word of ["music", "dumps", "users", "playlists", "~"]) {
|
||||
for (const word of ["music", "dumps", "users", "~"]) {
|
||||
assertFalse(isReservedSlug(word), word);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ test("categories: listed by position, then slug", async () => {
|
||||
test("categories: only category:manage may create, update or delete", async () => {
|
||||
const admin = await createUser({ role: "admin" });
|
||||
const mod = await createUser({ role: "moderator" });
|
||||
const json = { slug: "music", name: "Music" };
|
||||
const json = { kind: "dump", slug: "music", name: "Music" };
|
||||
assertEquals((await post("/api/categories", { json })).status, 401);
|
||||
assertEquals(
|
||||
(await post("/api/categories", { auth: mod.auth, json })).status,
|
||||
@@ -64,13 +64,17 @@ test("categories: only category:manage may create, update or delete", async () =
|
||||
|
||||
test("categories: slug and name rules, and friendly conflicts", async () => {
|
||||
const admin = await createUser({ role: "admin" });
|
||||
const create = (json: unknown) =>
|
||||
post<Category>("/api/categories", { auth: admin.auth, json });
|
||||
const create = (json: Record<string, unknown>) =>
|
||||
post<Category>("/api/categories", {
|
||||
auth: admin.auth,
|
||||
json: { kind: "dump", ...json },
|
||||
});
|
||||
for (
|
||||
const [json, message] of [
|
||||
[{ slug: "Bad Slug", name: "x" }, "lowercase"],
|
||||
[{ slug: "hot", name: "x" }, "reserved"],
|
||||
[{ slug: "settings", name: "x" }, "reserved"],
|
||||
[{ slug: "playlists", name: "x" }, "reserved"],
|
||||
[{ slug: "a".repeat(51), name: "x" }, "at most 50"],
|
||||
[{ slug: "ok", name: " " }, "Name is required"],
|
||||
[{ slug: "ok", name: "x".repeat(51) }, "at most 50"],
|
||||
@@ -144,3 +148,37 @@ test("GET /api/dumps?category=: members only, private for the owner", async () =
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("categories: each kind is a set apart", async () => {
|
||||
const admin = await createUser({ role: "admin" });
|
||||
const create = (json: Record<string, unknown>) =>
|
||||
post<Category>("/api/categories", { auth: admin.auth, json });
|
||||
|
||||
assertEquals((await create({ slug: "music", name: "Music" })).status, 400);
|
||||
const dump = await create({ kind: "dump", slug: "music", name: "Music" });
|
||||
assertEquals(dump.data.kind, "dump");
|
||||
// The same slug and name are free in the other kind, taken in the same.
|
||||
const playlist = await create({
|
||||
kind: "playlist",
|
||||
slug: "music",
|
||||
name: "Music",
|
||||
});
|
||||
assertEquals(playlist.status, 201);
|
||||
assertEquals(playlist.data.kind, "playlist");
|
||||
assertEquals(
|
||||
(await create({ kind: "playlist", slug: "music", name: "Other" })).status,
|
||||
409,
|
||||
);
|
||||
// A playlist category's page lives under /playlists/: nothing is reserved.
|
||||
assertEquals(
|
||||
(await create({ kind: "playlist", slug: "hot", name: "Hot" })).status,
|
||||
201,
|
||||
);
|
||||
|
||||
const list = await get<Category[]>("/api/categories");
|
||||
assertEquals(list.data.map((c) => [c.kind, c.slug]), [
|
||||
["dump", "music"],
|
||||
["playlist", "hot"],
|
||||
["playlist", "music"],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -130,6 +130,18 @@ test("POST /api/dumps: an unknown category is refused and nothing is left behind
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
|
||||
});
|
||||
|
||||
test("POST /api/dumps: a playlist category is refused", async () => {
|
||||
const user = await createUser();
|
||||
const lists = insertCategory({ slug: "mixes", kind: "playlist" });
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://example.com/e", categoryIds: [lists] },
|
||||
});
|
||||
assertEquals(res.status, 400);
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
|
||||
});
|
||||
|
||||
test("POST /api/dumps: a file dump is stored and served back", async () => {
|
||||
const user = await createUser();
|
||||
const res = await post<Dump>("/api/dumps", {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
del,
|
||||
formWithFile,
|
||||
get,
|
||||
insertCategory,
|
||||
insertDump,
|
||||
insertPlaylist,
|
||||
PNG,
|
||||
@@ -539,3 +540,126 @@ test("reorder: a moderator orders around the owner's private dumps", async () =>
|
||||
b.id,
|
||||
]);
|
||||
});
|
||||
|
||||
test("playlist categories: filed on creation, replaced on update, dump categories refused", async () => {
|
||||
const user = await createUser();
|
||||
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
|
||||
const live = insertCategory({ slug: "live", kind: "playlist" });
|
||||
const music = insertCategory({ slug: "music" });
|
||||
|
||||
const refused = await post("/api/playlists", {
|
||||
auth: user.auth,
|
||||
json: { title: "Wrong", isPublic: true, categoryIds: [music] },
|
||||
});
|
||||
assertEquals(refused.status, 400);
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM playlists;`), 0);
|
||||
assertEquals(
|
||||
(await post("/api/playlists", {
|
||||
auth: user.auth,
|
||||
json: { title: "Bad", isPublic: true, categoryIds: "mixes" },
|
||||
})).status,
|
||||
400,
|
||||
);
|
||||
|
||||
const created = await post<Playlist>("/api/playlists", {
|
||||
auth: user.auth,
|
||||
json: { title: "Mix", isPublic: true, categoryIds: [mixes, mixes] },
|
||||
});
|
||||
assertEquals(created.status, 201);
|
||||
assertEquals(created.data.categoryIds, [mixes]);
|
||||
assertEquals(
|
||||
(await get<Playlist>(`/api/playlists/${created.data.id}`)).data
|
||||
.categoryIds,
|
||||
[mixes],
|
||||
);
|
||||
|
||||
const id = created.data.id;
|
||||
assertEquals(
|
||||
(await put(`/api/playlists/${id}`, {
|
||||
auth: user.auth,
|
||||
json: { categoryIds: [music] },
|
||||
})).status,
|
||||
400,
|
||||
);
|
||||
const updated = await put<Playlist>(`/api/playlists/${id}`, {
|
||||
auth: user.auth,
|
||||
json: { categoryIds: [live] },
|
||||
});
|
||||
assertEquals(updated.data.categoryIds, [live]);
|
||||
// A change of anything else keeps them.
|
||||
const renamed = await put<Playlist>(`/api/playlists/${id}`, {
|
||||
auth: user.auth,
|
||||
json: { title: "Live mix" },
|
||||
});
|
||||
assertEquals(renamed.data.categoryIds, [live]);
|
||||
const cleared = await put<Playlist>(`/api/playlists/${id}`, {
|
||||
auth: user.auth,
|
||||
json: { categoryIds: [] },
|
||||
});
|
||||
assertEquals(cleared.data.categoryIds, []);
|
||||
});
|
||||
|
||||
test("GET /api/playlists: every public playlist, newest first, by category", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
|
||||
insertCategory({ slug: "music" });
|
||||
const a = insertPlaylist({ userId: owner.id, title: "A" });
|
||||
insertPlaylist({ userId: other.id, title: "B" });
|
||||
const hidden = insertPlaylist({
|
||||
userId: owner.id,
|
||||
title: "Hidden",
|
||||
isPublic: false,
|
||||
});
|
||||
db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`)
|
||||
.run("2026-01-01T00:00:00.000Z", a.id);
|
||||
for (const p of [a, hidden]) {
|
||||
db.prepare(
|
||||
`INSERT INTO playlist_categories (playlist_id, category_id) VALUES (?, ?);`,
|
||||
).run(p.id, mixes);
|
||||
}
|
||||
|
||||
const all = await get<PaginatedData<Playlist>>("/api/playlists", {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(all.data.items.map((p) => p.title), ["B", "A"]);
|
||||
assertEquals(all.data.items[1].categoryIds, [mixes]);
|
||||
assertEquals(
|
||||
(await get<PaginatedData<Playlist>>("/api/playlists?limit=1")).data
|
||||
.hasMore,
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
(await get<PaginatedData<Playlist>>("/api/playlists?category=mixes")).data
|
||||
.items.map((p) => p.id),
|
||||
[a.id],
|
||||
);
|
||||
// A dump category's slug names no playlist category.
|
||||
assertEquals((await get("/api/playlists?category=music")).status, 404);
|
||||
assertEquals((await get("/api/playlists?category=nope")).status, 404);
|
||||
});
|
||||
|
||||
test("GET /api/users/:key/playlists?category=: their playlists in one category", async () => {
|
||||
const owner = await createUser();
|
||||
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
|
||||
const a = insertPlaylist({ userId: owner.id, title: "A" });
|
||||
insertPlaylist({ userId: owner.id, title: "B" });
|
||||
db.prepare(
|
||||
`INSERT INTO playlist_categories (playlist_id, category_id) VALUES (?, ?);`,
|
||||
).run(a.id, mixes);
|
||||
|
||||
const url = `/api/users/${owner.id}/playlists`;
|
||||
assertEquals(
|
||||
(await get<PaginatedData<Playlist>>(url)).data.items.length,
|
||||
2,
|
||||
);
|
||||
assertEquals(
|
||||
(await get<PaginatedData<Playlist>>(`${url}?category=mixes`)).data.items
|
||||
.map((p) => p.id),
|
||||
[a.id],
|
||||
);
|
||||
assertEquals(
|
||||
(await get<PaginatedData<Playlist>>(`${url}?category=nope`)).data.items,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createUser,
|
||||
db,
|
||||
get,
|
||||
insertCategory,
|
||||
insertDump,
|
||||
insertPlaylist,
|
||||
test,
|
||||
@@ -62,3 +63,44 @@ test("GET /api/search: dumps, users and playlists, privacy-filtered", async () =
|
||||
playlists: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("GET /api/search: category and playlistCategory narrow each list", async () => {
|
||||
const owner = await createUser();
|
||||
const music = insertCategory({ slug: "music" });
|
||||
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
|
||||
const filed = insertDump({ userId: owner.id, title: "Banana song" });
|
||||
insertDump({ userId: owner.id, title: "Banana bread" });
|
||||
const list = insertPlaylist({ userId: owner.id, title: "Banana mix" });
|
||||
insertPlaylist({ userId: owner.id, title: "Banana bin" });
|
||||
db.prepare(
|
||||
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
|
||||
).run(filed.id, music);
|
||||
db.prepare(
|
||||
`INSERT INTO playlist_categories (playlist_id, category_id) VALUES (?, ?);`,
|
||||
).run(list.id, mixes);
|
||||
|
||||
const all = (await get<SearchResults>("/api/search?q=banana")).data;
|
||||
assertEquals(all.dumps.total, 2);
|
||||
assertEquals(all.playlists.length, 2);
|
||||
|
||||
const dumps = (await get<SearchResults>(
|
||||
"/api/search?q=banana&category=music",
|
||||
)).data;
|
||||
assertEquals(dumps.dumps.items.map((d) => d.id), [filed.id]);
|
||||
assertEquals(dumps.dumps.total, 1);
|
||||
assertEquals(dumps.playlists.length, 2);
|
||||
|
||||
const playlists = (await get<SearchResults>(
|
||||
"/api/search?q=banana&playlistCategory=mixes",
|
||||
)).data;
|
||||
assertEquals(playlists.dumps.total, 2);
|
||||
assertEquals(playlists.playlists.map((p) => p.id), [list.id]);
|
||||
assertEquals(playlists.playlists[0].categoryIds, [mixes]);
|
||||
|
||||
// Each names a category of its own kind only.
|
||||
const crossed = (await get<SearchResults>(
|
||||
"/api/search?q=banana&category=mixes&playlistCategory=music",
|
||||
)).data;
|
||||
assertEquals(crossed.dumps.total, 0);
|
||||
assertEquals(crossed.playlists, []);
|
||||
});
|
||||
|
||||
@@ -6,10 +6,27 @@ import {
|
||||
} from "../../validators/category.ts";
|
||||
|
||||
Deno.test("category requests", () => {
|
||||
assert(isCreateCategoryRequest({ slug: "a", name: "A" }));
|
||||
assert(isCreateCategoryRequest({ slug: "a", name: "A", position: 2 }));
|
||||
assertFalse(isCreateCategoryRequest({ slug: "a" }));
|
||||
assertFalse(isCreateCategoryRequest({ slug: "a", name: "A", position: "2" }));
|
||||
assert(isCreateCategoryRequest({ kind: "dump", slug: "a", name: "A" }));
|
||||
assert(isCreateCategoryRequest({ kind: "playlist", slug: "a", name: "A" }));
|
||||
assert(
|
||||
isCreateCategoryRequest({
|
||||
kind: "dump",
|
||||
slug: "a",
|
||||
name: "A",
|
||||
position: 2,
|
||||
}),
|
||||
);
|
||||
assertFalse(isCreateCategoryRequest({ slug: "a", name: "A" }));
|
||||
assertFalse(isCreateCategoryRequest({ kind: "user", slug: "a", name: "A" }));
|
||||
assertFalse(isCreateCategoryRequest({ kind: "dump", slug: "a" }));
|
||||
assertFalse(
|
||||
isCreateCategoryRequest({
|
||||
kind: "dump",
|
||||
slug: "a",
|
||||
name: "A",
|
||||
position: "2",
|
||||
}),
|
||||
);
|
||||
assert(isUpdateCategoryRequest({}));
|
||||
assertFalse(isUpdateCategoryRequest({ name: 1 }));
|
||||
});
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type {
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
import {
|
||||
type CreateCategoryRequest,
|
||||
isCategoryKind,
|
||||
type UpdateCategoryRequest,
|
||||
} from "../../shared/category.ts";
|
||||
|
||||
/** Checks whether `obj` has the fields of a {@link CreateCategoryRequest}. */
|
||||
@@ -16,6 +17,7 @@ export function isCreateCategoryRequest(
|
||||
): obj is CreateCategoryRequest {
|
||||
if (!obj || typeof obj !== "object") return false;
|
||||
const o = obj as Record<string, unknown>;
|
||||
if (!isCategoryKind(o.kind)) return false;
|
||||
if (typeof o.slug !== "string") return false;
|
||||
if (typeof o.name !== "string") return false;
|
||||
if ("position" in o && typeof o.position !== "number") return false;
|
||||
|
||||
@@ -11,6 +11,13 @@ import type {
|
||||
} from "../../shared/playlist.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
|
||||
/** Checks that `categoryIds`, when `o` has it, is a list of strings. */
|
||||
function hasValidCategoryIds(o: Record<string, unknown>): boolean {
|
||||
return !("categoryIds" in o) ||
|
||||
(Array.isArray(o.categoryIds) &&
|
||||
o.categoryIds.every((id) => typeof id === "string"));
|
||||
}
|
||||
|
||||
/** Checks that `title` isn't blank and, trimmed, fits the limit. */
|
||||
function isValidTitle(title: string): boolean {
|
||||
const trimmed = title.trim();
|
||||
@@ -20,7 +27,8 @@ function isValidTitle(title: string): boolean {
|
||||
/**
|
||||
* Checks whether `obj` is a {@link CreatePlaylistRequest} with a title that
|
||||
* isn't blank, and a title and description within the `VALIDATION` limits. A
|
||||
* null description passes.
|
||||
* null description passes. Whether its category ids name playlist categories
|
||||
* is the playlist service's to check.
|
||||
*/
|
||||
export function isCreatePlaylistRequest(
|
||||
obj: unknown,
|
||||
@@ -40,7 +48,7 @@ export function isCreatePlaylistRequest(
|
||||
typeof o.description === "string" &&
|
||||
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
|
||||
) return false;
|
||||
return true;
|
||||
return hasValidCategoryIds(o);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +76,7 @@ export function isUpdatePlaylistRequest(
|
||||
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
|
||||
) return false;
|
||||
if ("isPublic" in o && typeof o.isPublic !== "boolean") return false;
|
||||
return true;
|
||||
return hasValidCategoryIds(o);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user