import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { ChatMessage } from "../../shared/chat.ts"; import { chatMessageRowToApi, isChatMessageRow } from "../model/chat.ts"; import { db } from "../db/connection.ts"; import { linkAttachments } from "./attachment-service.ts"; import { VALIDATION } from "../../shared/validation.ts"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 100; // Length cap for the replied-to preview snippet, so a reply to a long message // doesn't ship the whole 2000-char body over the wire for every reply. This is // raw markdown: the client strips it to text before showing a line of it, so // it's cut generously enough that an image URL or link doesn't eat it all. const REPLY_SNIPPET_LEN = 300; const SELECT_COLS = `m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id, u.username as author_username, u.avatar_mime as author_avatar_mime, ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`; // Common joins: author (required) plus the optional replied-to message and its // author (LEFT — null when there's no reply or the target was deleted). const FROM_JOINS = `FROM chat_messages m JOIN users u ON m.user_id = u.id LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id LEFT JOIN users ru ON rm.user_id = ru.id`; function fetchMessage(id: string): ChatMessage { const row = db.prepare( `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`, ).get(id); if (!row || !isChatMessageRow(row)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found"); } return chatMessageRowToApi(row); } export function createChatMessage( userId: string, body: string, replyToId?: string, ): ChatMessage { const trimmed = body.trim(); if (!trimmed) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Message cannot be empty", ); } if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, `Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`, ); } // Only store the reply target if it actually exists — drop dangling ids so a // message never references a phantom parent. let replyTo: string | null = null; if (replyToId) { const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get( replyToId, ); if (exists) replyTo = replyToId; } const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); db.prepare( `INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`, ).run(id, userId, trimmed, createdAt, replyTo); // Claim any pasted/dropped image attachments so the startup cleanup doesn't // purge them as orphans (mirrors dumps/comments/playlists). linkAttachments(trimmed, id); return fetchMessage(id); } function getMessageOwner(id: string): string { const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get( id, ) as { user_id: string } | undefined; if (!row) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found"); } return row.user_id; } // Owners may edit their own messages; moderators may edit any. export function updateChatMessage( id: string, body: string, requestingUserId: string, canModerate: boolean, ): ChatMessage { const ownerId = getMessageOwner(id); if (ownerId !== requestingUserId && !canModerate) { throw new APIException( APIErrorCode.UNAUTHORIZED, 401, "Not authorized to edit this message", ); } const trimmed = body.trim(); if (!trimmed) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Message cannot be empty", ); } if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, `Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`, ); } db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`) .run(trimmed, new Date().toISOString(), id); // An edit may introduce a freshly pasted image; claim its attachment too. linkAttachments(trimmed, id); return fetchMessage(id); } // Owners may delete their own messages; moderators may delete any. export function deleteChatMessage( id: string, requestingUserId: string, canModerate: boolean, ): void { const ownerId = getMessageOwner(id); // 404s if the message doesn't exist if (ownerId !== requestingUserId && !canModerate) { throw new APIException( APIErrorCode.UNAUTHORIZED, 401, "Not authorized to delete this message", ); } db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id); } /** A position in the chat stream. Messages are ordered by (createdAt, id), so * two sharing a millisecond still have a strict order and a page boundary * between them can't skip either. */ export interface ChatCursor { createdAt: string; /** Omitted: the cursor sits before (for `after`) or after (for `before`) * every message sharing that timestamp. */ id?: string; } function toMessages(rows: unknown[]): ChatMessage[] { if (!rows.every(isChatMessageRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, 500, "Malformed chat message data", ); } return rows.map(chatMessageRowToApi); } function capLimit(limit: number): number { return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT); } /** * Returns up to `limit` most recent messages in ascending (display) order. * When `before` is given, returns the page of older messages immediately * preceding it — used for "load older" history. */ export function getRecentChatMessages( limit = DEFAULT_LIMIT, before?: ChatCursor, ): ChatMessage[] { const capped = capLimit(limit); const rows = before ? db.prepare( `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.created_at < ? OR (m.created_at = ? AND m.id < ?) ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`, ).all( before.createdAt, before.createdAt, // No id: nothing at that timestamp sorts below the empty string. before.id ?? "", capped, ) : db.prepare( `SELECT ${SELECT_COLS} ${FROM_JOINS} ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`, ).all(capped); // Newest-first from SQL; reverse to ascending for display. return toMessages(rows).reverse(); } /** * Returns up to `limit` messages following `after`, in ascending order — used * to resync the chat after a reconnect. Without an id the cursor is inclusive * of its timestamp. */ export function getChatMessagesAfter( after: ChatCursor, limit = DEFAULT_LIMIT, ): ChatMessage[] { const rows = db.prepare( `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.created_at > ? OR (m.created_at = ? AND m.id > ?) ORDER BY m.created_at ASC, m.id ASC LIMIT ?;`, ).all(after.createdAt, after.createdAt, after.id ?? "", capLimit(limit)); return toMessages(rows); } export interface ChatReadState { /** created_at of the newest message the user has seen (ISO). */ readAt: string; /** Messages from other people since then. */ unread: number; } export function getChatReadState(userId: string): ChatReadState { const row = db.prepare( `SELECT COALESCE(u.chat_read_at, u.created_at) AS read_at, (SELECT COUNT(*) FROM chat_messages m WHERE m.user_id != u.id AND m.created_at > COALESCE(u.chat_read_at, u.created_at)) AS unread FROM users u WHERE u.id = ?;`, ).get(userId) as { read_at: string; unread: number } | undefined; if (!row) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); } return { readAt: row.read_at, unread: row.unread }; } /** * Moves the user's read marker forward to `at` (a message's created_at) — * never backward, so a lagging tab can't undo a newer tab's read, and never * past the newest message, so a bogus timestamp can't pre-read the future. */ export function markChatRead(userId: string, at: string): ChatReadState { const parsed = new Date(at); if (Number.isNaN(parsed.getTime())) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid read marker", ); } const newest = db.prepare( `SELECT MAX(created_at) AS newest FROM chat_messages;`, ).get() as { newest: string | null }; // No messages yet: there's nothing to have read, and no newest message to // cap a bogus far-future marker at — which could never be moved back. if (!newest.newest) return getChatReadState(userId); const iso = parsed.toISOString(); const target = iso > newest.newest ? newest.newest : iso; db.prepare( `UPDATE users SET chat_read_at = ? WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`, ).run(target, userId, target); return getChatReadState(userId); }