Files
2026-07-17 21:48:37 +05:30

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());