v3: added content slugs, fixed real-time updates in client, added @mentions across the app, added new file selector and drop zone

This commit is contained in:
khannurien
2026-03-22 16:06:26 +00:00
parent 39a0cc397e
commit 34e908d1bc
42 changed files with 2170 additions and 628 deletions

18
api/lib/slugify.ts Normal file
View File

@@ -0,0 +1,18 @@
export const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function slugify(title: string): string {
const slug = title
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // strip diacritics
.replace(/[^a-z0-9]+/g, "-") // non-alphanumeric → dash
.replace(/^-+|-+$/g, "") // trim leading/trailing dashes
.substring(0, 60);
return slug || "untitled";
}
/** Stable slug tied to the record's id — unique by construction. */
export function makeSlug(title: string, id: string): string {
return `${slugify(title)}-${id.substring(0, 8)}`;
}

View File

@@ -8,10 +8,41 @@ import {
type RichContent,
type User,
} from "./interfaces.ts";
import { makeSlug } from "../lib/slugify.ts";
export const db = new DatabaseSync("api/sql/gerbeur.db");
db.exec("PRAGMA foreign_keys = ON;");
// Add columns to existing tables if missing (idempotent migrations)
for (
const [table, col, def] of [
["dumps", "updated_at", "TEXT"],
["users", "updated_at", "TEXT"],
["playlists", "updated_at", "TEXT"],
["comments", "updated_at", "TEXT"],
["dumps", "slug", "TEXT"],
["playlists", "slug", "TEXT"],
] as [string, string, string][]
) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as {
name: string;
}[];
if (!cols.some((c) => c.name === col)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def};`);
}
}
// Backfill slugs for any records created before this migration
for (const table of ["dumps", "playlists"] as const) {
const rows = db.prepare(
`SELECT id, title FROM ${table} WHERE slug IS NULL;`,
).all() as { id: string; title: string }[];
const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?;`);
for (const row of rows) {
update.run(makeSlug(row.title, row.id), row.id);
}
}
// Purge expired unused invites on startup
db.prepare(
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
@@ -28,6 +59,8 @@ export interface DumpRow {
comment: string | null;
user_id: string;
created_at: string;
updated_at: string | null;
slug: string | null;
url: string | null;
rich_content: string | null;
file_name: string | null;
@@ -45,6 +78,7 @@ export interface UserRow {
password_hash: string;
is_admin: number;
created_at: string;
updated_at: string | null;
avatar_mime: string | null;
invited_by: string | null;
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
@@ -100,9 +134,11 @@ export function dumpRowToApi(row: DumpRow): Dump {
id: row.id,
kind: row.kind as "url" | "file",
title: row.title,
slug: row.slug ?? undefined,
comment: row.comment ?? undefined,
userId: row.user_id,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
url: row.url ?? undefined,
richContent: row.rich_content
? (JSON.parse(row.rich_content) as RichContent)
@@ -121,9 +157,11 @@ export function dumpApiToRow(dump: Dump): DumpRow {
id: dump.id,
kind: dump.kind,
title: dump.title,
slug: dump.slug ?? null,
comment: dump.comment ?? null,
user_id: dump.userId,
created_at: dump.createdAt.toISOString(),
updated_at: dump.updatedAt?.toISOString() ?? null,
url: dump.url ?? null,
rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null,
file_name: dump.fileName ?? null,
@@ -142,6 +180,7 @@ export function userRowToApi(row: UserRow): User {
passwordHash: row.password_hash,
isAdmin: Boolean(row.is_admin),
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
avatarMime: row.avatar_mime ?? undefined,
invitedByUsername: typeof row.invited_by_username === "string"
? row.invited_by_username
@@ -156,6 +195,7 @@ export function userApiToRow(user: User): UserRow {
password_hash: user.passwordHash,
is_admin: user.isAdmin ? 1 : 0,
created_at: user.createdAt.toISOString(),
updated_at: user.updatedAt?.toISOString() ?? null,
avatar_mime: user.avatarMime ?? null,
invited_by: null,
invited_by_username: null,
@@ -169,6 +209,7 @@ export interface CommentRow {
parent_id: string | null;
body: string;
created_at: string;
updated_at: string | null;
deleted: number;
author_username: string;
author_avatar_mime: string | null;
@@ -199,6 +240,7 @@ export function commentRowToApi(row: CommentRow): Comment {
parentId: row.parent_id ?? undefined,
body: row.body,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
deleted: Boolean(row.deleted),
authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined,
@@ -209,9 +251,11 @@ export interface PlaylistRow {
id: string;
user_id: string;
title: string;
slug: string | null;
description: string | null;
is_public: number;
created_at: string;
updated_at: string | null;
image_mime: string | null;
[key: string]: SQLOutputValue;
}
@@ -231,9 +275,11 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
id: row.id,
userId: row.user_id,
title: row.title,
slug: row.slug ?? undefined,
description: row.description ?? undefined,
isPublic: Boolean(row.is_public),
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
imageMime: row.image_mime ?? undefined,
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
ownerUsername: typeof row.owner_username === "string"

View File

@@ -17,9 +17,11 @@ export interface Dump {
id: string;
kind: "url" | "file";
title: string;
slug?: string;
comment?: string;
userId: string;
createdAt: Date;
updatedAt?: Date;
url?: string;
richContent?: RichContent;
fileName?: string;
@@ -40,6 +42,7 @@ export interface User {
passwordHash: string;
isAdmin: boolean;
createdAt: Date;
updatedAt?: Date;
avatarMime?: string;
invitedByUsername?: string;
}
@@ -177,6 +180,7 @@ export interface Comment {
parentId?: string;
body: string;
createdAt: Date;
updatedAt?: Date;
deleted: boolean;
authorUsername: string;
authorAvatarMime?: string;
@@ -197,6 +201,18 @@ export function isCreateCommentRequest(
o.parentId === null);
}
export interface UpdateCommentRequest {
body: string;
}
export function isUpdateCommentRequest(
obj: unknown,
): obj is UpdateCommentRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
return typeof o.body === "string" && (o.body as string).trim().length > 0;
}
/**
* Playlists
*/
@@ -205,9 +221,11 @@ export interface Playlist {
id: string;
userId: string;
title: string;
slug?: string;
description?: string;
isPublic: boolean;
createdAt: Date;
updatedAt?: Date;
imageMime?: string;
dumpCount?: number;
ownerUsername?: string;
@@ -384,7 +402,8 @@ export type NotificationType =
| "user_followed"
| "user_dump_posted"
| "playlist_dump_added"
| "dump_upvoted";
| "dump_upvoted"
| "user_mentioned";
export interface PlaylistFollowedData {
followerId: string;
@@ -419,12 +438,22 @@ export interface DumpUpvotedData {
dumpTitle: string;
}
export interface UserMentionedData {
mentionerId: string;
mentionerUsername: string;
contextType: "comment" | "dump" | "playlist";
contextId: string;
contextTitle: string;
dumpId?: string;
}
export type NotificationData =
| PlaylistFollowedData
| UserFollowedData
| UserDumpPostedData
| PlaylistDumpAddedData
| DumpUpvotedData;
| DumpUpvotedData
| UserMentionedData;
export interface Notification {
id: string;

View File

@@ -5,6 +5,7 @@ import {
type APIResponse,
type Comment,
isCreateCommentRequest,
isUpdateCommentRequest,
} from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { verifyJWT } from "../lib/jwt.ts";
@@ -12,11 +13,13 @@ import {
createComment,
deleteComment,
getComments,
updateComment,
} from "../services/comment-service.ts";
import { getDump } from "../services/dump-service.ts";
import {
broadcastCommentCreated,
broadcastCommentDeleted,
broadcastCommentUpdated,
} from "../services/ws-service.ts";
const router = new Router({ prefix: "/api" });
@@ -62,6 +65,29 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
ctx.response.body = responseBody;
});
// PATCH /api/comments/:commentId — auth required
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId as string;
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
const body = await ctx.request.body.json();
if (!isUpdateCommentRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid comment data",
);
}
const { comment, isPrivate } = updateComment(
ctx.params.commentId,
body.body,
userId,
isAdmin,
);
if (!isPrivate) broadcastCommentUpdated(comment);
const responseBody: APIResponse<Comment> = { success: true, data: comment };
ctx.response.body = responseBody;
});
// DELETE /api/comments/:commentId — auth required
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
const userId = ctx.state.user.userId as string;

View File

@@ -14,6 +14,7 @@ import {
createUser,
getUserById,
getUserByUsername,
searchUsers,
} from "../services/user-service.ts";
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
import {
@@ -131,6 +132,13 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
}
});
// User search for @mention autocomplete
router.get("/search", (ctx) => {
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
const results = searchUsers(q, 8);
ctx.response.body = { success: true, data: results };
});
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
router.get("/by-id/:userId", (ctx) => {
const user = getUserById(ctx.params.userId);

View File

@@ -10,9 +10,10 @@ import {
db,
isCommentRow,
} from "../model/db.ts";
import { notifyMentions } from "./notification-service.ts";
const SELECT_COLS =
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.deleted,
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted,
u.username as author_username, u.avatar_mime as author_avatar_mime`;
function fetchComment(commentId: string): Comment {
@@ -59,7 +60,63 @@ export function createComment(
body.trim(),
createdAt.toISOString(),
);
return fetchComment(id);
const comment = fetchComment(id);
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
dumpId,
) as { title: string } | undefined;
notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId);
return comment;
}
export function updateComment(
commentId: string,
body: string,
requestingUserId: string,
isAdmin: boolean,
): { comment: Comment; dumpId: string; isPrivate: boolean } {
const row = db.prepare(
`SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
).get(commentId) as { dump_id: string; is_private: number } | undefined;
if (!row) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
}
const existing = fetchComment(commentId);
if (existing.deleted) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Cannot edit a deleted comment",
);
}
if (existing.userId !== requestingUserId && !isAdmin) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Not authorized to edit this comment",
);
}
const now = new Date().toISOString();
db.prepare(`UPDATE comments SET body = ?, updated_at = ? WHERE id = ?;`).run(
body.trim(),
now,
commentId,
);
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
row.dump_id,
) as { title: string } | undefined;
notifyMentions(
requestingUserId,
body,
"comment",
commentId,
dumpRow?.title ?? "",
row.dump_id,
);
return {
comment: fetchComment(commentId),
dumpId: row.dump_id,
isPrivate: Boolean(row.is_private),
};
}
export function deleteComment(

View File

@@ -12,7 +12,11 @@ import {
broadcastDumpUpdated,
broadcastNewDump,
} from "./ws-service.ts";
import { notifyUserFollowersNewDump } from "./notification-service.ts";
import {
notifyMentions,
notifyUserFollowersNewDump,
} from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
const UPLOADS_DIR = "api/uploads";
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
@@ -45,13 +49,13 @@ function titleFromUrl(url: string): string {
}
const BASE_COLS =
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
"id, kind, title, slug, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
const SELECT_COLS = `${BASE_COLS},
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
const SELECT_COLS_ALIASED =
"d.id, d.kind, d.title, d.comment, d.user_id, d.created_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," +
"d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," +
" (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";
export async function createUrlDump(
@@ -67,14 +71,16 @@ export async function createUrlDump(
const richContent = await fetchRichContent(request.url);
const title = richContent?.title ?? titleFromUrl(request.url);
const isPrivate = request.isPrivate ?? false;
const slug = makeSlug(title, dumpId);
db.prepare(
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`,
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, rich_content, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
).run(
dumpId,
"url",
title,
slug,
request.comment ?? null,
userId,
createdAt.toISOString(),
@@ -87,6 +93,7 @@ export async function createUrlDump(
id: dumpId,
kind: "url",
title,
slug,
comment: request.comment,
userId,
createdAt,
@@ -100,6 +107,7 @@ export async function createUrlDump(
broadcastNewDump(dump);
notifyUserFollowersNewDump(userId, dumpId, title);
}
if (request.comment) notifyMentions(userId, request.comment, "dump", dumpId, title);
return dump;
}
@@ -126,6 +134,7 @@ export async function createFileDump(
const dumpId = crypto.randomUUID();
const createdAt = new Date();
const slug = makeSlug(file.name, dumpId);
await Deno.mkdir(UPLOADS_DIR, { recursive: true });
const data = new Uint8Array(await file.arrayBuffer());
@@ -134,12 +143,13 @@ export async function createFileDump(
await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data);
db.prepare(
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, file_name, file_mime, file_size, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
).run(
dumpId,
"file",
file.name,
slug,
comment ?? null,
userId,
createdAt.toISOString(),
@@ -158,6 +168,7 @@ export async function createFileDump(
id: dumpId,
kind: "file",
title: file.name,
slug,
comment,
userId,
createdAt,
@@ -172,14 +183,17 @@ export async function createFileDump(
broadcastNewDump(dump);
notifyUserFollowersNewDump(userId, dumpId, file.name);
}
if (comment) notifyMentions(userId, comment, "dump", dumpId, file.name);
return dump;
}
// Internal fetch — no privacy check. Use only when ownership is already enforced.
function fetchDump(dumpId: string): Dump {
const row = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`,
).get(dumpId);
function fetchDump(idOrSlug: string): Dump {
const row = UUID_RE.test(idOrSlug)
? db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`).get(idOrSlug)
: db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE slug = ?;`).get(
idOrSlug,
);
if (!row || !isDumpRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
@@ -234,6 +248,8 @@ export async function updateDump(
): Promise<Dump> {
const dump = fetchDump(dumpId);
const now = new Date();
// File dumps: only comment and isPrivate are editable
if (dump.kind === "file") {
const updatedDump: Dump = {
@@ -244,10 +260,27 @@ export async function updateDump(
isPrivate: "isPrivate" in request
? (request.isPrivate ?? false)
: dump.isPrivate,
updatedAt: now,
};
db.prepare(`UPDATE dumps SET comment = ?, is_private = ? WHERE id = ?;`)
.run(updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, dumpId);
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
db.prepare(
`UPDATE dumps SET comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run(
updatedDump.comment ?? null,
updatedDump.isPrivate ? 1 : 0,
now.toISOString(),
dumpId,
);
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
if (updatedDump.comment) {
notifyMentions(
dump.userId,
updatedDump.comment,
"dump",
dumpId,
updatedDump.title,
);
}
return updatedDump;
}
@@ -265,9 +298,11 @@ export async function updateDump(
title = richContent?.title ?? titleFromUrl(newUrl);
}
const newSlug = makeSlug(title, dumpId);
const updatedDump: Dump = {
...dump,
title,
slug: newSlug,
comment: "comment" in request
? (request.comment ?? undefined)
: dump.comment,
@@ -276,17 +311,20 @@ export async function updateDump(
isPrivate: "isPrivate" in request
? (request.isPrivate ?? false)
: dump.isPrivate,
updatedAt: now,
};
const row = dumpApiToRow(updatedDump);
const result = db.prepare(
`UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ?, is_private = ? WHERE id = ?;`,
`UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run(
row.title,
row.slug,
row.comment,
row.url,
row.rich_content,
row.is_private,
now.toISOString(),
row.id,
);
@@ -294,7 +332,11 @@ export async function updateDump(
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
if (updatedDump.comment) {
notifyMentions(dump.userId, updatedDump.comment, "dump", dumpId, updatedDump.title);
}
return updatedDump;
}
@@ -326,17 +368,31 @@ export async function replaceFileDump(
const data = new Uint8Array(await file.arrayBuffer());
await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data);
const now = new Date();
const newSlug = makeSlug(file.name, dumpId);
db.prepare(
`UPDATE dumps SET title = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ? WHERE id = ?;`,
).run(file.name, file.name, file.type, file.size, comment ?? null, dumpId);
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`,
).run(
file.name,
newSlug,
file.name,
file.type,
file.size,
comment ?? null,
now.toISOString(),
dumpId,
);
if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name);
return {
...dump,
title: file.name,
slug: newSlug,
fileName: file.name,
fileMime: file.type,
fileSize: file.size,
comment,
updatedAt: now,
};
}
@@ -376,19 +432,20 @@ export function getVotedDumpsByUser(
let totalRow: { count: number } | undefined;
let rawRows: unknown[];
if (requestingUserId) {
if (requestingUserId === userId) {
// Own profile: include private dumps the user themselves voted on and owns.
rawRows = db.prepare(
`SELECT ${dumpCols}
FROM dumps d
INNER JOIN votes v ON d.id = v.dump_id
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?)
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
).all(userId, requestingUserId, limit, offset);
).all(userId, userId, limit, offset);
totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps d
INNER JOIN votes v ON d.id = v.dump_id
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?);`,
).get(userId, requestingUserId) as { count: number } | undefined;
).get(userId, userId) as { count: number } | undefined;
} else {
rawRows = db.prepare(
`SELECT ${dumpCols}

View File

@@ -7,6 +7,9 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts";
import { sendToUser } from "./ws-service.ts";
// Regex: matches @username not already inside a markdown link ([...] or (...)
const MENTION_RE = /(?<![[(\\w])@([\w]+)/g;
// ── Core CRUD ─────────────────────────────────────────────────────────────────
// sourceKey: if set, INSERT OR IGNORE — same (user_id, source_key) pair is a no-op.
@@ -191,6 +194,45 @@ export function notifyDumpOwnerUpvote(
);
}
export function notifyMentions(
mentionerUserId: string,
body: string,
contextType: "comment" | "dump" | "playlist",
contextId: string,
contextTitle: string,
dumpId?: string,
): void {
const mentionerRow = db.prepare(
`SELECT username FROM users WHERE id = ?;`,
).get(mentionerUserId) as { username: string } | undefined;
if (!mentionerRow) return;
const usernames = [...new Set(
[...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase()),
)];
for (const username of usernames) {
const mentionedRow = db.prepare(
`SELECT id FROM users WHERE lower(username) = ?;`,
).get(username) as { id: string } | undefined;
if (!mentionedRow || mentionedRow.id === mentionerUserId) continue;
createNotification(
mentionedRow.id,
"user_mentioned",
{
mentionerId: mentionerUserId,
mentionerUsername: mentionerRow.username,
contextType,
contextId,
contextTitle,
dumpId,
},
`mention:${contextType}:${contextId}:${mentionedRow.id}`,
);
}
}
export function notifyPlaylistFollowersNewDump(
playlistId: string,
playlistTitle: string,

View File

@@ -22,19 +22,23 @@ import {
broadcastPlaylistDumpsUpdated,
broadcastPlaylistUpdated,
} from "./ws-service.ts";
import { notifyPlaylistFollowersNewDump } from "./notification-service.ts";
import {
notifyMentions,
notifyPlaylistFollowersNewDump,
} from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
const DUMP_SELECT_COLS =
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
"id, kind, title, slug, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
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 LEFT JOIN users u ON u.id = p.user_id`;
function getPlaylistById(playlistId: string): Playlist {
const row = db.prepare(
`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`,
).get(playlistId);
function getPlaylistById(idOrSlug: string): Playlist {
const row = UUID_RE.test(idOrSlug)
? db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`).get(idOrSlug)
: db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.slug = ?;`).get(idOrSlug);
if (!row || !isPlaylistRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
}
@@ -47,13 +51,15 @@ export function createPlaylist(
): Playlist {
const id = crypto.randomUUID();
const createdAt = new Date();
const slug = makeSlug(req.title, id);
db.prepare(
`INSERT INTO playlists (id, user_id, title, description, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?);`,
`INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?);`,
).run(
id,
userId,
req.title,
slug,
req.description ?? null,
req.isPublic ? 1 : 0,
createdAt.toISOString(),
@@ -62,10 +68,12 @@ export function createPlaylist(
id,
userId,
title: req.title,
slug,
description: req.description,
isPublic: req.isPublic,
createdAt,
};
if (req.description) notifyMentions(userId, req.description, "playlist", id, req.title);
broadcastPlaylistCreated(playlist);
return playlist;
}
@@ -91,7 +99,7 @@ export function getPlaylist(
WHERE pd.playlist_id = ?
AND (d.is_private = 0 OR d.user_id = ?)
ORDER BY pd.position ASC;`,
).all(playlistId, requestingUserId ?? "");
).all(playlist.id, requestingUserId ?? "");
const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi);
// Owners always see their own private dumps; strip them for non-owners regardless
@@ -145,16 +153,21 @@ export function updatePlaylist(
? req.isPublic
: playlist.isPublic;
const now = new Date();
const newSlug = makeSlug(newTitle, playlist.id);
db.prepare(
`UPDATE playlists SET title = ?, description = ?, is_public = ? WHERE id = ?;`,
).run(newTitle, newDescription, newIsPublic ? 1 : 0, playlistId);
`UPDATE playlists SET title = ?, slug = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`,
).run(newTitle, newSlug, newDescription, newIsPublic ? 1 : 0, now.toISOString(), playlist.id);
const updated: Playlist = {
...playlist,
title: newTitle,
slug: newSlug,
description: newDescription ?? undefined,
isPublic: newIsPublic,
updatedAt: now,
};
if (newDescription) notifyMentions(requestingUserId, newDescription, "playlist", playlist.id, newTitle);
broadcastPlaylistUpdated(updated);
return updated;
}
@@ -169,8 +182,8 @@ export function deletePlaylist(
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
}
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlistId);
broadcastPlaylistDeleted(playlistId, playlist.userId, playlist.isPublic);
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic);
}
export function setPlaylistImage(
@@ -184,9 +197,9 @@ export function setPlaylistImage(
}
db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run(
imageMime,
playlistId,
playlist.id,
);
const updated = getPlaylistById(playlistId);
const updated = getPlaylistById(playlist.id);
broadcastPlaylistUpdated(updated);
return updated;
}
@@ -204,7 +217,7 @@ export function addDumpToPlaylist(
const minRow = db.prepare(
`SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`,
).get(playlistId) as { min_pos: number | null } | undefined;
).get(playlist.id) as { min_pos: number | null } | undefined;
const nextPos = (minRow?.min_pos ?? 1) - 1;
const addedAt = new Date().toISOString();
@@ -213,7 +226,7 @@ export function addDumpToPlaylist(
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, ?, ?);`,
).run(playlistId, dumpId, nextPos, addedAt);
).run(playlist.id, dumpId, nextPos, addedAt);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("UNIQUE") || msg.includes("unique")) {
@@ -226,7 +239,7 @@ export function addDumpToPlaylist(
throw err;
}
const dumpIds = getCurrentDumpIds(playlistId);
const dumpIds = getCurrentDumpIds(playlist.id);
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
if (playlist.isPublic) {
@@ -235,7 +248,7 @@ export function addDumpToPlaylist(
) as { title: string } | undefined;
if (dumpRow) {
notifyPlaylistFollowersNewDump(
playlistId,
playlist.id,
playlist.title,
dumpId,
dumpRow.title,
@@ -257,9 +270,9 @@ export function removeDumpFromPlaylist(
db.prepare(
`DELETE FROM playlist_dumps WHERE playlist_id = ? AND dump_id = ?;`,
).run(playlistId, dumpId);
).run(playlist.id, dumpId);
const dumpIds = getCurrentDumpIds(playlistId);
const dumpIds = getCurrentDumpIds(playlist.id);
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
}
@@ -274,7 +287,7 @@ export function reorderPlaylist(
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
}
const currentIds = getCurrentDumpIds(playlistId);
const currentIds = getCurrentDumpIds(playlist.id);
const currentSet = new Set(currentIds);
const newSet = new Set(dumpIds);
@@ -293,7 +306,7 @@ export function reorderPlaylist(
`UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`,
);
for (let i = 0; i < dumpIds.length; i++) {
update.run(i, playlistId, dumpIds[i]);
update.run(i, playlist.id, dumpIds[i]);
}
broadcastPlaylistDumpsUpdated(playlist, dumpIds);

View File

@@ -81,6 +81,25 @@ export function getUserByUsername(username: string): User {
return userRowToApi(userRow);
}
export function searchUsers(
query: string,
limit: number,
): { id: string; username: string; avatarMime: string | null }[] {
if (!query) return [];
const rows = db.prepare(
`SELECT id, username, avatar_mime FROM users WHERE username LIKE ? ORDER BY username LIMIT ?;`,
).all(`${query}%`, limit) as {
id: string;
username: string;
avatar_mime: string | null;
}[];
return rows.map((r) => ({
id: r.id,
username: r.username,
avatarMime: r.avatar_mime,
}));
}
export function listUsers(): User[] {
const userRows = db.prepare(
`${USER_SELECT}`,
@@ -101,20 +120,23 @@ export async function updateUser(
const { password, ...requestFields } = request;
const now = new Date();
const updatedUser: User = {
...user,
passwordHash: password ? await hashPassword(password) : user.passwordHash,
...requestFields,
updatedAt: now,
};
const updatedUserRow = userApiToRow(updatedUser);
const userResult = db.prepare(
`UPDATE users SET username = ?, password_hash = ?, is_admin = ? WHERE id = ?`,
`UPDATE users SET username = ?, password_hash = ?, is_admin = ?, updated_at = ? WHERE id = ?`,
).run(
updatedUserRow.username,
updatedUserRow.password_hash,
updatedUserRow.is_admin,
now.toISOString(),
updatedUserRow.id,
);
@@ -127,8 +149,8 @@ export async function updateUser(
export function updateUserAvatar(userId: string, mime: string): void {
const result = db.prepare(
`UPDATE users SET avatar_mime = ? WHERE id = ?`,
).run(mime, userId);
`UPDATE users SET avatar_mime = ?, updated_at = ? WHERE id = ?`,
).run(mime, new Date().toISOString(), userId);
if (result.changes === 0) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");

View File

@@ -117,7 +117,11 @@ export function broadcastPlaylistCreated(playlist: Playlist): void {
}
export function broadcastPlaylistUpdated(playlist: Playlist): void {
sendToPlaylistAudience(playlist, { type: "playlist_updated", playlist });
// Broadcast to ALL clients so non-owners can react to visibility changes
// (e.g. remove a now-private playlist from their feed).
for (const client of clients) {
send(client.socket, { type: "playlist_updated", playlist });
}
}
export function broadcastPlaylistDeleted(
@@ -158,6 +162,12 @@ export function broadcastCommentDeleted(
}
}
export function broadcastCommentUpdated(comment: Comment): void {
for (const client of clients) {
send(client.socket, { type: "comment_updated", comment });
}
}
// Keepalive: ping all clients every 30s, remove non-responsive ones
const PING_INTERVAL = 30_000;

View File

@@ -5,6 +5,7 @@ CREATE TABLE dumps (
comment TEXT,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT,
url TEXT,
rich_content TEXT,
file_name TEXT,
@@ -21,6 +22,7 @@ CREATE TABLE users (
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT,
avatar_mime TEXT,
invited_by TEXT REFERENCES users(id)
);
@@ -41,6 +43,7 @@ CREATE TABLE playlists (
description TEXT,
is_public INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT,
image_mime TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
@@ -62,6 +65,7 @@ CREATE TABLE comments (
parent_id TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,