mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
1372 lines
43 KiB
TypeScript
1372 lines
43 KiB
TypeScript
import { getDb } from "./db";
|
|
import {
|
|
createHash,
|
|
randomBytes,
|
|
randomInt,
|
|
randomUUID,
|
|
scryptSync,
|
|
timingSafeEqual,
|
|
} from "node:crypto";
|
|
import { sendPasswordResetCode, sendVerificationCode } from "./email";
|
|
|
|
const DEAKIN_DOMAIN = "deakin.edu.au";
|
|
const CODE_TTL_MS = 10 * 60 * 1000;
|
|
const REQUEST_COOLDOWN_MS = 60 * 1000;
|
|
const MAX_ATTEMPTS = 5;
|
|
|
|
type Db = ReturnType<typeof getDb>;
|
|
|
|
function newId(): string {
|
|
return randomUUID();
|
|
}
|
|
|
|
function generateToken(): string {
|
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
let result = "";
|
|
for (let i = 0; i < 64; i++) {
|
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function generateCode(): string {
|
|
return String(randomInt(0, 1000000)).padStart(6, "0");
|
|
}
|
|
|
|
function hashCode(code: string): string {
|
|
return createHash("sha256").update(code).digest("hex");
|
|
}
|
|
|
|
function hashPassword(password: string): string {
|
|
const salt = randomBytes(16).toString("hex");
|
|
const hash = scryptSync(password, salt, 64).toString("hex");
|
|
return `${salt}:${hash}`;
|
|
}
|
|
|
|
function verifyPassword(password: string, stored: string): boolean {
|
|
const [salt, hash] = stored.split(":");
|
|
if (!salt || !hash) return false;
|
|
const candidate = scryptSync(password, salt, 64);
|
|
const expected = Buffer.from(hash, "hex");
|
|
if (candidate.length !== expected.length) return false;
|
|
return timingSafeEqual(candidate, expected);
|
|
}
|
|
|
|
function validatePassword(password: string): string {
|
|
if (!password || password.length < 8) {
|
|
throw new Error("Password must be at least 8 characters");
|
|
}
|
|
return password;
|
|
}
|
|
|
|
function normalizeEmail(email: string): string {
|
|
const normalized = email.trim().toLowerCase();
|
|
const emailDomain = normalized.split("@")[1]?.toLowerCase();
|
|
if (emailDomain !== DEAKIN_DOMAIN) {
|
|
throw new Error(`Only @${DEAKIN_DOMAIN} email addresses are allowed`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function requireAuth(db: Db, token: string): Record<string, any> {
|
|
const user = db
|
|
.prepare(
|
|
"SELECT id AS _id, email, name, avatarUrl, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?",
|
|
)
|
|
.get(token) as Record<string, any> | undefined;
|
|
if (!user) throw new Error("Not authenticated");
|
|
return user;
|
|
}
|
|
|
|
function requireAdmin(db: Db, token: string): Record<string, any> {
|
|
const user = requireAuth(db, token);
|
|
if (user.role !== "admin") throw new Error("Not authorized");
|
|
return user;
|
|
}
|
|
|
|
function mapQuestion(row: Record<string, any>): Record<string, any> {
|
|
return { ...row, solved: !!row.solved };
|
|
}
|
|
|
|
function unitRecordMatchesQuery(unit: Record<string, any> | null | undefined, q: string) {
|
|
if (!unit) return false;
|
|
return (
|
|
String(unit.code).toLowerCase().includes(q) ||
|
|
String(unit.code2 ?? "")
|
|
.toLowerCase()
|
|
.includes(q) ||
|
|
String(unit.name).toLowerCase().includes(q) ||
|
|
String(unit.description ?? "")
|
|
.toLowerCase()
|
|
.includes(q)
|
|
);
|
|
}
|
|
|
|
function addUnits(db: Db, rows: Record<string, any>[]): Record<string, any>[] {
|
|
const units = new Map<string, Record<string, any>>();
|
|
const getUnit = db.prepare(
|
|
"SELECT id AS _id, code, code2, name, description FROM units WHERE id = ?",
|
|
);
|
|
|
|
return rows.map((row) => {
|
|
if (!units.has(row.unitId)) {
|
|
const unit = getUnit.get(row.unitId) as Record<string, any> | undefined;
|
|
if (unit) units.set(row.unitId, unit);
|
|
}
|
|
return { ...row, unit: units.get(row.unitId) ?? null };
|
|
});
|
|
}
|
|
|
|
// ---- users ----
|
|
|
|
function issueSession(db: Db, user: { _id: string; name: string; role?: string }) {
|
|
const token = generateToken();
|
|
db.prepare("UPDATE users SET sessionToken = ? WHERE id = ?").run(token, user._id);
|
|
return { userId: user._id, token, name: user.name, role: user.role ?? "user" };
|
|
}
|
|
|
|
async function sendCode(db: Db, email: string, name: string, kind: "verification" | "reset") {
|
|
const existing = db
|
|
.prepare("SELECT email, createdAt FROM email_verifications WHERE email = ?")
|
|
.get(email) as { email: string; createdAt: number } | undefined;
|
|
|
|
if (existing && Date.now() - existing.createdAt < REQUEST_COOLDOWN_MS) {
|
|
throw new Error("A code was just sent. Please wait a minute before trying again.");
|
|
}
|
|
|
|
const code = generateCode();
|
|
const codeHash = hashCode(code);
|
|
const now = Date.now();
|
|
const expiresAt = now + CODE_TTL_MS;
|
|
|
|
if (existing) {
|
|
db.prepare(
|
|
"UPDATE email_verifications SET name = ?, codeHash = ?, expiresAt = ?, attempts = 0, createdAt = ? WHERE email = ?",
|
|
).run(name, codeHash, expiresAt, now, email);
|
|
} else {
|
|
db.prepare(
|
|
"INSERT INTO email_verifications (email, name, codeHash, expiresAt, attempts, createdAt) VALUES (?, ?, ?, ?, 0, ?)",
|
|
).run(email, name, codeHash, expiresAt, now);
|
|
}
|
|
|
|
try {
|
|
if (kind === "reset") {
|
|
await sendPasswordResetCode(email, code);
|
|
} else {
|
|
await sendVerificationCode(email, code);
|
|
}
|
|
} catch (err) {
|
|
db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email);
|
|
throw err;
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
function verifyAndConsumeCode(db: Db, email: string, code: string): { name: string } {
|
|
const pending = db
|
|
.prepare(
|
|
"SELECT name, codeHash, expiresAt, attempts FROM email_verifications WHERE email = ?",
|
|
)
|
|
.get(email) as
|
|
| { name: string; codeHash: string; expiresAt: number; attempts: number }
|
|
| undefined;
|
|
|
|
if (!pending) throw new Error("No verification code requested for this email");
|
|
if (Date.now() > pending.expiresAt) throw new Error("Verification code has expired");
|
|
if (pending.attempts >= MAX_ATTEMPTS) throw new Error("Too many attempts. Request a new code.");
|
|
|
|
if (hashCode(code) !== pending.codeHash) {
|
|
db.prepare("UPDATE email_verifications SET attempts = attempts + 1 WHERE email = ?").run(
|
|
email,
|
|
);
|
|
throw new Error("Invalid verification code");
|
|
}
|
|
|
|
db.prepare("DELETE FROM email_verifications WHERE email = ?").run(email);
|
|
return { name: pending.name };
|
|
}
|
|
|
|
async function authRequestCode(db: Db, args: { email: string; name: string }) {
|
|
const email = normalizeEmail(args.email);
|
|
if (!args.name || !args.name.trim()) {
|
|
throw new Error("Name is required");
|
|
}
|
|
return await sendCode(db, email, args.name.trim(), "verification");
|
|
}
|
|
|
|
function authSignup(db: Db, args: { email: string; code: string; password: string }) {
|
|
const email = normalizeEmail(args.email);
|
|
const password = validatePassword(args.password);
|
|
const pending = verifyAndConsumeCode(db, email, args.code);
|
|
|
|
const existing = db.prepare("SELECT id AS _id FROM users WHERE email = ?").get(email) as
|
|
| { _id: string }
|
|
| undefined;
|
|
if (existing) throw new Error("An account already exists for this email. Sign in instead.");
|
|
|
|
const id = newId();
|
|
db.prepare(
|
|
"INSERT INTO users (id, email, name, passwordHash, role, createdAt) VALUES (?, ?, ?, ?, 'user', ?)",
|
|
).run(id, email, pending.name, hashPassword(password), Date.now());
|
|
|
|
return issueSession(db, { _id: id, name: pending.name, role: "user" });
|
|
}
|
|
|
|
function authSignin(db: Db, args: { email: string; password: string }) {
|
|
const email = normalizeEmail(args.email);
|
|
const user = db
|
|
.prepare("SELECT id AS _id, name, role, passwordHash FROM users WHERE email = ?")
|
|
.get(email) as
|
|
| { _id: string; name: string; role: string; passwordHash: string | null }
|
|
| undefined;
|
|
|
|
if (!user) throw new Error("No account found for this email");
|
|
if (!user.passwordHash) {
|
|
throw new Error("This account has no password set. Use Forgot password to create one.");
|
|
}
|
|
if (!verifyPassword(args.password, user.passwordHash)) throw new Error("Incorrect password");
|
|
|
|
return issueSession(db, { _id: user._id, name: user.name, role: user.role });
|
|
}
|
|
|
|
async function authForgotPassword(db: Db, args: { email: string }) {
|
|
const email = normalizeEmail(args.email);
|
|
const user = db.prepare("SELECT id AS _id, name FROM users WHERE email = ?").get(email) as
|
|
| { _id: string; name: string }
|
|
| undefined;
|
|
if (!user) throw new Error("No account found for this email");
|
|
return await sendCode(db, email, user.name, "reset");
|
|
}
|
|
|
|
function authResetPassword(db: Db, args: { email: string; code: string; password: string }) {
|
|
const email = normalizeEmail(args.email);
|
|
const password = validatePassword(args.password);
|
|
verifyAndConsumeCode(db, email, args.code);
|
|
|
|
const user = db
|
|
.prepare("SELECT id AS _id, name, role FROM users WHERE email = ?")
|
|
.get(email) as { _id: string; name: string; role: string } | undefined;
|
|
if (!user) throw new Error("No account found for this email");
|
|
|
|
db.prepare("UPDATE users SET passwordHash = ? WHERE id = ?").run(
|
|
hashPassword(password),
|
|
user._id,
|
|
);
|
|
return issueSession(db, { _id: user._id, name: user.name, role: user.role });
|
|
}
|
|
|
|
function usersGetByToken(db: Db, args: { token: string }) {
|
|
return (
|
|
db
|
|
.prepare(
|
|
"SELECT id AS _id, email, name, avatarUrl, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?",
|
|
)
|
|
.get(args.token) ?? null
|
|
);
|
|
}
|
|
|
|
function usersUpdateProfile(
|
|
db: Db,
|
|
args: { token: string; name: string; avatarUrl?: string | null },
|
|
) {
|
|
const user = requireAuth(db, args.token);
|
|
const name = (args.name ?? "").trim();
|
|
if (name.length < 2 || name.length > 50) {
|
|
throw new Error("Display name must be between 2 and 50 characters");
|
|
}
|
|
|
|
const avatarUrl = args.avatarUrl?.trim() || null;
|
|
if (avatarUrl && !/^\/uploads\/[a-zA-Z0-9-]+\.(png|jpe?g|gif|webp)$/.test(avatarUrl)) {
|
|
throw new Error("Invalid profile picture");
|
|
}
|
|
|
|
db.exec("BEGIN");
|
|
try {
|
|
db.prepare("UPDATE users SET name = ?, avatarUrl = ? WHERE id = ?").run(
|
|
name,
|
|
avatarUrl,
|
|
user._id,
|
|
);
|
|
db.prepare("UPDATE notes SET authorName = ? WHERE authorId = ?").run(name, user._id);
|
|
db.prepare("UPDATE questions SET authorName = ? WHERE authorId = ?").run(name, user._id);
|
|
db.prepare("UPDATE comments SET authorName = ? WHERE authorId = ?").run(name, user._id);
|
|
db.exec("COMMIT");
|
|
} catch (err) {
|
|
db.exec("ROLLBACK");
|
|
throw err;
|
|
}
|
|
|
|
return usersGetByToken(db, { token: args.token });
|
|
}
|
|
|
|
function usersChangePassword(
|
|
db: Db,
|
|
args: { token: string; currentPassword: string; newPassword: string },
|
|
) {
|
|
const user = requireAuth(db, args.token);
|
|
const credentials = db.prepare("SELECT passwordHash FROM users WHERE id = ?").get(user._id) as
|
|
| { passwordHash: string | null }
|
|
| undefined;
|
|
if (
|
|
!credentials?.passwordHash ||
|
|
!verifyPassword(args.currentPassword ?? "", credentials.passwordHash)
|
|
) {
|
|
throw new Error("Current password is incorrect");
|
|
}
|
|
|
|
const newPassword = validatePassword(args.newPassword);
|
|
if (verifyPassword(newPassword, credentials.passwordHash)) {
|
|
throw new Error("New password must be different from your current password");
|
|
}
|
|
db.prepare("UPDATE users SET passwordHash = ? WHERE id = ?").run(
|
|
hashPassword(newPassword),
|
|
user._id,
|
|
);
|
|
return { ok: true };
|
|
}
|
|
|
|
function usersGetPublicProfile(db: Db, args: { id: string }) {
|
|
const profile = db
|
|
.prepare(
|
|
`SELECT
|
|
u.id AS _id,
|
|
u.name,
|
|
u.avatarUrl,
|
|
u.createdAt AS _creationTime,
|
|
(SELECT COUNT(*) FROM notes n WHERE n.authorId = u.id) AS noteCount,
|
|
(SELECT COUNT(*) FROM questions q WHERE q.authorId = u.id) AS questionCount,
|
|
(SELECT COUNT(*) FROM comments c WHERE c.authorId = u.id) AS commentCount
|
|
FROM users u
|
|
WHERE u.id = ?`,
|
|
)
|
|
.get(args.id) as Record<string, any> | undefined;
|
|
if (!profile) return null;
|
|
|
|
const posts = db
|
|
.prepare(
|
|
`SELECT id AS _id, 'note' AS type, title, createdAt, voteCount
|
|
FROM notes WHERE authorId = ?
|
|
UNION ALL
|
|
SELECT id AS _id, 'question' AS type, title, createdAt, voteCount
|
|
FROM questions WHERE authorId = ?
|
|
ORDER BY createdAt DESC
|
|
LIMIT 50`,
|
|
)
|
|
.all(args.id, args.id);
|
|
const comments = db
|
|
.prepare(
|
|
`SELECT
|
|
c.id AS _id,
|
|
c.content,
|
|
c.createdAt,
|
|
CASE WHEN c.parentId IS NOT NULL THEN 'note' ELSE 'question' END AS targetType,
|
|
COALESCE(c.parentId, c.questionId) AS targetId,
|
|
COALESCE(n.title, q.title) AS targetTitle
|
|
FROM comments c
|
|
LEFT JOIN notes n ON n.id = c.parentId
|
|
LEFT JOIN questions q ON q.id = c.questionId
|
|
WHERE c.authorId = ?
|
|
ORDER BY c.createdAt DESC
|
|
LIMIT 50`,
|
|
)
|
|
.all(args.id);
|
|
|
|
return {
|
|
...profile,
|
|
totalContributions: profile.noteCount + profile.questionCount + profile.commentCount,
|
|
posts,
|
|
comments,
|
|
};
|
|
}
|
|
|
|
// ---- topics ----
|
|
|
|
function topicsGetBySlug(db: Db, args: { slug: string }) {
|
|
return (
|
|
db
|
|
.prepare("SELECT id AS _id, name, slug, description FROM topics WHERE slug = ?")
|
|
.get(args.slug) ?? null
|
|
);
|
|
}
|
|
|
|
function topicsGetAll(db: Db) {
|
|
return db
|
|
.prepare("SELECT id AS _id, name, slug, description FROM topics ORDER BY name ASC")
|
|
.all();
|
|
}
|
|
|
|
// ---- units ----
|
|
|
|
function unitsGetByCode(db: Db, args: { code: string }) {
|
|
const code = args.code.toUpperCase();
|
|
return (
|
|
db
|
|
.prepare(
|
|
"SELECT id AS _id, code, code2, name, description FROM units WHERE code = ? OR code2 = ?",
|
|
)
|
|
.get(code, code) ?? null
|
|
);
|
|
}
|
|
|
|
function unitsGetAll(db: Db) {
|
|
return db
|
|
.prepare("SELECT id AS _id, code, code2, name, description FROM units ORDER BY code ASC")
|
|
.all();
|
|
}
|
|
|
|
function unitsCreateCustom(db: Db, args: { code: string; name: string }) {
|
|
const code = args.code.toUpperCase();
|
|
const existing = db
|
|
.prepare("SELECT id AS _id FROM units WHERE code = ? OR code2 = ?")
|
|
.get(code, code) as { _id: string } | undefined;
|
|
if (existing) return existing._id;
|
|
|
|
const id = newId();
|
|
db.prepare("INSERT INTO units (id, code, name) VALUES (?, ?, ?)").run(id, code, args.name);
|
|
return id;
|
|
}
|
|
|
|
// ---- pinned units ----
|
|
|
|
const MAX_PINNED_UNITS = 10;
|
|
|
|
function getPinnedUnitIds(db: Db, userId: string): string[] {
|
|
return (
|
|
db
|
|
.prepare("SELECT unitId FROM pinned_units WHERE userId = ? ORDER BY createdAt ASC")
|
|
.all(userId) as { unitId: string }[]
|
|
).map((row) => row.unitId);
|
|
}
|
|
|
|
function unitsGetPinned(db: Db, args: { token: string }) {
|
|
const user = requireAuth(db, args.token);
|
|
return getPinnedUnitIds(db, user._id);
|
|
}
|
|
|
|
function unitsPin(db: Db, args: { token: string; unitId: string }) {
|
|
const user = requireAuth(db, args.token);
|
|
const unit = db.prepare("SELECT id AS _id FROM units WHERE id = ?").get(args.unitId);
|
|
if (!unit) throw new Error("Unit not found");
|
|
|
|
const existing = db
|
|
.prepare("SELECT 1 AS found FROM pinned_units WHERE userId = ? AND unitId = ?")
|
|
.get(user._id, args.unitId);
|
|
if (!existing) {
|
|
const count = db
|
|
.prepare("SELECT COUNT(*) AS c FROM pinned_units WHERE userId = ?")
|
|
.get(user._id) as { c: number };
|
|
if (count.c >= MAX_PINNED_UNITS) {
|
|
throw new Error(`You can pin up to ${MAX_PINNED_UNITS} units`);
|
|
}
|
|
db.prepare("INSERT INTO pinned_units (userId, unitId, createdAt) VALUES (?, ?, ?)").run(
|
|
user._id,
|
|
args.unitId,
|
|
Date.now(),
|
|
);
|
|
}
|
|
return getPinnedUnitIds(db, user._id);
|
|
}
|
|
|
|
function unitsUnpin(db: Db, args: { token: string; unitId: string }) {
|
|
const user = requireAuth(db, args.token);
|
|
db.prepare("DELETE FROM pinned_units WHERE userId = ? AND unitId = ?").run(
|
|
user._id,
|
|
args.unitId,
|
|
);
|
|
return getPinnedUnitIds(db, user._id);
|
|
}
|
|
|
|
// ---- notes ----
|
|
|
|
const NOTE_COLUMNS =
|
|
"id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount";
|
|
|
|
function notesCreate(
|
|
db: Db,
|
|
args: { token: string; title: string; content: string; topicId?: string; unitId?: string },
|
|
) {
|
|
const user = requireAuth(db, args.token);
|
|
const topicId = args.topicId?.trim() ?? "";
|
|
const unitId = args.unitId?.trim() ?? "";
|
|
if (!unitId) throw new Error("Select a unit");
|
|
const id = newId();
|
|
const now = Date.now();
|
|
db.exec("BEGIN");
|
|
try {
|
|
db.prepare(
|
|
`INSERT INTO notes (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, commentCount)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0)`,
|
|
).run(id, args.title, args.content, topicId, unitId, user._id, user.name, now, now);
|
|
db.prepare(
|
|
"INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, 'note', ?, 1)",
|
|
).run(newId(), user._id, id);
|
|
db.exec("COMMIT");
|
|
} catch (err) {
|
|
db.exec("ROLLBACK");
|
|
throw err;
|
|
}
|
|
return id;
|
|
}
|
|
|
|
function notesList(db: Db, args: { topicId?: string; unitId?: string; limit?: number }) {
|
|
const limit = args.limit ?? 50;
|
|
if (args.topicId) {
|
|
return addUnits(
|
|
db,
|
|
db
|
|
.prepare(
|
|
`SELECT ${NOTE_COLUMNS} FROM notes WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`,
|
|
)
|
|
.all(args.topicId, limit) as Record<string, any>[],
|
|
);
|
|
}
|
|
if (args.unitId) {
|
|
return addUnits(
|
|
db,
|
|
db
|
|
.prepare(
|
|
`SELECT ${NOTE_COLUMNS} FROM notes WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`,
|
|
)
|
|
.all(args.unitId, limit) as Record<string, any>[],
|
|
);
|
|
}
|
|
return addUnits(
|
|
db,
|
|
db
|
|
.prepare(`SELECT ${NOTE_COLUMNS} FROM notes ORDER BY createdAt DESC LIMIT ?`)
|
|
.all(limit) as Record<string, any>[],
|
|
);
|
|
}
|
|
|
|
function notesSearch(db: Db, args: { query: string; limit?: number }) {
|
|
const all = db
|
|
.prepare(`SELECT ${NOTE_COLUMNS} FROM notes ORDER BY createdAt DESC LIMIT ?`)
|
|
.all(args.limit ?? 200) as Record<string, any>[];
|
|
const q = args.query.toLowerCase();
|
|
return addUnits(db, all).filter(
|
|
(n) =>
|
|
String(n.title).toLowerCase().includes(q) ||
|
|
String(n.content).toLowerCase().includes(q) ||
|
|
unitRecordMatchesQuery(n.unit, q),
|
|
);
|
|
}
|
|
|
|
function notesGetById(db: Db, args: { id: string }) {
|
|
return db.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE id = ?`).get(args.id) ?? null;
|
|
}
|
|
|
|
function notesRemove(db: Db, args: { token: string; id: string }) {
|
|
const user = requireAuth(db, args.token);
|
|
const note = db.prepare("SELECT id, authorId FROM notes WHERE id = ?").get(args.id) as
|
|
| { id: string; authorId: string }
|
|
| undefined;
|
|
if (!note || note.authorId !== user._id) throw new Error("Not authorized");
|
|
db.prepare("DELETE FROM notes WHERE id = ?").run(args.id);
|
|
}
|
|
|
|
function notesUpdate(db: Db, args: { token: string; id: string; title: string; content: string }) {
|
|
const user = requireAuth(db, args.token);
|
|
const note = db.prepare("SELECT id, authorId FROM notes WHERE id = ?").get(args.id) as
|
|
| { id: string; authorId: string }
|
|
| undefined;
|
|
if (!note || note.authorId !== user._id) throw new Error("Not authorized");
|
|
|
|
const title = (args.title ?? "").trim();
|
|
const content = (args.content ?? "").trim();
|
|
if (!title || !content) throw new Error("Title and content are required");
|
|
|
|
const updatedAt = Date.now();
|
|
db.prepare("UPDATE notes SET title = ?, content = ?, updatedAt = ? WHERE id = ?").run(
|
|
title,
|
|
content,
|
|
updatedAt,
|
|
args.id,
|
|
);
|
|
return { updatedAt };
|
|
}
|
|
|
|
// ---- questions ----
|
|
|
|
const QUESTION_COLUMNS =
|
|
"id AS _id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved";
|
|
|
|
function questionsCreate(
|
|
db: Db,
|
|
args: { token: string; title: string; content: string; topicId?: string; unitId?: string },
|
|
) {
|
|
const user = requireAuth(db, args.token);
|
|
const topicId = args.topicId?.trim() ?? "";
|
|
const unitId = args.unitId?.trim() ?? "";
|
|
if (!unitId) throw new Error("Select a unit");
|
|
const id = newId();
|
|
const now = Date.now();
|
|
db.exec("BEGIN");
|
|
try {
|
|
db.prepare(
|
|
`INSERT INTO questions (id, title, content, topicId, unitId, authorId, authorName, createdAt, updatedAt, voteCount, answerCount, solved)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0)`,
|
|
).run(id, args.title, args.content, topicId, unitId, user._id, user.name, now, now);
|
|
db.prepare(
|
|
"INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, 'question', ?, 1)",
|
|
).run(newId(), user._id, id);
|
|
db.exec("COMMIT");
|
|
} catch (err) {
|
|
db.exec("ROLLBACK");
|
|
throw err;
|
|
}
|
|
return id;
|
|
}
|
|
|
|
function questionsList(db: Db, args: { topicId?: string; unitId?: string; limit?: number }) {
|
|
const limit = args.limit ?? 50;
|
|
const rows = (
|
|
args.topicId
|
|
? db
|
|
.prepare(
|
|
`SELECT ${QUESTION_COLUMNS} FROM questions WHERE topicId = ? ORDER BY createdAt DESC LIMIT ?`,
|
|
)
|
|
.all(args.topicId, limit)
|
|
: args.unitId
|
|
? db
|
|
.prepare(
|
|
`SELECT ${QUESTION_COLUMNS} FROM questions WHERE unitId = ? ORDER BY createdAt DESC LIMIT ?`,
|
|
)
|
|
.all(args.unitId, limit)
|
|
: db
|
|
.prepare(
|
|
`SELECT ${QUESTION_COLUMNS} FROM questions ORDER BY createdAt DESC LIMIT ?`,
|
|
)
|
|
.all(limit)
|
|
) as Record<string, any>[];
|
|
return addUnits(db, rows.map(mapQuestion));
|
|
}
|
|
|
|
function questionsGetById(db: Db, args: { id: string }) {
|
|
const row = db
|
|
.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`)
|
|
.get(args.id) as Record<string, any> | undefined;
|
|
return row ? mapQuestion(row) : null;
|
|
}
|
|
|
|
function questionsSearch(db: Db, args: { query: string; limit?: number }) {
|
|
const all = db
|
|
.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions ORDER BY createdAt DESC LIMIT ?`)
|
|
.all(args.limit ?? 200) as Record<string, any>[];
|
|
const q = args.query.toLowerCase();
|
|
return addUnits(db, all.map(mapQuestion)).filter(
|
|
(qr) =>
|
|
String(qr.title).toLowerCase().includes(q) ||
|
|
String(qr.content).toLowerCase().includes(q) ||
|
|
unitRecordMatchesQuery(qr.unit, q),
|
|
);
|
|
}
|
|
|
|
function unitsSearch(db: Db, args: { query: string; limit?: number }) {
|
|
const q = args.query.toLowerCase();
|
|
const all = unitsGetAll(db) as Record<string, any>[];
|
|
return all.filter((unit) => unitRecordMatchesQuery(unit, q)).slice(0, args.limit ?? 200);
|
|
}
|
|
|
|
function searchAll(db: Db, args: { query: string; limit?: number }) {
|
|
const limit = args.limit ?? 200;
|
|
const units = unitsSearch(db, { query: args.query, limit }) as Record<string, any>[];
|
|
const notes = notesSearch(db, { query: args.query, limit }) as Record<string, any>[];
|
|
const questions = questionsSearch(db, { query: args.query, limit }) as Record<string, any>[];
|
|
const posts: Record<string, any>[] = [
|
|
...notes.map((n) => ({ ...n, type: "note" })),
|
|
...questions.map((q) => ({ ...q, type: "question" })),
|
|
];
|
|
posts.sort((a, b) => b.createdAt - a.createdAt);
|
|
return [...units.map((unit) => ({ ...unit, type: "unit" })), ...posts];
|
|
}
|
|
|
|
function questionsMarkSolved(db: Db, args: { token: string; id: string }) {
|
|
const user = requireAuth(db, args.token);
|
|
const question = db.prepare("SELECT id, authorId FROM questions WHERE id = ?").get(args.id) as
|
|
| { id: string; authorId: string }
|
|
| undefined;
|
|
if (!question || question.authorId !== user._id) throw new Error("Not authorized");
|
|
db.prepare("UPDATE questions SET solved = 1 WHERE id = ?").run(args.id);
|
|
}
|
|
|
|
function questionsRemove(db: Db, args: { token: string; id: string }) {
|
|
const user = requireAuth(db, args.token);
|
|
const question = db.prepare("SELECT id, authorId FROM questions WHERE id = ?").get(args.id) as
|
|
| { id: string; authorId: string }
|
|
| undefined;
|
|
if (!question || question.authorId !== user._id) throw new Error("Not authorized");
|
|
db.prepare("DELETE FROM questions WHERE id = ?").run(args.id);
|
|
}
|
|
|
|
function questionsUpdate(
|
|
db: Db,
|
|
args: { token: string; id: string; title: string; content: string },
|
|
) {
|
|
const user = requireAuth(db, args.token);
|
|
const question = db.prepare("SELECT id, authorId FROM questions WHERE id = ?").get(args.id) as
|
|
| { id: string; authorId: string }
|
|
| undefined;
|
|
if (!question || question.authorId !== user._id) throw new Error("Not authorized");
|
|
|
|
const title = (args.title ?? "").trim();
|
|
const content = (args.content ?? "").trim();
|
|
if (!title || !content) throw new Error("Title and content are required");
|
|
|
|
const updatedAt = Date.now();
|
|
db.prepare("UPDATE questions SET title = ?, content = ?, updatedAt = ? WHERE id = ?").run(
|
|
title,
|
|
content,
|
|
updatedAt,
|
|
args.id,
|
|
);
|
|
return { updatedAt };
|
|
}
|
|
|
|
// ---- comments ----
|
|
|
|
const COMMENT_COLUMNS =
|
|
"c.id AS _id, c.content, c.authorId, COALESCE(u.name, c.authorName) AS authorName, u.avatarUrl, c.parentId, c.questionId, c.parentCommentId, c.createdAt, c.updatedAt";
|
|
|
|
function commentsCreateOnNote(
|
|
db: Db,
|
|
args: { token: string; content: string; parentId: string; parentCommentId?: string },
|
|
) {
|
|
const user = requireAuth(db, args.token);
|
|
const id = newId();
|
|
const now = Date.now();
|
|
db.prepare(
|
|
`INSERT INTO comments (id, content, authorId, authorName, parentId, parentCommentId, createdAt, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
id,
|
|
args.content,
|
|
user._id,
|
|
user.name,
|
|
args.parentId,
|
|
args.parentCommentId ?? null,
|
|
now,
|
|
now,
|
|
);
|
|
db.prepare("UPDATE notes SET commentCount = commentCount + 1 WHERE id = ?").run(args.parentId);
|
|
return id;
|
|
}
|
|
|
|
function commentsCreateOnQuestion(
|
|
db: Db,
|
|
args: { token: string; content: string; questionId: string; parentCommentId?: string },
|
|
) {
|
|
const user = requireAuth(db, args.token);
|
|
const id = newId();
|
|
const now = Date.now();
|
|
db.prepare(
|
|
`INSERT INTO comments (id, content, authorId, authorName, questionId, parentCommentId, createdAt, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
id,
|
|
args.content,
|
|
user._id,
|
|
user.name,
|
|
args.questionId,
|
|
args.parentCommentId ?? null,
|
|
now,
|
|
now,
|
|
);
|
|
db.prepare("UPDATE questions SET answerCount = answerCount + 1 WHERE id = ?").run(
|
|
args.questionId,
|
|
);
|
|
return id;
|
|
}
|
|
|
|
function commentsListByNote(db: Db, args: { noteId: string }) {
|
|
return db
|
|
.prepare(
|
|
`SELECT ${COMMENT_COLUMNS} FROM comments c LEFT JOIN users u ON u.id = c.authorId WHERE c.parentId = ? ORDER BY c.createdAt ASC`,
|
|
)
|
|
.all(args.noteId);
|
|
}
|
|
|
|
function commentsListByQuestion(db: Db, args: { questionId: string }) {
|
|
return db
|
|
.prepare(
|
|
`SELECT ${COMMENT_COLUMNS} FROM comments c LEFT JOIN users u ON u.id = c.authorId WHERE c.questionId = ? ORDER BY c.createdAt ASC`,
|
|
)
|
|
.all(args.questionId);
|
|
}
|
|
|
|
function commentsRemove(db: Db, args: { token: string; id: string }) {
|
|
const user = requireAuth(db, args.token);
|
|
const comment = db.prepare("SELECT id, authorId FROM comments WHERE id = ?").get(args.id) as
|
|
| { id: string; authorId: string }
|
|
| undefined;
|
|
if (!comment || comment.authorId !== user._id) throw new Error("Not authorized");
|
|
|
|
const ids = [args.id];
|
|
const stack = [args.id];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop()!;
|
|
const children = db
|
|
.prepare("SELECT id FROM comments WHERE parentCommentId = ?")
|
|
.all(current) as { id: string }[];
|
|
for (const child of children) {
|
|
ids.push(child.id);
|
|
stack.push(child.id);
|
|
}
|
|
}
|
|
|
|
db.exec("BEGIN");
|
|
try {
|
|
for (const id of ids) {
|
|
db.prepare("DELETE FROM comments WHERE id = ?").run(id);
|
|
}
|
|
db.exec("COMMIT");
|
|
} catch (err) {
|
|
db.exec("ROLLBACK");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function commentsUpdate(db: Db, args: { token: string; id: string; content: string }) {
|
|
const user = requireAuth(db, args.token);
|
|
const comment = db.prepare("SELECT id, authorId FROM comments WHERE id = ?").get(args.id) as
|
|
| { id: string; authorId: string }
|
|
| undefined;
|
|
if (!comment || comment.authorId !== user._id) throw new Error("Not authorized");
|
|
|
|
const content = (args.content ?? "").trim();
|
|
if (!content) throw new Error("Comment cannot be empty");
|
|
|
|
const updatedAt = Date.now();
|
|
db.prepare("UPDATE comments SET content = ?, updatedAt = ? WHERE id = ?").run(
|
|
content,
|
|
updatedAt,
|
|
args.id,
|
|
);
|
|
return { updatedAt };
|
|
}
|
|
|
|
// ---- votes ----
|
|
|
|
function votesCast(
|
|
db: Db,
|
|
args: { token: string; targetType: "note" | "question"; targetId: string; value: 1 | -1 },
|
|
) {
|
|
const user = requireAuth(db, args.token);
|
|
const isNote = args.targetType === "note";
|
|
const table = isNote ? "notes" : "questions";
|
|
const doc = db.prepare(`SELECT id, voteCount FROM ${table} WHERE id = ?`).get(args.targetId) as
|
|
| { id: string; voteCount: number }
|
|
| undefined;
|
|
if (!doc) throw new Error("Not found");
|
|
|
|
const existing = db
|
|
.prepare("SELECT id, value FROM votes WHERE userId = ? AND targetType = ? AND targetId = ?")
|
|
.get(user._id, args.targetType, args.targetId) as { id: string; value: number } | undefined;
|
|
|
|
let voteCount = doc.voteCount;
|
|
let userVote: number = args.value;
|
|
|
|
db.exec("BEGIN");
|
|
try {
|
|
if (existing) {
|
|
if (existing.value === args.value) {
|
|
db.prepare("DELETE FROM votes WHERE id = ?").run(existing.id);
|
|
voteCount -= args.value;
|
|
userVote = 0;
|
|
} else {
|
|
db.prepare("UPDATE votes SET value = ? WHERE id = ?").run(args.value, existing.id);
|
|
voteCount = voteCount - existing.value + args.value;
|
|
}
|
|
} else {
|
|
db.prepare(
|
|
"INSERT INTO votes (id, userId, targetType, targetId, value) VALUES (?, ?, ?, ?, ?)",
|
|
).run(newId(), user._id, args.targetType, args.targetId, args.value);
|
|
voteCount += args.value;
|
|
}
|
|
|
|
db.prepare(`UPDATE ${table} SET voteCount = ? WHERE id = ?`).run(voteCount, args.targetId);
|
|
db.exec("COMMIT");
|
|
} catch (err) {
|
|
db.exec("ROLLBACK");
|
|
throw err;
|
|
}
|
|
|
|
return { voteCount, userVote };
|
|
}
|
|
|
|
function votesGetMyVote(
|
|
db: Db,
|
|
args: { token: string; targetType: "note" | "question"; targetId: string },
|
|
) {
|
|
const user = requireAuth(db, args.token);
|
|
const row = db
|
|
.prepare("SELECT value FROM votes WHERE userId = ? AND targetType = ? AND targetId = ?")
|
|
.get(user._id, args.targetType, args.targetId) as { value: number } | undefined;
|
|
return row?.value ?? 0;
|
|
}
|
|
|
|
// ---- details ----
|
|
|
|
const MAX_POST_TITLES = 50;
|
|
|
|
function postsGetTitles(db: Db, args: { ids?: string[] }) {
|
|
const ids = [
|
|
...new Set(
|
|
(Array.isArray(args.ids) ? args.ids : [])
|
|
.filter((id): id is string => typeof id === "string")
|
|
.map((id) => id.trim())
|
|
.filter(Boolean),
|
|
),
|
|
].slice(0, MAX_POST_TITLES);
|
|
|
|
if (ids.length === 0) return {};
|
|
|
|
const placeholders = ids.map(() => "?").join(",");
|
|
const titles: Record<string, string> = {};
|
|
|
|
for (const row of db
|
|
.prepare(`SELECT id, title FROM notes WHERE id IN (${placeholders})`)
|
|
.all(...ids) as { id: string; title: string }[]) {
|
|
titles[row.id] = row.title;
|
|
}
|
|
|
|
for (const row of db
|
|
.prepare(`SELECT id, title FROM questions WHERE id IN (${placeholders})`)
|
|
.all(...ids) as { id: string; title: string }[]) {
|
|
if (titles[row.id] === undefined) titles[row.id] = row.title;
|
|
}
|
|
|
|
return titles;
|
|
}
|
|
|
|
function getNoteWithDetails(db: Db, args: { id: string }) {
|
|
const note = db.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE id = ?`).get(args.id) as
|
|
| Record<string, any>
|
|
| undefined;
|
|
if (!note) return null;
|
|
|
|
const topic = db
|
|
.prepare("SELECT id AS _id, name, slug, description FROM topics WHERE id = ?")
|
|
.get(note.topicId);
|
|
const unit = db
|
|
.prepare("SELECT id AS _id, code, code2, name, description FROM units WHERE id = ?")
|
|
.get(note.unitId);
|
|
const comments = commentsListByNote(db, { noteId: note._id });
|
|
|
|
return { ...note, topic, unit, comments };
|
|
}
|
|
|
|
function getQuestionWithDetails(db: Db, args: { id: string }) {
|
|
const question = db
|
|
.prepare(`SELECT ${QUESTION_COLUMNS} FROM questions WHERE id = ?`)
|
|
.get(args.id) as Record<string, any> | undefined;
|
|
if (!question) return null;
|
|
|
|
const topic = db
|
|
.prepare("SELECT id AS _id, name, slug, description FROM topics WHERE id = ?")
|
|
.get(question.topicId);
|
|
const unit = db
|
|
.prepare("SELECT id AS _id, code, code2, name, description FROM units WHERE id = ?")
|
|
.get(question.unitId);
|
|
const answers = commentsListByQuestion(db, { questionId: question._id });
|
|
|
|
return { ...mapQuestion(question), topic, unit, answers };
|
|
}
|
|
|
|
// ---- admin ----
|
|
|
|
function adminGetState(db: Db, args: { token?: string }) {
|
|
const admin = db.prepare("SELECT id AS _id FROM users WHERE role = 'admin' LIMIT 1").get();
|
|
let currentUser: Record<string, any> | null = null;
|
|
let isAdmin = false;
|
|
|
|
if (args.token) {
|
|
const user = db
|
|
.prepare(
|
|
"SELECT id AS _id, email, name, avatarUrl, sessionToken, role, createdAt AS _creationTime FROM users WHERE sessionToken = ?",
|
|
)
|
|
.get(args.token) as Record<string, any> | undefined;
|
|
if (user) {
|
|
currentUser = user;
|
|
isAdmin = user.role === "admin";
|
|
}
|
|
}
|
|
|
|
return { hasAdmin: !!admin, currentUser, isAdmin };
|
|
}
|
|
|
|
function ensureNoAdmin(db: Db) {
|
|
const admin = db.prepare("SELECT id AS _id FROM users WHERE role = 'admin' LIMIT 1").get();
|
|
if (admin) throw new Error("An admin already exists");
|
|
}
|
|
|
|
async function adminRequestCode(db: Db, args: { email: string; name?: string }) {
|
|
ensureNoAdmin(db);
|
|
const email = normalizeEmail(args.email);
|
|
const existing = db.prepare("SELECT name FROM users WHERE email = ?").get(email) as
|
|
| { name: string }
|
|
| undefined;
|
|
const name = (existing?.name ?? args.name ?? "").trim();
|
|
if (!name) throw new Error("Name is required");
|
|
return await sendCode(db, email, name, "verification");
|
|
}
|
|
|
|
function adminCompleteSetup(db: Db, args: { email: string; code: string }) {
|
|
ensureNoAdmin(db);
|
|
const email = normalizeEmail(args.email);
|
|
const pending = verifyAndConsumeCode(db, email, args.code);
|
|
|
|
const existing = db.prepare("SELECT id AS _id, name FROM users WHERE email = ?").get(email) as
|
|
| { _id: string; name: string }
|
|
| undefined;
|
|
|
|
if (existing) {
|
|
db.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(existing._id);
|
|
return issueSession(db, { _id: existing._id, name: existing.name, role: "admin" });
|
|
}
|
|
|
|
const id = newId();
|
|
db.prepare(
|
|
"INSERT INTO users (id, email, name, role, createdAt) VALUES (?, ?, ?, 'admin', ?)",
|
|
).run(id, email, pending.name, Date.now());
|
|
return issueSession(db, { _id: id, name: pending.name, role: "admin" });
|
|
}
|
|
|
|
function adminUnitsSave(
|
|
db: Db,
|
|
args: {
|
|
token: string;
|
|
id?: string;
|
|
code: string;
|
|
code2?: string;
|
|
name: string;
|
|
description?: string;
|
|
},
|
|
) {
|
|
requireAdmin(db, args.token);
|
|
const code = (args.code ?? "").trim().toUpperCase();
|
|
const name = (args.name ?? "").trim();
|
|
if (!code || !name) throw new Error("Code and name are required");
|
|
|
|
const code2 = (args.code2 ?? "").trim().toUpperCase() || null;
|
|
if (code2 && code2 === code)
|
|
throw new Error("The second code must differ from the primary code");
|
|
|
|
const duplicate = db
|
|
.prepare("SELECT id AS _id FROM units WHERE (code = ? OR code2 = ?) AND id != ?")
|
|
.get(code, code, args.id ?? "") as { _id: string } | undefined;
|
|
if (duplicate) throw new Error("A unit with this code already exists");
|
|
|
|
if (code2) {
|
|
const duplicate2 = db
|
|
.prepare("SELECT id AS _id FROM units WHERE (code = ? OR code2 = ?) AND id != ?")
|
|
.get(code2, code2, args.id ?? "") as { _id: string } | undefined;
|
|
if (duplicate2) throw new Error("A unit with this second code already exists");
|
|
}
|
|
|
|
const description = args.description?.trim() || null;
|
|
if (args.id) {
|
|
const existing = db.prepare("SELECT id AS _id FROM units WHERE id = ?").get(args.id);
|
|
if (!existing) throw new Error("Unit not found");
|
|
db.prepare(
|
|
"UPDATE units SET code = ?, code2 = ?, name = ?, description = ? WHERE id = ?",
|
|
).run(code, code2, name, description, args.id);
|
|
return args.id;
|
|
}
|
|
|
|
const id = newId();
|
|
db.prepare("INSERT INTO units (id, code, code2, name, description) VALUES (?, ?, ?, ?, ?)").run(
|
|
id,
|
|
code,
|
|
code2,
|
|
name,
|
|
description,
|
|
);
|
|
return id;
|
|
}
|
|
|
|
function adminUnitsDelete(db: Db, args: { token: string; id: string }) {
|
|
requireAdmin(db, args.token);
|
|
const refs = db
|
|
.prepare(
|
|
"SELECT (SELECT COUNT(*) FROM notes WHERE unitId = ?) + (SELECT COUNT(*) FROM questions WHERE unitId = ?) AS c",
|
|
)
|
|
.get(args.id, args.id) as { c: number };
|
|
if (refs.c > 0) throw new Error("Cannot delete a unit that has notes or questions");
|
|
db.prepare("DELETE FROM units WHERE id = ?").run(args.id);
|
|
}
|
|
|
|
function adminUsersList(db: Db, args: { token: string }) {
|
|
requireAdmin(db, args.token);
|
|
return db
|
|
.prepare(
|
|
`SELECT
|
|
u.id AS _id,
|
|
u.email,
|
|
u.name,
|
|
u.role,
|
|
u.createdAt AS _creationTime,
|
|
(SELECT COUNT(*) FROM notes n WHERE n.authorId = u.id) AS noteCount,
|
|
(SELECT COUNT(*) FROM questions q WHERE q.authorId = u.id) AS questionCount
|
|
FROM users u
|
|
ORDER BY u.createdAt ASC`,
|
|
)
|
|
.all();
|
|
}
|
|
|
|
function adminUsersUpdate(
|
|
db: Db,
|
|
args: { token: string; id: string; email: string; name: string; role: string },
|
|
) {
|
|
requireAdmin(db, args.token);
|
|
const user = db.prepare("SELECT id AS _id, role FROM users WHERE id = ?").get(args.id) as
|
|
| { _id: string; role: string }
|
|
| undefined;
|
|
if (!user) throw new Error("User not found");
|
|
|
|
const email = normalizeEmail(args.email);
|
|
const name = (args.name ?? "").trim();
|
|
if (!name) throw new Error("Name is required");
|
|
|
|
const duplicate = db
|
|
.prepare("SELECT id AS _id FROM users WHERE email = ? AND id != ?")
|
|
.get(email, args.id) as { _id: string } | undefined;
|
|
if (duplicate) throw new Error("Email is already in use");
|
|
|
|
const role = args.role === "admin" ? "admin" : "user";
|
|
if (user.role === "admin" && role !== "admin") {
|
|
const adminCount = db
|
|
.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'")
|
|
.get() as { c: number };
|
|
if (adminCount.c <= 1) throw new Error("Cannot demote the last admin");
|
|
}
|
|
|
|
db.prepare("UPDATE users SET email = ?, name = ?, role = ? WHERE id = ?").run(
|
|
email,
|
|
name,
|
|
role,
|
|
args.id,
|
|
);
|
|
}
|
|
|
|
function adminUsersDelete(db: Db, args: { token: string; id: string }) {
|
|
const admin = requireAdmin(db, args.token);
|
|
const user = db
|
|
.prepare("SELECT id AS _id, role, email FROM users WHERE id = ?")
|
|
.get(args.id) as { _id: string; role: string; email: string } | undefined;
|
|
if (!user) throw new Error("User not found");
|
|
|
|
if (user._id === admin._id) throw new Error("You cannot delete your own account");
|
|
if (user.role === "admin") {
|
|
const adminCount = db
|
|
.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'")
|
|
.get() as { c: number };
|
|
if (adminCount.c <= 1) throw new Error("Cannot delete the last admin");
|
|
}
|
|
|
|
const noteIds = (
|
|
db.prepare("SELECT id FROM notes WHERE authorId = ?").all(args.id) as { id: string }[]
|
|
).map((r) => r.id);
|
|
const questionIds = (
|
|
db.prepare("SELECT id FROM questions WHERE authorId = ?").all(args.id) as {
|
|
id: string;
|
|
}[]
|
|
).map((r) => r.id);
|
|
|
|
db.exec("BEGIN");
|
|
try {
|
|
db.prepare("DELETE FROM votes WHERE userId = ?").run(args.id);
|
|
db.prepare("DELETE FROM pinned_units WHERE userId = ?").run(args.id);
|
|
for (const id of noteIds) {
|
|
db.prepare("DELETE FROM votes WHERE targetType = 'note' AND targetId = ?").run(id);
|
|
db.prepare("DELETE FROM comments WHERE parentId = ?").run(id);
|
|
}
|
|
for (const id of questionIds) {
|
|
db.prepare("DELETE FROM votes WHERE targetType = 'question' AND targetId = ?").run(id);
|
|
db.prepare("DELETE FROM comments WHERE questionId = ?").run(id);
|
|
}
|
|
db.prepare("DELETE FROM comments WHERE authorId = ?").run(args.id);
|
|
db.prepare("DELETE FROM notes WHERE authorId = ?").run(args.id);
|
|
db.prepare("DELETE FROM questions WHERE authorId = ?").run(args.id);
|
|
db.prepare("DELETE FROM email_verifications WHERE email = ?").run(user.email);
|
|
db.prepare("DELETE FROM users WHERE id = ?").run(args.id);
|
|
db.exec("COMMIT");
|
|
} catch (err) {
|
|
db.exec("ROLLBACK");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function adminNotesList(db: Db, args: { token: string }) {
|
|
requireAdmin(db, args.token);
|
|
return db
|
|
.prepare(
|
|
`SELECT
|
|
n.id AS _id,
|
|
n.title,
|
|
n.content,
|
|
n.topicId,
|
|
n.unitId,
|
|
n.authorId,
|
|
n.authorName,
|
|
n.createdAt,
|
|
n.updatedAt,
|
|
n.voteCount,
|
|
n.commentCount,
|
|
u.code AS unitCode,
|
|
u.code2 AS unitCode2
|
|
FROM notes n
|
|
LEFT JOIN units u ON u.id = n.unitId
|
|
ORDER BY n.createdAt DESC`,
|
|
)
|
|
.all();
|
|
}
|
|
|
|
function adminNotesUpdate(
|
|
db: Db,
|
|
args: { token: string; id: string; title: string; content: string },
|
|
) {
|
|
requireAdmin(db, args.token);
|
|
const title = (args.title ?? "").trim();
|
|
const content = (args.content ?? "").trim();
|
|
if (!title || !content) throw new Error("Title and content are required");
|
|
db.prepare("UPDATE notes SET title = ?, content = ?, updatedAt = ? WHERE id = ?").run(
|
|
title,
|
|
content,
|
|
Date.now(),
|
|
args.id,
|
|
);
|
|
}
|
|
|
|
function adminNotesDelete(db: Db, args: { token: string; id: string }) {
|
|
requireAdmin(db, args.token);
|
|
db.prepare("DELETE FROM comments WHERE parentId = ?").run(args.id);
|
|
db.prepare("DELETE FROM votes WHERE targetType = 'note' AND targetId = ?").run(args.id);
|
|
db.prepare("DELETE FROM notes WHERE id = ?").run(args.id);
|
|
}
|
|
|
|
function startOfUtcWeek(ts: number): number {
|
|
const date = new Date(ts);
|
|
const day = date.getUTCDay();
|
|
const diffToMonday = (day + 6) % 7;
|
|
date.setUTCHours(0, 0, 0, 0);
|
|
date.setUTCDate(date.getUTCDate() - diffToMonday);
|
|
return date.getTime();
|
|
}
|
|
|
|
function adminStats(db: Db, args: { token: string; weeks?: number }) {
|
|
requireAdmin(db, args.token);
|
|
const weekCount = Math.max(1, Math.min(52, args.weeks ?? 8));
|
|
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
|
const now = Date.now();
|
|
const currentWeekStart = startOfUtcWeek(now);
|
|
const firstWeekStart = currentWeekStart - (weekCount - 1) * weekMs;
|
|
|
|
const notes = db
|
|
.prepare("SELECT createdAt FROM notes WHERE createdAt >= ?")
|
|
.all(firstWeekStart) as { createdAt: number }[];
|
|
const questions = db
|
|
.prepare("SELECT createdAt FROM questions WHERE createdAt >= ?")
|
|
.all(firstWeekStart) as { createdAt: number }[];
|
|
|
|
const buckets = new Map<number, { notes: number; questions: number }>();
|
|
for (let i = 0; i < weekCount; i++) {
|
|
buckets.set(firstWeekStart + i * weekMs, { notes: 0, questions: 0 });
|
|
}
|
|
|
|
for (const row of notes) {
|
|
const start = startOfUtcWeek(row.createdAt);
|
|
const bucket = buckets.get(start);
|
|
if (bucket) bucket.notes++;
|
|
}
|
|
for (const row of questions) {
|
|
const start = startOfUtcWeek(row.createdAt);
|
|
const bucket = buckets.get(start);
|
|
if (bucket) bucket.questions++;
|
|
}
|
|
|
|
const weeks = [...buckets.entries()]
|
|
.sort((a, b) => a[0] - b[0])
|
|
.map(([weekStart, counts]) => ({ weekStart, ...counts }));
|
|
|
|
const totals = {
|
|
notes: (db.prepare("SELECT COUNT(*) AS c FROM notes").get() as { c: number }).c,
|
|
questions: (db.prepare("SELECT COUNT(*) AS c FROM questions").get() as { c: number }).c,
|
|
users: (db.prepare("SELECT COUNT(*) AS c FROM users").get() as { c: number }).c,
|
|
units: (db.prepare("SELECT COUNT(*) AS c FROM units").get() as { c: number }).c,
|
|
};
|
|
|
|
return { weeks, totals };
|
|
}
|
|
|
|
// ---- dispatcher ----
|
|
|
|
type Handler = (db: Db, args: any) => any;
|
|
|
|
const handlers: Record<string, Handler> = {
|
|
"auth:requestCode": authRequestCode,
|
|
"auth:signup": authSignup,
|
|
"auth:signin": authSignin,
|
|
"auth:forgotPassword": authForgotPassword,
|
|
"auth:resetPassword": authResetPassword,
|
|
"users:getByToken": usersGetByToken,
|
|
"users:updateProfile": usersUpdateProfile,
|
|
"users:changePassword": usersChangePassword,
|
|
"users:getPublicProfile": usersGetPublicProfile,
|
|
"topics:getBySlug": topicsGetBySlug,
|
|
"topics:getAll": topicsGetAll,
|
|
"units:getByCode": unitsGetByCode,
|
|
"units:getAll": unitsGetAll,
|
|
"units:search": unitsSearch,
|
|
"units:createCustom": unitsCreateCustom,
|
|
"units:getPinned": unitsGetPinned,
|
|
"units:pin": unitsPin,
|
|
"units:unpin": unitsUnpin,
|
|
"notes:create": notesCreate,
|
|
"notes:list": notesList,
|
|
"notes:search": notesSearch,
|
|
"search:all": searchAll,
|
|
"notes:getById": notesGetById,
|
|
"notes:remove": notesRemove,
|
|
"notes:update": notesUpdate,
|
|
"questions:create": questionsCreate,
|
|
"questions:list": questionsList,
|
|
"questions:search": questionsSearch,
|
|
"questions:getById": questionsGetById,
|
|
"questions:markSolved": questionsMarkSolved,
|
|
"questions:remove": questionsRemove,
|
|
"questions:update": questionsUpdate,
|
|
"comments:createOnNote": commentsCreateOnNote,
|
|
"comments:createOnQuestion": commentsCreateOnQuestion,
|
|
"comments:listByNote": commentsListByNote,
|
|
"comments:listByQuestion": commentsListByQuestion,
|
|
"comments:remove": commentsRemove,
|
|
"comments:update": commentsUpdate,
|
|
"votes:cast": votesCast,
|
|
"votes:getMyVote": votesGetMyVote,
|
|
"posts:getTitles": postsGetTitles,
|
|
"details:getNoteWithDetails": getNoteWithDetails,
|
|
"details:getQuestionWithDetails": getQuestionWithDetails,
|
|
"admin:getState": adminGetState,
|
|
"admin:requestCode": adminRequestCode,
|
|
"admin:completeSetup": adminCompleteSetup,
|
|
"admin:unitsSave": adminUnitsSave,
|
|
"admin:unitsDelete": adminUnitsDelete,
|
|
"admin:usersList": adminUsersList,
|
|
"admin:usersUpdate": adminUsersUpdate,
|
|
"admin:usersDelete": adminUsersDelete,
|
|
"admin:notesList": adminNotesList,
|
|
"admin:notesUpdate": adminNotesUpdate,
|
|
"admin:notesDelete": adminNotesDelete,
|
|
"admin:stats": adminStats,
|
|
};
|
|
|
|
export async function call(fn: string, args: Record<string, any> = {}): Promise<any> {
|
|
const handler = handlers[fn];
|
|
if (!handler) throw new Error(`Unknown function: ${fn}`);
|
|
return await handler(getDb(), args ?? {});
|
|
}
|
|
|
|
export function getUserByToken(token: string): Record<string, any> | null {
|
|
if (!token) return null;
|
|
return usersGetByToken(getDb(), { token });
|
|
}
|