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:
18
api/lib/slugify.ts
Normal file
18
api/lib/slugify.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user