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>
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -6,27 +12,36 @@ 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;
|
||||
|
||||
// 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.
|
||||
/**
|
||||
* 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`;
|
||||
|
||||
// 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).
|
||||
/**
|
||||
* 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 = ?;`,
|
||||
@@ -37,6 +52,13 @@ function fetchMessage(id: string): ChatMessage {
|
||||
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,
|
||||
@@ -77,6 +99,7 @@ export function createChatMessage(
|
||||
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,
|
||||
@@ -87,7 +110,14 @@ function getMessageOwner(id: string): string {
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
// Owners may edit their own messages; moderators may edit any.
|
||||
/**
|
||||
* 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,
|
||||
@@ -124,7 +154,12 @@ export function updateChatMessage(
|
||||
return fetchMessage(id);
|
||||
}
|
||||
|
||||
// Owners may delete their own messages; moderators may delete any.
|
||||
/**
|
||||
* 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,
|
||||
@@ -141,16 +176,22 @@ export function deleteChatMessage(
|
||||
db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id);
|
||||
}
|
||||
|
||||
/** A position in the chat stream. Messages are ordered by (createdAt, id), so
|
||||
/**
|
||||
* 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. */
|
||||
* between them can't skip either.
|
||||
*/
|
||||
export interface ChatCursor {
|
||||
/** The `created_at` of the message the cursor sits at. */
|
||||
createdAt: string;
|
||||
/** Omitted: the cursor sits before (for `after`) or after (for `before`)
|
||||
* every message sharing that timestamp. */
|
||||
/**
|
||||
* 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(
|
||||
@@ -162,6 +203,7 @@ function toMessages(rows: unknown[]): ChatMessage[] {
|
||||
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);
|
||||
}
|
||||
@@ -213,13 +255,20 @@ export function getChatMessagesAfter(
|
||||
return toMessages(rows);
|
||||
}
|
||||
|
||||
/** How far a user has read the chat. */
|
||||
export interface ChatReadState {
|
||||
/** created_at of the newest message the user has seen (ISO). */
|
||||
/** 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,
|
||||
@@ -238,6 +287,9 @@ export function getChatReadState(userId: string): ChatReadState {
|
||||
* 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);
|
||||
|
||||
Reference in New Issue
Block a user