Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
5.3 KiB
TypeScript
158 lines
5.3 KiB
TypeScript
/**
|
|
* Stores upvotes on dumps and keeps each dump's `vote_count` in step with them.
|
|
*
|
|
* Voting has no HTTP endpoint. The WebSocket route (`routes/ws.ts`) calls this
|
|
* module on `vote_cast` and `vote_remove` messages, then broadcasts the new
|
|
* count. `routes/dumps.ts` uses it only to list a dump's voters.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import { isUserRow, type User, userRowToApi } from "../model/user.ts";
|
|
import { db } from "../db/connection.ts";
|
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
|
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
|
|
|
/**
|
|
* Returned to the WebSocket route by {@link castVote} and {@link removeVote}.
|
|
* The route acks the voter with `voteCount`, then broadcasts it to every
|
|
* client, or to the dump owner's alone when `isPrivate`.
|
|
*/
|
|
export interface VoteResult {
|
|
voteCount: number;
|
|
isPrivate: boolean;
|
|
}
|
|
|
|
/**
|
|
* Returns whether the dump is private, or throws a 404 when the user can't see
|
|
* it — voting must not confirm that a private dump exists.
|
|
*/
|
|
function visibleDump(dumpId: string, userId: string): { isPrivate: boolean } {
|
|
const row = db.prepare(
|
|
`SELECT is_private, user_id FROM dumps WHERE id = ?;`,
|
|
).get(dumpId) as { is_private: number; user_id: string } | undefined;
|
|
if (!row || (row.is_private && row.user_id !== userId)) {
|
|
throw new APIException(404, "Dump not found");
|
|
}
|
|
return { isPrivate: Boolean(row.is_private) };
|
|
}
|
|
|
|
/**
|
|
* Records `userId`'s upvote on a dump and notifies the dump's poster. The
|
|
* notification is best-effort: if it fails, the vote still counts.
|
|
*
|
|
* @throws {APIException} 404 when the dump doesn't exist or is someone else's
|
|
* private dump, 409 when the user already voted for it.
|
|
*/
|
|
export function castVote(dumpId: string, userId: string): VoteResult {
|
|
const { isPrivate } = visibleDump(dumpId, userId);
|
|
let voteCount: number;
|
|
try {
|
|
db.exec("BEGIN;");
|
|
db.prepare(
|
|
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
|
|
).run(dumpId, userId, new Date().toISOString());
|
|
db.prepare(
|
|
`UPDATE dumps SET vote_count = vote_count + 1 WHERE id = ?;`,
|
|
).run(dumpId);
|
|
const row = db.prepare(
|
|
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
|
).get(dumpId) as { vote_count: number } | undefined;
|
|
db.exec("COMMIT;");
|
|
voteCount = row?.vote_count ?? 0;
|
|
} catch (err) {
|
|
try {
|
|
db.exec("ROLLBACK;");
|
|
} catch { /* ignore if no active transaction */ }
|
|
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
|
|
throw new APIException(409, "Already voted");
|
|
}
|
|
throw err;
|
|
}
|
|
// The vote is committed: a failed notification must not report it as failed.
|
|
try {
|
|
notifyDumpOwnerUpvote(userId, dumpId);
|
|
} catch { /* ignore */ }
|
|
return { voteCount, isPrivate };
|
|
}
|
|
|
|
/**
|
|
* Takes back `userId`'s upvote on a dump. No visibility check: taking back a
|
|
* vote on a dump that has since gone private is harmless, and it's the voter's
|
|
* own row.
|
|
*
|
|
* @throws {APIException} 404 when the user hasn't voted for the dump.
|
|
*/
|
|
export function removeVote(dumpId: string, userId: string): VoteResult {
|
|
const row = db.prepare(`SELECT is_private FROM dumps WHERE id = ?;`).get(
|
|
dumpId,
|
|
) as { is_private: number } | undefined;
|
|
const isPrivate = Boolean(row?.is_private);
|
|
try {
|
|
db.exec("BEGIN;");
|
|
const result = db.prepare(
|
|
`DELETE FROM votes WHERE dump_id = ? AND user_id = ?;`,
|
|
).run(dumpId, userId);
|
|
if (result.changes === 0) {
|
|
db.exec("ROLLBACK;");
|
|
throw new APIException(404, "Vote not found");
|
|
}
|
|
db.prepare(
|
|
`UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`,
|
|
).run(dumpId);
|
|
const count = db.prepare(
|
|
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
|
).get(dumpId) as { vote_count: number } | undefined;
|
|
db.exec("COMMIT;");
|
|
return { voteCount: count?.vote_count ?? 0, isPrivate };
|
|
} catch (err) {
|
|
if (err instanceof APIException) throw err;
|
|
db.exec("ROLLBACK;");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the ids of the dumps `userId` upvoted. The WebSocket route sends them
|
|
* in its `welcome` message, and the client shows those vote buttons pressed.
|
|
*/
|
|
export function getUserVotes(userId: string): string[] {
|
|
const rows = db.prepare(
|
|
`SELECT dump_id FROM votes WHERE user_id = ?;`,
|
|
).all(userId) as { dump_id: string }[];
|
|
return rows.map((r) => r.dump_id);
|
|
}
|
|
|
|
/**
|
|
* Returns a page of the users who upvoted a dump, latest vote first, for
|
|
* `GET /api/dumps/:dumpId/voters`. It doesn't check that the caller may see the
|
|
* dump: the route does that first, with `getDump`.
|
|
*
|
|
* @throws {APIException} 500 when a row doesn't parse as a user.
|
|
*/
|
|
export function getDumpVoters(
|
|
dumpId: string,
|
|
page: number,
|
|
limit: number,
|
|
): Page<User> {
|
|
const offset = (page - 1) * limit;
|
|
|
|
const rawRows = db.prepare(
|
|
`SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at,
|
|
u.avatar_mime, u.description, u.invited_by, u.email,
|
|
i.username as invited_by_username
|
|
FROM users u
|
|
LEFT JOIN users i ON i.id = u.invited_by
|
|
INNER JOIN votes v ON v.dump_id = ? AND v.user_id = u.id
|
|
ORDER BY v.created_at DESC
|
|
LIMIT ? OFFSET ?;`,
|
|
).all(dumpId, limit + 1, offset);
|
|
|
|
if (!rawRows.every(isUserRow)) {
|
|
throw new APIException(500, "Malformed user data");
|
|
}
|
|
|
|
return pageOf(rawRows.map(userRowToApi), limit);
|
|
}
|