Team/user CRMFS export + scoped chat
This commit is contained in:
142
Frontend/server/dataset/exporter.ts
Normal file
142
Frontend/server/dataset/exporter.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { datasetRoot } from "./paths";
|
||||
|
||||
type ExportMeta = {
|
||||
exportedAt: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
async function ensureDir(p: string) {
|
||||
await fs.mkdir(p, { recursive: true });
|
||||
}
|
||||
|
||||
async function writeJson(p: string, value: unknown) {
|
||||
await fs.writeFile(p, JSON.stringify(value, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function jsonlLine(value: unknown) {
|
||||
return JSON.stringify(value) + "\n";
|
||||
}
|
||||
|
||||
export async function exportDatasetFromPrisma() {
|
||||
throw new Error("exportDatasetFromPrisma now requires { teamId, userId }");
|
||||
}
|
||||
|
||||
export async function exportDatasetFromPrismaFor(input: { teamId: string; userId: string }) {
|
||||
const root = datasetRoot(input);
|
||||
const tmp = root + ".tmp";
|
||||
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
await ensureDir(tmp);
|
||||
|
||||
const contactsDir = path.join(tmp, "contacts");
|
||||
const notesDir = path.join(tmp, "notes");
|
||||
const messagesDir = path.join(tmp, "messages");
|
||||
const eventsDir = path.join(tmp, "events");
|
||||
const indexDir = path.join(tmp, "index");
|
||||
await Promise.all([
|
||||
ensureDir(contactsDir),
|
||||
ensureDir(notesDir),
|
||||
ensureDir(messagesDir),
|
||||
ensureDir(eventsDir),
|
||||
ensureDir(indexDir),
|
||||
]);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: { teamId: input.teamId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: {
|
||||
note: { select: { content: true, updatedAt: true } },
|
||||
messages: {
|
||||
select: { direction: true, channel: true, content: true, occurredAt: true },
|
||||
orderBy: { occurredAt: "asc" },
|
||||
},
|
||||
events: {
|
||||
select: { title: true, startsAt: true, endsAt: true, status: true, note: true },
|
||||
orderBy: { startsAt: "asc" },
|
||||
},
|
||||
},
|
||||
take: 5000,
|
||||
});
|
||||
|
||||
const contactIndex = [];
|
||||
|
||||
for (const c of contacts) {
|
||||
const contactFile = path.join(contactsDir, `${c.id}.json`);
|
||||
await writeJson(contactFile, {
|
||||
id: c.id,
|
||||
teamId: c.teamId,
|
||||
name: c.name,
|
||||
company: c.company ?? null,
|
||||
email: c.email ?? null,
|
||||
phone: c.phone ?? null,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
});
|
||||
|
||||
const noteFile = path.join(notesDir, `${c.id}.md`);
|
||||
await fs.writeFile(
|
||||
noteFile,
|
||||
(c.note?.content?.trim() ? c.note.content.trim() : "") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const msgFile = path.join(messagesDir, `${c.id}.jsonl`);
|
||||
const msgLines = c.messages.map((m) =>
|
||||
jsonlLine({
|
||||
direction: m.direction,
|
||||
channel: m.channel,
|
||||
occurredAt: m.occurredAt,
|
||||
content: m.content,
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(msgFile, msgLines.join(""), "utf8");
|
||||
|
||||
const evFile = path.join(eventsDir, `${c.id}.jsonl`);
|
||||
const evLines = c.events.map((e) =>
|
||||
jsonlLine({
|
||||
title: e.title,
|
||||
startsAt: e.startsAt,
|
||||
endsAt: e.endsAt,
|
||||
status: e.status ?? null,
|
||||
note: e.note ?? null,
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(evFile, evLines.join(""), "utf8");
|
||||
|
||||
const lastMessageAt = c.messages.length ? c.messages[c.messages.length - 1].occurredAt : null;
|
||||
const nextEventAt = c.events.find((e) => new Date(e.startsAt).getTime() >= Date.now())?.startsAt ?? null;
|
||||
|
||||
contactIndex.push({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
company: c.company ?? null,
|
||||
lastMessageAt,
|
||||
nextEventAt,
|
||||
updatedAt: c.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
await writeJson(path.join(indexDir, "contacts.json"), contactIndex);
|
||||
|
||||
const meta: ExportMeta = { exportedAt: new Date().toISOString(), version: 1 };
|
||||
await writeJson(path.join(tmp, "meta.json"), meta);
|
||||
|
||||
await ensureDir(path.dirname(root));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
await fs.rename(tmp, root);
|
||||
}
|
||||
|
||||
export async function ensureDataset(input: { teamId: string; userId: string }) {
|
||||
const root = datasetRoot(input);
|
||||
try {
|
||||
const metaPath = path.join(root, "meta.json");
|
||||
await fs.access(metaPath);
|
||||
return;
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
await exportDatasetFromPrismaFor(input);
|
||||
}
|
||||
6
Frontend/server/dataset/paths.ts
Normal file
6
Frontend/server/dataset/paths.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function datasetRoot(input: { teamId: string; userId: string }) {
|
||||
// Keep it outside Frontend so it can be easily ignored and shared.
|
||||
return path.resolve(process.cwd(), "..", ".data", "crmfs", "teams", input.teamId, "users", input.userId);
|
||||
}
|
||||
Reference in New Issue
Block a user