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:
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user