Files
gerbeur/api/services/chat-service.ts
khannurien d62e4a9883
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s
v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:07:40 +00:00

317 lines
10 KiB
TypeScript

/**
* The site-wide chat: messages, their history, and each user's read marker.
*
* @module
*/
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";
/** How many messages a page holds when the caller names no limit. */
const DEFAULT_LIMIT = 50;
/** The most messages a page may hold. */
const MAX_LIMIT = 100;
/**
* The length cap of 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;
/** The columns of a full `ChatMessageRow`, over {@link FROM_JOINS}. */
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`;
/**
* The 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`;
/** Returns message `id`, or throws a 404. */
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);
}
/**
* Posts a message from `userId`, replying to `replyToId` when that message
* exists, and binds its attachments.
*
* @throws {APIException} 400 when the body is blank or longer than
* `VALIDATION.CHAT_MESSAGE_MAX`.
*/
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);
}
/** Returns the author of message `id`, or throws a 404. */
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;
}
/**
* Replaces a message's body, as its author or a moderator (`canModerate`), and
* binds any new attachment.
*
* @throws {APIException} 404 when there is no such message, 401 when the user
* is neither its author nor a moderator, 400 when the body is blank or too
* long.
*/
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);
}
/**
* Deletes a message, as its author or a moderator (`canModerate`).
*
* @throws {APIException} 404 when there is no such message, 401 when the user
* is neither its author nor a moderator.
*/
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 {
/** The `created_at` of the message the cursor sits at. */
createdAt: string;
/**
* The id of that message. Omitted: the cursor sits before (for `after`) or
* after (for `before`) every message sharing that timestamp.
*/
id?: string;
}
/** Converts query rows to messages. */
function toMessages(rows: unknown[]): ChatMessage[] {
if (!rows.every(isChatMessageRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed chat message data",
);
}
return rows.map(chatMessageRowToApi);
}
/** Clamps a page size to [1, {@link MAX_LIMIT}]. */
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);
}
/** How far a user has read the chat. */
export interface ChatReadState {
/** The `created_at` of the newest message the user has seen (ISO). */
readAt: string;
/** Messages from other people since then. */
unread: number;
}
/**
* Returns how far `userId` has read the chat; a user who never opened it has
* read up to their registration.
*
* @throws {APIException} 404 when there is no such user.
*/
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.
*
* @throws {APIException} 400 when `at` isn't a date, 404 when there is no
* such user.
*/
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);
}