All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
164 lines
4.9 KiB
TypeScript
164 lines
4.9 KiB
TypeScript
import {
|
|
APIErrorCode,
|
|
APIException,
|
|
type ChatMessage,
|
|
} from "../model/interfaces.ts";
|
|
import { chatMessageRowToApi, db, isChatMessageRow } from "../model/db.ts";
|
|
|
|
export const MAX_CHAT_LENGTH = 2000;
|
|
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.
|
|
const REPLY_SNIPPET_LEN = 140;
|
|
|
|
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 > MAX_CHAT_LENGTH) {
|
|
throw new APIException(
|
|
APIErrorCode.VALIDATION_ERROR,
|
|
400,
|
|
`Message exceeds ${MAX_CHAT_LENGTH} 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);
|
|
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 > MAX_CHAT_LENGTH) {
|
|
throw new APIException(
|
|
APIErrorCode.VALIDATION_ERROR,
|
|
400,
|
|
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
|
|
);
|
|
}
|
|
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
|
|
.run(trimmed, new Date().toISOString(), id);
|
|
return fetchMessage(id);
|
|
}
|
|
|
|
// Deletion is a moderation action only (owners can edit but not delete).
|
|
export function deleteChatMessage(id: string, canModerate: boolean): void {
|
|
if (!canModerate) {
|
|
throw new APIException(
|
|
APIErrorCode.UNAUTHORIZED,
|
|
401,
|
|
"Not authorized to delete this message",
|
|
);
|
|
}
|
|
getMessageOwner(id); // 404s if the message doesn't exist
|
|
db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id);
|
|
}
|
|
|
|
/**
|
|
* Returns up to `limit` most recent messages in ascending (display) order.
|
|
* When `before` (a message created_at ISO string) is given, returns the page
|
|
* of older messages immediately preceding it — used for "load older" history.
|
|
*/
|
|
export function getRecentChatMessages(
|
|
limit = DEFAULT_LIMIT,
|
|
before?: string,
|
|
): ChatMessage[] {
|
|
const capped = Math.min(Math.max(limit, 1), MAX_LIMIT);
|
|
const rows = before
|
|
? db.prepare(
|
|
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
|
WHERE m.created_at < ? ORDER BY m.created_at DESC LIMIT ?;`,
|
|
).all(before, capped)
|
|
: db.prepare(
|
|
`SELECT ${SELECT_COLS} ${FROM_JOINS}
|
|
ORDER BY m.created_at DESC LIMIT ?;`,
|
|
).all(capped);
|
|
|
|
if (!rows.every(isChatMessageRow)) {
|
|
throw new APIException(
|
|
APIErrorCode.SERVER_ERROR,
|
|
500,
|
|
"Malformed chat message data",
|
|
);
|
|
}
|
|
// Newest-first from SQL; reverse to ascending for display.
|
|
return rows.map(chatMessageRowToApi).reverse();
|
|
}
|