292 lines
9.4 KiB
TypeScript
292 lines
9.4 KiB
TypeScript
import type {
|
|
InboxItem,
|
|
InboxState,
|
|
Message,
|
|
SearchResult,
|
|
Ticket,
|
|
TicketState,
|
|
} from "@lynkd/messaging-inbox-sdk";
|
|
import * as seed from "./data";
|
|
|
|
/**
|
|
* In-memory mutable store. Next.js dev/prod route handlers share one Node
|
|
* process, so a module singleton persists across requests for the session.
|
|
* This is the ONLY stateful piece of the "backend" — swap it for the IIOS
|
|
* client and the BFF routes stay identical.
|
|
*/
|
|
class MockStore {
|
|
users = clone(seed.users);
|
|
channels = clone(seed.channels);
|
|
messages = clone(seed.messages);
|
|
inbox = clone(seed.inbox);
|
|
tickets = clone(seed.tickets);
|
|
notifications = clone(seed.notifications);
|
|
availability = clone(seed.availability);
|
|
private idc = 10000;
|
|
|
|
me() {
|
|
return this.users.find((u) => u.id === seed.me.id) ?? this.users[0];
|
|
}
|
|
|
|
bootstrap() {
|
|
return {
|
|
me: this.me(),
|
|
workspace: seed.workspace,
|
|
workspaces: seed.workspaces,
|
|
users: this.users,
|
|
sections: seed.sections,
|
|
channels: this.channels,
|
|
};
|
|
}
|
|
|
|
addMember(channelId: string, userId: string) {
|
|
const c = this.channels.find((x) => x.id === channelId);
|
|
if (c && !c.memberIds.includes(userId)) c.memberIds.push(userId);
|
|
return c ?? null;
|
|
}
|
|
|
|
removeMember(channelId: string, userId: string) {
|
|
const c = this.channels.find((x) => x.id === channelId);
|
|
if (c) c.memberIds = c.memberIds.filter((id) => id !== userId);
|
|
return c ?? null;
|
|
}
|
|
|
|
markRead(channelId: string) {
|
|
const c = this.channels.find((x) => x.id === channelId);
|
|
if (c) {
|
|
c.unreadCount = 0;
|
|
c.mentionCount = 0;
|
|
}
|
|
return c ?? null;
|
|
}
|
|
|
|
editMessage(messageId: string, body: string): Message | null {
|
|
const m = this.messages.find((x) => x.id === messageId);
|
|
if (!m) return null;
|
|
m.body = body;
|
|
m.editedTs = Date.now();
|
|
return m;
|
|
}
|
|
|
|
deleteMessage(messageId: string): boolean {
|
|
const before = this.messages.length;
|
|
this.messages = this.messages.filter((m) => m.id !== messageId && m.parentId !== messageId);
|
|
return this.messages.length !== before;
|
|
}
|
|
|
|
savedMessages(): Message[] {
|
|
return this.messages.filter((m) => m.isSaved).sort((a, b) => b.ts - a.ts);
|
|
}
|
|
|
|
threadParents(): Message[] {
|
|
return this.messages.filter((m) => (m.replyCount ?? 0) > 0).sort((a, b) => (b.lastReplyTs ?? b.ts) - (a.lastReplyTs ?? a.ts));
|
|
}
|
|
|
|
setStatus(text: string | undefined, emoji: string | undefined, clearAt?: number) {
|
|
const me = this.me();
|
|
me.statusText = text;
|
|
me.statusEmoji = emoji;
|
|
me.statusClearAt = clearAt;
|
|
return me;
|
|
}
|
|
|
|
updateMe(patch: { name?: string; title?: string; avatarColor?: string; avatarUrl?: string | null; presence?: string }) {
|
|
const me = this.me();
|
|
if (patch.name !== undefined) me.name = patch.name;
|
|
if (patch.title !== undefined) me.title = patch.title;
|
|
if (patch.avatarColor !== undefined) me.avatarColor = patch.avatarColor;
|
|
if (patch.avatarUrl !== undefined) me.avatarUrl = patch.avatarUrl ?? undefined;
|
|
if (patch.presence !== undefined) me.presence = patch.presence as any;
|
|
return me;
|
|
}
|
|
|
|
channelMessages(channelId: string): Message[] {
|
|
return this.messages
|
|
.filter((m) => m.channelId === channelId && !m.parentId)
|
|
.sort((a, b) => a.ts - b.ts);
|
|
}
|
|
|
|
thread(channelId: string, parentId: string) {
|
|
const parent = this.messages.find((m) => m.id === parentId) ?? null;
|
|
const replies = this.messages
|
|
.filter((m) => m.parentId === parentId)
|
|
.sort((a, b) => a.ts - b.ts);
|
|
return { parent, replies };
|
|
}
|
|
|
|
send(
|
|
channelId: string,
|
|
body: string,
|
|
opts: { parentId?: string; scheduledFor?: number; attachments?: any[]; threadRootId?: string | null } = {},
|
|
): Message {
|
|
const msg: Message = {
|
|
id: `m_new_${this.idc++}`,
|
|
channelId,
|
|
authorId: seed.me.id,
|
|
ts: Date.now(),
|
|
body,
|
|
reactions: [],
|
|
deliveryState: "sent",
|
|
parentId: opts.parentId ?? null,
|
|
scheduledFor: opts.scheduledFor,
|
|
attachments: opts.attachments && opts.attachments.length ? opts.attachments : undefined,
|
|
threadRootId: opts.threadRootId ?? undefined,
|
|
};
|
|
this.messages.push(msg);
|
|
if (opts.parentId) {
|
|
const parent = this.messages.find((m) => m.id === opts.parentId);
|
|
if (parent) {
|
|
parent.replyCount = (parent.replyCount ?? 0) + 1;
|
|
parent.lastReplyTs = msg.ts;
|
|
parent.replyUserIds = Array.from(new Set([...(parent.replyUserIds ?? []), seed.me.id]));
|
|
}
|
|
}
|
|
const ch = this.channels.find((c) => c.id === channelId);
|
|
if (ch) ch.lastActivityTs = msg.ts;
|
|
return msg;
|
|
}
|
|
|
|
createChannel(input: { name: string; kind?: string; topic?: string; memberIds?: string[] }) {
|
|
const kind = (input.kind as any) || "channel";
|
|
const isDm = kind === "dm" || kind === "group_dm";
|
|
const id = `${isDm ? "d" : "c"}_new_${this.idc++}`;
|
|
const channel: any = {
|
|
id,
|
|
kind,
|
|
name: input.name.replace(/^#/, "").trim() || "new-channel",
|
|
topic: input.topic,
|
|
memberIds: Array.from(new Set([seed.me.id, ...(input.memberIds ?? [])])),
|
|
sectionId: isDm ? "dms" : "channels",
|
|
unreadCount: 0,
|
|
lastActivityTs: Date.now(),
|
|
};
|
|
this.channels.push(channel);
|
|
return channel;
|
|
}
|
|
|
|
react(messageId: string, emoji: string): Message | null {
|
|
const msg = this.messages.find((m) => m.id === messageId);
|
|
if (!msg) return null;
|
|
msg.reactions = msg.reactions ?? [];
|
|
const r = msg.reactions.find((x) => x.emoji === emoji);
|
|
const uid = seed.me.id;
|
|
if (r) {
|
|
if (r.userIds.includes(uid)) {
|
|
r.userIds = r.userIds.filter((id) => id !== uid);
|
|
if (r.userIds.length === 0) msg.reactions = msg.reactions.filter((x) => x.emoji !== emoji);
|
|
} else {
|
|
r.userIds.push(uid);
|
|
}
|
|
} else {
|
|
msg.reactions.push({ emoji, userIds: [uid] });
|
|
}
|
|
return msg;
|
|
}
|
|
|
|
pin(messageId: string): Message | null {
|
|
const msg = this.messages.find((m) => m.id === messageId);
|
|
if (!msg) return null;
|
|
msg.isPinned = !msg.isPinned;
|
|
return msg;
|
|
}
|
|
|
|
save(messageId: string): Message | null {
|
|
const msg = this.messages.find((m) => m.id === messageId);
|
|
if (!msg) return null;
|
|
msg.isSaved = !msg.isSaved;
|
|
return msg;
|
|
}
|
|
|
|
listInbox(state?: InboxState): InboxItem[] {
|
|
const s = state ?? "OPEN";
|
|
return this.inbox.filter((i) => i.state === s).sort((a, b) => b.ts - a.ts);
|
|
}
|
|
|
|
patchInbox(id: string, state: InboxState): InboxItem | null {
|
|
const item = this.inbox.find((i) => i.id === id);
|
|
if (!item) return null;
|
|
item.state = state;
|
|
return item;
|
|
}
|
|
|
|
listTickets(scope?: string): Ticket[] {
|
|
let list = this.tickets;
|
|
if (scope === "mine") list = list.filter((t) => t.requesterId === seed.me.id || t.assigneeId === seed.me.id);
|
|
if (scope === "assigned") list = list.filter((t) => t.assigneeId === seed.me.id);
|
|
return [...list].sort((a, b) => b.updatedTs - a.updatedTs);
|
|
}
|
|
|
|
getTicket(id: string): Ticket | null {
|
|
return this.tickets.find((t) => t.id === id) ?? null;
|
|
}
|
|
|
|
patchTicket(id: string, state: TicketState): Ticket | null {
|
|
const t = this.tickets.find((x) => x.id === id);
|
|
if (!t) return null;
|
|
t.state = state;
|
|
t.updatedTs = Date.now();
|
|
if (state === "RESOLVED" || state === "CLOSED") t.slaBreached = false;
|
|
return t;
|
|
}
|
|
|
|
replyTicket(id: string, body: string): Ticket | null {
|
|
const t = this.tickets.find((x) => x.id === id);
|
|
if (!t) return null;
|
|
t.messages = t.messages ?? [];
|
|
t.messages.push({
|
|
id: `tm_new_${this.idc++}`,
|
|
channelId: `t_${id}`,
|
|
authorId: seed.me.id,
|
|
ts: Date.now(),
|
|
body,
|
|
deliveryState: "sent",
|
|
});
|
|
t.updatedTs = Date.now();
|
|
if (t.state === "NEW") t.state = "OPEN";
|
|
t.slaBreached = false;
|
|
return t;
|
|
}
|
|
|
|
search(q: string): SearchResult[] {
|
|
const query = q.toLowerCase().trim();
|
|
if (!query) return [];
|
|
const out: SearchResult[] = [];
|
|
for (const c of this.channels) {
|
|
if (c.name.toLowerCase().includes(query) || c.topic?.toLowerCase().includes(query)) {
|
|
out.push({ id: c.id, type: "channel", title: c.kind === "channel" ? `#${c.name}` : c.name, subtitle: c.topic, channelId: c.id });
|
|
}
|
|
}
|
|
for (const u of this.users) {
|
|
if (u.name.toLowerCase().includes(query) || u.handle.includes(query)) {
|
|
out.push({ id: u.id, type: "person", title: u.name, subtitle: u.title });
|
|
}
|
|
}
|
|
for (const msg of this.messages) {
|
|
if (msg.body.toLowerCase().includes(query)) {
|
|
out.push({ id: msg.id, type: "message", title: msg.body.slice(0, 80), subtitle: `in ${this.channelLabel(msg.channelId)}`, channelId: msg.channelId, ts: msg.ts, parentId: msg.parentId ?? undefined });
|
|
}
|
|
}
|
|
for (const t of this.tickets) {
|
|
if (t.subject.toLowerCase().includes(query) || t.ref.toLowerCase().includes(query)) {
|
|
out.push({ id: t.id, type: "ticket", title: `${t.ref} · ${t.subject}`, subtitle: t.state });
|
|
}
|
|
}
|
|
return out.slice(0, 20);
|
|
}
|
|
|
|
private channelLabel(id: string): string {
|
|
const c = this.channels.find((x) => x.id === id);
|
|
if (!c) return "conversation";
|
|
return c.kind === "channel" || c.kind === "private" ? `#${c.name}` : c.name;
|
|
}
|
|
}
|
|
|
|
function clone<T>(v: T): T {
|
|
return JSON.parse(JSON.stringify(v));
|
|
}
|
|
|
|
// preserve the singleton across HMR reloads in dev (bump the key when the
|
|
// store's shape/methods change so HMR rebuilds it instead of reusing a stale one)
|
|
const g = globalThis as unknown as { __lynkdStore_v3?: MockStore };
|
|
export const store = g.__lynkdStore_v3 ?? (g.__lynkdStore_v3 = new MockStore());
|