129 lines
4.2 KiB
TypeScript
129 lines
4.2 KiB
TypeScript
import { APIErrorCode, APIException, type User } from "../model/interfaces.ts";
|
|
import { db, isUserRow, userRowToApi } from "../model/db.ts";
|
|
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
|
|
|
export interface VoteResult {
|
|
voteCount: number;
|
|
/** Private dumps' vote counts go to their owner only. */
|
|
isPrivate: boolean;
|
|
}
|
|
|
|
// Visibility of the dump, or 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(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
|
}
|
|
return { isPrivate: Boolean(row.is_private) };
|
|
}
|
|
|
|
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(
|
|
APIErrorCode.VALIDATION_ERROR,
|
|
409,
|
|
"Already voted",
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
// Notification is best-effort — must not prevent vote_ack from being sent
|
|
try {
|
|
notifyDumpOwnerUpvote(userId, dumpId);
|
|
} catch { /* ignore */ }
|
|
return { voteCount, isPrivate };
|
|
}
|
|
|
|
// 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.
|
|
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(APIErrorCode.NOT_FOUND, 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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
export function getDumpVoters(
|
|
dumpId: string,
|
|
page: number,
|
|
limit: number,
|
|
): { items: User[]; total: number } {
|
|
const offset = (page - 1) * limit;
|
|
|
|
const totalRow = db.prepare(
|
|
`SELECT COUNT(*) as count FROM votes WHERE dump_id = ?;`,
|
|
).get(dumpId) as { count: number } | undefined;
|
|
|
|
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, offset);
|
|
|
|
if (!rawRows.every(isUserRow)) {
|
|
throw new APIException(
|
|
APIErrorCode.SERVER_ERROR,
|
|
500,
|
|
"Malformed user data",
|
|
);
|
|
}
|
|
|
|
return { items: rawRows.map(userRowToApi), total: totalRow?.count ?? 0 };
|
|
}
|