Add board thread interventions
This commit is contained in:
1 parent
28007b3691
commit
78dbcbe7ce
14 files changed
+990
-370
No files matched your search
@@ -0,0 +1,208 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SendMessageInput } from "../../shared/commands";
|
||||
import {
|
||||
BOARD_INTERVENTION_ACTION,
|
||||
BOARD_INTERVENTION_MODE,
|
||||
CHAT_THREAD_BOARD_STATE,
|
||||
RISK_LEVEL,
|
||||
type BoardInterventionAction,
|
||||
type BoardInterventionMode,
|
||||
} from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import type { ResolvedMention } from "./chat-mentions";
|
||||
import { ApprovalService } from "./approval-service";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
interface PrincipalRef {
|
||||
type: "employee" | "agent";
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ApprovalRequester {
|
||||
type: "employee" | "agent" | "system";
|
||||
id: string | null;
|
||||
}
|
||||
|
||||
interface BoardCommand {
|
||||
mode: BoardInterventionMode;
|
||||
action: BoardInterventionAction;
|
||||
details: string;
|
||||
}
|
||||
|
||||
interface BoardTarget {
|
||||
type: "employee" | "agent" | "board";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RecordBoardInterventionInput {
|
||||
messageInput: SendMessageInput;
|
||||
sender: PrincipalRef | null;
|
||||
mentions: ResolvedMention[];
|
||||
args: string;
|
||||
messageId: string;
|
||||
threadId: string;
|
||||
spaceId: string;
|
||||
now: string;
|
||||
}
|
||||
|
||||
export class BoardInterventionService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly approvals: ApprovalService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.approvals = new ApprovalService(db);
|
||||
}
|
||||
|
||||
record(input: RecordBoardInterventionInput): string {
|
||||
if (input.messageInput.senderType !== "board") throw new Error("Board intervention requires board sender.");
|
||||
const command = parseBoardCommand(input.args);
|
||||
const target = resolveBoardTarget(command, input.messageInput.orgId, input.mentions);
|
||||
const approvalId = command.action === BOARD_INTERVENTION_ACTION.FORCE_APPROVAL
|
||||
? this.createBoardApproval(input, command)
|
||||
: null;
|
||||
const interventionId = createId("bdi");
|
||||
this.db.prepare(`
|
||||
insert into board_interventions(
|
||||
id, org_id, thread_id, message_id, mode, action, details, target_type, target_id,
|
||||
approval_id, created_by_type, created_by_id, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
interventionId,
|
||||
input.messageInput.orgId,
|
||||
input.threadId,
|
||||
input.messageId,
|
||||
command.mode,
|
||||
command.action,
|
||||
command.details,
|
||||
target.type,
|
||||
target.id,
|
||||
approvalId,
|
||||
input.messageInput.senderType,
|
||||
input.messageInput.senderId,
|
||||
input.now,
|
||||
);
|
||||
this.applyBoardThreadState(input, command, target);
|
||||
this.audit.record({
|
||||
orgId: input.messageInput.orgId,
|
||||
entityType: "board_intervention",
|
||||
entityId: interventionId,
|
||||
actorType: input.messageInput.senderType,
|
||||
actorId: input.messageInput.senderId,
|
||||
action: "recorded",
|
||||
payload: {
|
||||
threadId: input.threadId,
|
||||
messageId: input.messageId,
|
||||
mode: command.mode,
|
||||
action: command.action,
|
||||
targetType: target.type,
|
||||
targetId: target.id,
|
||||
approvalId,
|
||||
},
|
||||
});
|
||||
return interventionId;
|
||||
}
|
||||
|
||||
private createBoardApproval(input: RecordBoardInterventionInput, command: BoardCommand): string {
|
||||
const requester = approvalRequester(input.messageInput.senderType, input.sender);
|
||||
return this.approvals.create({
|
||||
orgId: input.messageInput.orgId,
|
||||
kind: "board_force_approval",
|
||||
title: `Board approval ${spaceName(this.db, input.spaceId)}`,
|
||||
details: command.details,
|
||||
risk: RISK_LEVEL.HIGH,
|
||||
requestedByType: requester.type,
|
||||
requestedById: requester.id,
|
||||
}, { sourceMessageId: input.messageId, sourceThreadId: input.threadId });
|
||||
}
|
||||
|
||||
private applyBoardThreadState(input: RecordBoardInterventionInput, command: BoardCommand, target: BoardTarget): void {
|
||||
if (command.action === BOARD_INTERVENTION_ACTION.FREEZE) {
|
||||
this.updateBoardThreadState(input, CHAT_THREAD_BOARD_STATE.FROZEN, null, null);
|
||||
return;
|
||||
}
|
||||
if (command.action === BOARD_INTERVENTION_ACTION.TAKEOVER) {
|
||||
this.updateBoardThreadState(input, CHAT_THREAD_BOARD_STATE.TAKEOVER, target.type, target.id);
|
||||
return;
|
||||
}
|
||||
if (command.action === BOARD_INTERVENTION_ACTION.ROLLBACK) {
|
||||
this.updateBoardThreadState(input, CHAT_THREAD_BOARD_STATE.OPEN, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private updateBoardThreadState(
|
||||
input: RecordBoardInterventionInput,
|
||||
boardState: string,
|
||||
controlledByType: BoardTarget["type"] | null,
|
||||
controlledById: string | null,
|
||||
): void {
|
||||
this.db.prepare(`
|
||||
update chat_threads
|
||||
set board_state = ?, controlled_by_type = ?, controlled_by_id = ?, updated_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(boardState, controlledByType, controlledById, input.now, input.threadId, input.messageInput.orgId);
|
||||
this.audit.record({
|
||||
orgId: input.messageInput.orgId,
|
||||
entityType: "chat_thread",
|
||||
entityId: input.threadId,
|
||||
actorType: "board",
|
||||
actorId: input.messageInput.senderId,
|
||||
action: "board_state_changed",
|
||||
payload: { threadId: input.threadId, boardState, controlledByType, controlledById },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBoardCommand(args: string): BoardCommand {
|
||||
const trimmed = args.trim();
|
||||
if (!trimmed) throw new Error("Slash command /board requires an action.");
|
||||
const [rawAction = "", ...rest] = trimmed.split(/\s+/);
|
||||
const action = normalizeBoardAction(rawAction);
|
||||
const details = rest.join(" ").trim();
|
||||
if (!details) throw new Error(`Slash command /board ${rawAction} requires details.`);
|
||||
return {
|
||||
mode: boardModeFor(action, rawAction),
|
||||
action,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBoardTarget(command: BoardCommand, orgId: string, mentions: ResolvedMention[]): BoardTarget {
|
||||
if (command.action !== BOARD_INTERVENTION_ACTION.TAKEOVER) return { type: "board", id: orgId };
|
||||
const principal = mentions.find((mention) => mention.target.type === "employee" || mention.target.type === "agent");
|
||||
if (!principal) return { type: "board", id: orgId };
|
||||
if (principal.target.type === "employee" || principal.target.type === "agent") {
|
||||
return { type: principal.target.type, id: principal.target.id };
|
||||
}
|
||||
return { type: "board", id: orgId };
|
||||
}
|
||||
|
||||
function normalizeBoardAction(value: string): BoardInterventionAction {
|
||||
const normalized = value.toLowerCase().replace(/-/g, "_");
|
||||
if (normalized === "observe") return BOARD_INTERVENTION_ACTION.DECISION;
|
||||
if (normalized === BOARD_INTERVENTION_ACTION.DECISION) return BOARD_INTERVENTION_ACTION.DECISION;
|
||||
if (normalized === BOARD_INTERVENTION_ACTION.FREEZE) return BOARD_INTERVENTION_ACTION.FREEZE;
|
||||
if (normalized === BOARD_INTERVENTION_ACTION.TAKEOVER) return BOARD_INTERVENTION_ACTION.TAKEOVER;
|
||||
if (normalized === BOARD_INTERVENTION_ACTION.ROLLBACK) return BOARD_INTERVENTION_ACTION.ROLLBACK;
|
||||
if (normalized === BOARD_INTERVENTION_ACTION.FORCE_APPROVAL) return BOARD_INTERVENTION_ACTION.FORCE_APPROVAL;
|
||||
throw new Error(`Unsupported board action: ${value}.`);
|
||||
}
|
||||
|
||||
function boardModeFor(action: BoardInterventionAction, rawAction: string): BoardInterventionMode {
|
||||
if (rawAction.toLowerCase() === "observe") return BOARD_INTERVENTION_MODE.OBSERVE;
|
||||
if (action === BOARD_INTERVENTION_ACTION.TAKEOVER) return BOARD_INTERVENTION_MODE.TAKEOVER;
|
||||
return BOARD_INTERVENTION_MODE.INTERVENE;
|
||||
}
|
||||
|
||||
function approvalRequester(senderType: SendMessageInput["senderType"], sender: PrincipalRef | null): ApprovalRequester {
|
||||
if (senderType === "system") return { type: "system", id: null };
|
||||
if (!sender) throw new Error("Approval requester is missing.");
|
||||
if (senderType === "agent") return { type: "agent", id: sender.id };
|
||||
return { type: "employee", id: sender.id };
|
||||
}
|
||||
|
||||
function spaceName(db: DatabaseSync, spaceId: string): string {
|
||||
const row = db.prepare("select name from chat_spaces where id = ?").get(spaceId) as { name: string } | undefined;
|
||||
if (!row) throw new Error("Chat space was not found.");
|
||||
return row.name;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { canonicalMentionHandle } from "./mention-handles";
|
||||
|
||||
export interface MentionTarget {
|
||||
type: "employee" | "agent" | "board" | "org_group";
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface MentionCandidate extends MentionTarget {
|
||||
handle: string;
|
||||
}
|
||||
|
||||
export interface ResolvedMention {
|
||||
target: MentionTarget;
|
||||
offsetStart: number;
|
||||
offsetEnd: number;
|
||||
}
|
||||
|
||||
export function resolveChatMentions(db: DatabaseSync, orgId: string, spaceId: string, body: string): ResolvedMention[] {
|
||||
const mentions: ResolvedMention[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of body.matchAll(/(^|\s)@([A-Za-z][A-Za-z0-9_-]{1,63})/g)) {
|
||||
const token = match[2];
|
||||
const target = resolveMention(db, orgId, spaceId, token);
|
||||
const offsetStart = match.index + match[1].length;
|
||||
const key = `${target.type}:${target.id}:${offsetStart}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
mentions.push({ target, offsetStart, offsetEnd: offsetStart + token.length + 1 });
|
||||
}
|
||||
return mentions;
|
||||
}
|
||||
|
||||
export function recordChatMentions(db: DatabaseSync, messageId: string, mentions: ResolvedMention[]): void {
|
||||
for (const mention of mentions) {
|
||||
db.prepare(`
|
||||
insert into chat_mentions(message_id, target_type, target_id, offset_start, offset_end)
|
||||
values (?, ?, ?, ?, ?)
|
||||
`).run(messageId, mention.target.type, mention.target.id, mention.offsetStart, mention.offsetEnd);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMention(db: DatabaseSync, orgId: string, spaceId: string, token: string): MentionTarget {
|
||||
const handles = mentionLookupHandles(token);
|
||||
if (handles.has("board")) return { type: "board", id: orgId };
|
||||
if (handles.has("oncall")) return { type: "org_group", id: `${orgId}:oncall` };
|
||||
const candidates = mentionCandidates(db, orgId, spaceId).filter((candidate) => handles.has(candidate.handle));
|
||||
const unique = new Map(candidates.map((candidate) => [`${candidate.type}:${candidate.id}`, candidate]));
|
||||
if (unique.size === 0) throw new Error(`Mention @${token} could not be resolved.`);
|
||||
if (unique.size > 1) throw new Error(`Mention @${token} is ambiguous.`);
|
||||
const target = [...unique.values()][0];
|
||||
return { type: target.type, id: target.id };
|
||||
}
|
||||
|
||||
function mentionCandidates(db: DatabaseSync, orgId: string, spaceId: string): MentionCandidate[] {
|
||||
const employees = db.prepare(`
|
||||
select employees.id, employees.display_name
|
||||
from employees
|
||||
join chat_memberships memberships
|
||||
on memberships.principal_type = 'employee'
|
||||
and memberships.principal_id = employees.id
|
||||
and memberships.space_id = ?
|
||||
where employees.org_id = ?
|
||||
`).all(spaceId, orgId) as Array<{ id: string; display_name: string }>;
|
||||
const agents = db.prepare(`
|
||||
select agents.id, agents.name
|
||||
from agents
|
||||
join chat_memberships memberships
|
||||
on memberships.principal_type = 'agent'
|
||||
and memberships.principal_id = agents.id
|
||||
and memberships.space_id = ?
|
||||
where agents.org_id = ?
|
||||
`).all(spaceId, orgId) as Array<{ id: string; name: string }>;
|
||||
return [
|
||||
...employees.flatMap((employee) => principalMentionHandles("employee", employee.id, employee.display_name)),
|
||||
...agents.flatMap((agent) => principalMentionHandles("agent", agent.id, agent.name)),
|
||||
];
|
||||
}
|
||||
|
||||
function principalMentionHandles(type: "employee" | "agent", id: string, name: string): MentionCandidate[] {
|
||||
const handles = new Set([canonicalMentionHandle(name), id.toLowerCase()].filter((handle) => handle.length > 0));
|
||||
return [...handles].map((handle) => ({ type, id, handle }));
|
||||
}
|
||||
|
||||
function mentionLookupHandles(token: string): Set<string> {
|
||||
return new Set([token.toLowerCase(), canonicalMentionHandle(token)].filter((handle) => handle.length > 0));
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
|
||||
import { FILE_SCAN_STATE, RISK_LEVEL } from "../../shared/domain";
|
||||
import {
|
||||
CHAT_THREAD_BOARD_STATE,
|
||||
FILE_SCAN_STATE,
|
||||
RISK_LEVEL,
|
||||
} from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { ApprovalService } from "./approval-service";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { canonicalMentionHandle } from "./mention-handles";
|
||||
import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service";
|
||||
import { recordChatMentions, resolveChatMentions, type MentionTarget, type ResolvedMention } from "./chat-mentions";
|
||||
|
||||
const SPACE_TYPES = new Set<CreateChatSpaceInput["spaceType"]>(["company", "department", "project", "direct"]);
|
||||
const SPACE_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["workspace", "restricted"]);
|
||||
@@ -29,33 +34,27 @@ interface SpaceRow {
|
||||
space_type: string;
|
||||
}
|
||||
|
||||
interface ThreadRow {
|
||||
id: string;
|
||||
board_state: string;
|
||||
controlled_by_type: "employee" | "agent" | "board" | null;
|
||||
controlled_by_id: string | null;
|
||||
}
|
||||
|
||||
interface PrincipalRef {
|
||||
type: "employee" | "agent";
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface MentionTarget {
|
||||
type: "employee" | "agent" | "board" | "org_group";
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface MentionCandidate extends MentionTarget {
|
||||
handle: string;
|
||||
}
|
||||
|
||||
interface ResolvedMention {
|
||||
target: MentionTarget;
|
||||
offsetStart: number;
|
||||
offsetEnd: number;
|
||||
}
|
||||
|
||||
export class ChatService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly approvals: ApprovalService;
|
||||
private readonly boardInterventions: BoardInterventionService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.approvals = new ApprovalService(db);
|
||||
this.boardInterventions = new BoardInterventionService(db);
|
||||
}
|
||||
|
||||
createSpace(input: CreateChatSpaceInput): string {
|
||||
@@ -97,12 +96,13 @@ export class ChatService {
|
||||
const space = this.requireSpace(input.orgId, input.spaceId);
|
||||
const sender = this.requireSender(input.orgId, input.senderType, input.senderId);
|
||||
if (sender) this.requireMembership(space.id, sender);
|
||||
if (input.threadId) this.requireThread(input.orgId, input.spaceId, input.threadId);
|
||||
const thread = input.threadId ? this.requireThread(input.orgId, input.spaceId, input.threadId) : null;
|
||||
this.assertThreadControl(input, thread, sender);
|
||||
const slash = parseSlashInvocation(body);
|
||||
if (slash && !ALLOWED_SLASH_COMMANDS.has(slash.command)) throw new Error(`Slash command /${slash.command} is unavailable.`);
|
||||
if (slash) validateSlashInvocation(slash);
|
||||
const attachments = this.requireAttachments(input.orgId, input.attachmentFileIds ?? [], sender);
|
||||
const mentions = this.resolveMentions(input.orgId, input.spaceId, body);
|
||||
const mentions = resolveChatMentions(this.db, input.orgId, input.spaceId, body);
|
||||
|
||||
const id = createId("msg");
|
||||
const threadId = input.threadId ?? createId("thread");
|
||||
@@ -131,8 +131,8 @@ export class ChatService {
|
||||
} else {
|
||||
this.db.prepare("update chat_threads set updated_at = ? where id = ? and org_id = ?").run(now, input.threadId, input.orgId);
|
||||
}
|
||||
this.recordMentions(id, mentions);
|
||||
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, slash, id, threadId) : null;
|
||||
recordChatMentions(this.db, id, mentions);
|
||||
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, mentions, slash, id, threadId, now) : null;
|
||||
if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now);
|
||||
this.recordAttachments(id, attachments);
|
||||
this.recordAttachmentGrants(input, id, threadId, attachments, mentions, now);
|
||||
@@ -180,50 +180,28 @@ export class ChatService {
|
||||
`).run(input.orgId, input.employeeId, input.spaceId, latest?.id ?? null, now);
|
||||
}
|
||||
|
||||
private resolveMentions(orgId: string, spaceId: string, body: string): ResolvedMention[] {
|
||||
const mentions: ResolvedMention[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of body.matchAll(/(^|\s)@([A-Za-z][A-Za-z0-9_-]{1,63})/g)) {
|
||||
const token = match[2];
|
||||
const target = this.resolveMention(orgId, spaceId, token);
|
||||
const offsetStart = match.index + match[1].length;
|
||||
const key = `${target.type}:${target.id}:${offsetStart}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
mentions.push({ target, offsetStart, offsetEnd: offsetStart + token.length + 1 });
|
||||
}
|
||||
return mentions;
|
||||
}
|
||||
|
||||
private recordMentions(messageId: string, mentions: ResolvedMention[]): void {
|
||||
for (const mention of mentions) {
|
||||
this.db.prepare(`
|
||||
insert into chat_mentions(message_id, target_type, target_id, offset_start, offset_end)
|
||||
values (?, ?, ?, ?, ?)
|
||||
`).run(messageId, mention.target.type, mention.target.id, mention.offsetStart, mention.offsetEnd);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveMention(orgId: string, spaceId: string, token: string): MentionTarget {
|
||||
const handles = mentionLookupHandles(token);
|
||||
if (handles.has("board")) return { type: "board", id: orgId };
|
||||
if (handles.has("oncall")) return { type: "org_group", id: `${orgId}:oncall` };
|
||||
const candidates = mentionCandidates(this.db, orgId, spaceId).filter((candidate) => handles.has(candidate.handle));
|
||||
const unique = new Map(candidates.map((candidate) => [`${candidate.type}:${candidate.id}`, candidate]));
|
||||
if (unique.size === 0) throw new Error(`Mention @${token} could not be resolved.`);
|
||||
if (unique.size > 1) throw new Error(`Mention @${token} is ambiguous.`);
|
||||
const target = [...unique.values()][0];
|
||||
return { type: target.type, id: target.id };
|
||||
}
|
||||
|
||||
private executeSlashCommand(
|
||||
input: SendMessageInput,
|
||||
space: SpaceRow,
|
||||
sender: PrincipalRef | null,
|
||||
mentions: ResolvedMention[],
|
||||
slash: SlashInvocation,
|
||||
messageId: string,
|
||||
threadId: string,
|
||||
now: string,
|
||||
): string | null {
|
||||
if (slash.command === "board") {
|
||||
return this.boardInterventions.record({
|
||||
messageInput: input,
|
||||
sender,
|
||||
mentions,
|
||||
args: slash.args,
|
||||
messageId,
|
||||
threadId,
|
||||
spaceId: space.id,
|
||||
now,
|
||||
});
|
||||
}
|
||||
if (slash.command !== "escalate") return null;
|
||||
const requester = approvalRequester(input.senderType, sender);
|
||||
return this.approvals.create({
|
||||
@@ -307,6 +285,18 @@ export class ChatService {
|
||||
return space;
|
||||
}
|
||||
|
||||
private assertThreadControl(input: SendMessageInput, thread: ThreadRow | null, sender: PrincipalRef | null): void {
|
||||
if (!thread) return;
|
||||
if (input.senderType === "board") return;
|
||||
if (thread.board_state === CHAT_THREAD_BOARD_STATE.FROZEN) {
|
||||
throw new Error("Board-frozen thread requires board sender.");
|
||||
}
|
||||
if (thread.board_state !== CHAT_THREAD_BOARD_STATE.TAKEOVER) return;
|
||||
if (!sender) throw new Error("Board takeover controls this thread.");
|
||||
if (thread.controlled_by_type === sender.type && thread.controlled_by_id === sender.id) return;
|
||||
throw new Error("Board takeover controls this thread.");
|
||||
}
|
||||
|
||||
private requireSender(orgId: string, senderType: SendMessageInput["senderType"], senderId: string | null): PrincipalRef | null {
|
||||
if (senderType === "system") return null;
|
||||
if (senderType === "employee") {
|
||||
@@ -324,10 +314,14 @@ export class ChatService {
|
||||
return { type: "employee", id: senderId };
|
||||
}
|
||||
|
||||
private requireThread(orgId: string, spaceId: string, threadId: string): void {
|
||||
const thread = this.db.prepare("select id from chat_threads where id = ? and org_id = ? and space_id = ?")
|
||||
.get(threadId, orgId, spaceId) as { id: string } | undefined;
|
||||
private requireThread(orgId: string, spaceId: string, threadId: string): ThreadRow {
|
||||
const thread = this.db.prepare(`
|
||||
select id, board_state, controlled_by_type, controlled_by_id
|
||||
from chat_threads
|
||||
where id = ? and org_id = ? and space_id = ?
|
||||
`).get(threadId, orgId, spaceId) as ThreadRow | undefined;
|
||||
if (!thread) throw new Error("Chat thread was not found in this space.");
|
||||
return thread;
|
||||
}
|
||||
|
||||
private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): AttachmentCandidate[] {
|
||||
@@ -452,6 +446,7 @@ const ALLOWED_SLASH_COMMANDS = new Set([
|
||||
"runbook",
|
||||
"file",
|
||||
"escalate",
|
||||
"board",
|
||||
]);
|
||||
|
||||
function parseSlashInvocation(body: string): SlashInvocation | null {
|
||||
@@ -464,6 +459,7 @@ function validateSlashInvocation(slash: SlashInvocation): void {
|
||||
if (slash.command === "escalate" && slash.args.trim().length === 0) {
|
||||
throw new Error("Slash command /escalate requires details.");
|
||||
}
|
||||
if (slash.command === "board") parseBoardCommand(slash.args);
|
||||
}
|
||||
|
||||
function approvalRequester(senderType: SendMessageInput["senderType"], sender: PrincipalRef | null): ApprovalRequester {
|
||||
@@ -483,37 +479,3 @@ function createThreadTitle(body: string): string {
|
||||
const firstLine = body.split(/\r?\n/, 1)[0].trim();
|
||||
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
|
||||
}
|
||||
|
||||
function mentionCandidates(db: DatabaseSync, orgId: string, spaceId: string): MentionCandidate[] {
|
||||
const employees = db.prepare(`
|
||||
select employees.id, employees.display_name
|
||||
from employees
|
||||
join chat_memberships memberships
|
||||
on memberships.principal_type = 'employee'
|
||||
and memberships.principal_id = employees.id
|
||||
and memberships.space_id = ?
|
||||
where employees.org_id = ?
|
||||
`).all(spaceId, orgId) as Array<{ id: string; display_name: string }>;
|
||||
const agents = db.prepare(`
|
||||
select agents.id, agents.name
|
||||
from agents
|
||||
join chat_memberships memberships
|
||||
on memberships.principal_type = 'agent'
|
||||
and memberships.principal_id = agents.id
|
||||
and memberships.space_id = ?
|
||||
where agents.org_id = ?
|
||||
`).all(spaceId, orgId) as Array<{ id: string; name: string }>;
|
||||
return [
|
||||
...employees.flatMap((employee) => principalMentionHandles("employee", employee.id, employee.display_name)),
|
||||
...agents.flatMap((agent) => principalMentionHandles("agent", agent.id, agent.name)),
|
||||
];
|
||||
}
|
||||
|
||||
function principalMentionHandles(type: "employee" | "agent", id: string, name: string): MentionCandidate[] {
|
||||
const handles = new Set([canonicalMentionHandle(name), id.toLowerCase()].filter((handle) => handle.length > 0));
|
||||
return [...handles].map((handle) => ({ type, id, handle }));
|
||||
}
|
||||
|
||||
function mentionLookupHandles(token: string): Set<string> {
|
||||
return new Set([token.toLowerCase(), canonicalMentionHandle(token)].filter((handle) => handle.length > 0));
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const LEGACY_TABLES = [
|
||||
"chat_attachments",
|
||||
"chat_mentions",
|
||||
"chat_slash_invocations",
|
||||
"board_interventions",
|
||||
"chat_presence",
|
||||
"chat_read_state",
|
||||
"providers",
|
||||
@@ -113,6 +114,10 @@ const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "chat_attachments", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" },
|
||||
{ table: "chat_mentions", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||
{ table: "chat_slash_invocations", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||
{ table: "board_interventions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "board_interventions", column: "thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_thread_id" },
|
||||
{ table: "board_interventions", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||
{ table: "board_interventions", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" },
|
||||
{ table: "chat_read_state", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "chat_read_state", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" },
|
||||
{ table: "chat_read_state", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" },
|
||||
|
||||
@@ -65,6 +65,7 @@ export class SnapshotService {
|
||||
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
|
||||
chatMentions: list(this.db, "chat_mentions", "message_id asc, offset_start asc"),
|
||||
chatSlashInvocations: list(this.db, "chat_slash_invocations", "created_at asc"),
|
||||
boardInterventions: list(this.db, "board_interventions", "created_at asc"),
|
||||
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
|
||||
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
|
||||
files,
|
||||
|
||||
Reference in new issue
Block a user