feat(housekeeping): complete people moderation parity

This commit is contained in:
Simo committed 2026-08-29 22:38:50 +02:00
1 parent 3d385d1869
commit 29fe22297b
41 files changed
+3465 -580

No files matched your search

+47
View File
@@ -21,6 +21,53 @@ export interface ModerationResult {
reason?: string;
}
export type ModerationAction =
| { readonly action: "kick"; readonly userId: number }
| {
readonly action: "mute";
readonly userId: number;
readonly duration: number;
}
| { readonly action: "unmute"; readonly userId: number }
| {
readonly action: "alert";
readonly userId: number;
readonly message: string;
}
| { readonly action: "room-kick"; readonly roomId: number }
| {
readonly action: "broadcast";
readonly message: string;
readonly type: "hotel" | "staff";
};
export interface ModerationActionTransport {
disconnectUser(userId: number): Promise<boolean>;
muteUser(userId: number, duration: number): Promise<boolean>;
unmuteUser(userId: number): Promise<boolean>;
alertUser(userId: number, message: string): Promise<boolean>;
kickAll(roomId: number): Promise<boolean>;
hotelAlert(message: string): Promise<boolean>;
staffAlert(message: string): Promise<boolean>;
}
// Execute one already-authorized moderation effect and expose delivery truth.
export async function executeModerationAction(
transport: ModerationActionTransport,
input: ModerationAction,
): Promise<boolean> {
if (input.action === "kick") return transport.disconnectUser(input.userId);
if (input.action === "mute")
return transport.muteUser(input.userId, input.duration);
if (input.action === "unmute") return transport.unmuteUser(input.userId);
if (input.action === "alert")
return transport.alertUser(input.userId, input.message);
if (input.action === "room-kick") return transport.kickAll(input.roomId);
return input.type === "hotel"
? transport.hotelAlert(input.message)
: transport.staffAlert(input.message);
}
const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations";
// Bound the AI call so a slow/hung endpoint can't stall a server action.
const OPENAI_TIMEOUT_MS = 5_000;
+22
View File
@@ -19,6 +19,28 @@ export interface TicketReplyDb {
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
}
const MAX_UNSIGNED_BIGINT = 18_446_744_073_709_551_615n;
// Canonicalize a SQL BIGINT identifier without passing through Number.
export function canonicalTicketId(value: string | number | bigint): bigint {
let parsed: bigint;
try {
if (
typeof value === "number" &&
(!Number.isSafeInteger(value) || value <= 0)
) {
throw new Error("unsafe identifier");
}
parsed = BigInt(String(value));
} catch {
throw new Error("invalid ticket identifier");
}
if (parsed <= 0n || parsed > MAX_UNSIGNED_BIGINT) {
throw new Error("invalid ticket identifier");
}
return parsed;
}
export async function createOwnedTicketReply(
db: TicketReplyDb,
input: { ticketId: bigint; userId: number; content: string },