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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user