/** * 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 { 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); }