402 lines
14 KiB
JavaScript
402 lines
14 KiB
JavaScript
import { PrismaClient } from "@prisma/client";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { randomBytes, scryptSync } from "node:crypto";
|
|
|
|
function loadEnvFromDotEnv() {
|
|
const p = path.resolve(process.cwd(), ".env");
|
|
if (!fs.existsSync(p)) return;
|
|
const raw = fs.readFileSync(p, "utf8");
|
|
for (const line of raw.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
const idx = trimmed.indexOf("=");
|
|
if (idx === -1) continue;
|
|
const key = trimmed.slice(0, idx).trim();
|
|
let val = trimmed.slice(idx + 1).trim();
|
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
val = val.slice(1, -1);
|
|
}
|
|
if (!key) continue;
|
|
if (key === "DATABASE_URL") {
|
|
process.env[key] = val;
|
|
continue;
|
|
}
|
|
if (!process.env[key]) process.env[key] = val;
|
|
}
|
|
}
|
|
|
|
loadEnvFromDotEnv();
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
const LOGIN_PHONE = "+15550000001";
|
|
const LOGIN_PASSWORD = "ConnectFlow#2026";
|
|
const LOGIN_NAME = "Connect Owner";
|
|
|
|
const SCRYPT_KEY_LENGTH = 64;
|
|
|
|
function hashPassword(password) {
|
|
const salt = randomBytes(16).toString("base64url");
|
|
const digest = scryptSync(password, salt, SCRYPT_KEY_LENGTH).toString("base64url");
|
|
return `scrypt$${salt}$${digest}`;
|
|
}
|
|
|
|
function atOffset(days, hour, minute) {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + days);
|
|
d.setHours(hour, minute, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
function plusMinutes(date, minutes) {
|
|
const d = new Date(date);
|
|
d.setMinutes(d.getMinutes() + minutes);
|
|
return d;
|
|
}
|
|
|
|
function buildContacts(teamId, count) {
|
|
const firstNames = [
|
|
"Alex", "Mia", "Leo", "Sofia", "Noah", "Emma", "Liam", "Ava", "Ethan", "Luna",
|
|
"Mason", "Chloe", "Logan", "Mila", "Lucas", "Nora", "Elijah", "Zoey", "James", "Aria",
|
|
"Daniel", "Nina", "Henry", "Layla", "Oliver", "Iris", "Oscar", "Diana", "Max", "Eva",
|
|
];
|
|
const lastNames = [
|
|
"Carter", "Meyer", "Ali", "Petrov", "Rivera", "Ivanova", "Fisher", "Khan", "Wright", "Cole",
|
|
"Silva", "Morris", "King", "Anderson", "Lopez", "Walker", "Young", "Scott", "Green", "Parker",
|
|
];
|
|
const companies = [
|
|
"Northline", "Connecta", "Volta", "Blueport", "Skyline", "PrimeGrid", "Helio", "CoreLabs", "NovaTrade", "Astera",
|
|
];
|
|
const locations = [
|
|
{ country: "USA", city: "New York" },
|
|
{ country: "USA", city: "Austin" },
|
|
{ country: "Germany", city: "Berlin" },
|
|
{ country: "UAE", city: "Dubai" },
|
|
{ country: "Spain", city: "Barcelona" },
|
|
{ country: "Armenia", city: "Yerevan" },
|
|
{ country: "UK", city: "London" },
|
|
{ country: "France", city: "Paris" },
|
|
{ country: "Singapore", city: "Singapore" },
|
|
{ country: "Canada", city: "Toronto" },
|
|
];
|
|
|
|
const rows = [];
|
|
for (let i = 0; i < count; i += 1) {
|
|
const first = firstNames[i % firstNames.length];
|
|
const last = lastNames[Math.floor(i / firstNames.length) % lastNames.length];
|
|
const company = `${companies[i % companies.length]} ${String.fromCharCode(65 + (i % 26))}`;
|
|
const loc = locations[i % locations.length];
|
|
const female = i % 2 === 0;
|
|
const picIdx = (i % 70) + 1;
|
|
rows.push({
|
|
teamId,
|
|
name: `${first} ${last} ${i + 1}`,
|
|
company,
|
|
country: loc.country,
|
|
location: loc.city,
|
|
avatarUrl: `https://randomuser.me/api/portraits/${female ? "women" : "men"}/${picIdx}.jpg`,
|
|
email: `${first.toLowerCase()}.${last.toLowerCase()}${i + 1}@${company.toLowerCase().replace(/\s+/g, "")}.example`,
|
|
phone: `+1 555 01${String(i).padStart(4, "0")}`,
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
async function main() {
|
|
const passwordHash = hashPassword(LOGIN_PASSWORD);
|
|
|
|
const user = await prisma.user.upsert({
|
|
where: { id: "demo-user" },
|
|
update: { phone: LOGIN_PHONE, passwordHash, name: LOGIN_NAME, email: "owner@clientsflow.local" },
|
|
create: {
|
|
id: "demo-user",
|
|
phone: LOGIN_PHONE,
|
|
passwordHash,
|
|
name: LOGIN_NAME,
|
|
email: "owner@clientsflow.local",
|
|
},
|
|
});
|
|
|
|
const team = await prisma.team.upsert({
|
|
where: { id: "demo-team" },
|
|
update: { name: "Connect Workspace" },
|
|
create: { id: "demo-team", name: "Connect Workspace" },
|
|
});
|
|
|
|
await prisma.teamMember.upsert({
|
|
where: { teamId_userId: { teamId: team.id, userId: user.id } },
|
|
update: { role: "OWNER" },
|
|
create: { teamId: team.id, userId: user.id, role: "OWNER" },
|
|
});
|
|
|
|
const conversation = await prisma.chatConversation.upsert({
|
|
where: { id: `pilot-${team.id}` },
|
|
update: { title: "Pilot" },
|
|
create: { id: `pilot-${team.id}`, teamId: team.id, createdByUserId: user.id, title: "Pilot" },
|
|
});
|
|
|
|
await prisma.$transaction([
|
|
prisma.feedCard.deleteMany({ where: { teamId: team.id } }),
|
|
prisma.contactPin.deleteMany({ where: { teamId: team.id } }),
|
|
prisma.workspaceDocument.deleteMany({ where: { teamId: team.id } }),
|
|
prisma.deal.deleteMany({ where: { teamId: team.id } }),
|
|
prisma.calendarEvent.deleteMany({ where: { teamId: team.id } }),
|
|
prisma.contactMessage.deleteMany({ where: { contact: { teamId: team.id } } }),
|
|
prisma.chatMessage.deleteMany({ where: { teamId: team.id, conversationId: conversation.id } }),
|
|
prisma.omniMessage.deleteMany({ where: { teamId: team.id } }),
|
|
prisma.omniThread.deleteMany({ where: { teamId: team.id } }),
|
|
prisma.omniContactIdentity.deleteMany({ where: { teamId: team.id } }),
|
|
prisma.telegramBusinessConnection.deleteMany({ where: { teamId: team.id } }),
|
|
prisma.contact.deleteMany({ where: { teamId: team.id } }),
|
|
]);
|
|
|
|
const contacts = await prisma.contact.createManyAndReturn({
|
|
data: buildContacts(team.id, 220),
|
|
select: { id: true, name: true, company: true },
|
|
});
|
|
|
|
await prisma.contactNote.createMany({
|
|
data: contacts.map((c, idx) => ({
|
|
contactId: c.id,
|
|
content:
|
|
`Summary for ${c.name}. Main objective: move the account to a predictable weekly rhythm. ` +
|
|
`Current context: ${c.company ?? "Account"} is active in at least one channel. ` +
|
|
`Recommended path: keep messages short, lock a concrete next step, and update the note after each interaction. ` +
|
|
`Priority signal ${idx % 5 === 0 ? "high" : "normal"}.`,
|
|
})),
|
|
});
|
|
|
|
const channels = ["TELEGRAM", "WHATSAPP", "INSTAGRAM", "EMAIL"];
|
|
const contactMessages = [];
|
|
for (let i = 0; i < contacts.length; i += 1) {
|
|
const contact = contacts[i];
|
|
const base = atOffset(-(i % 18), 9 + (i % 7), (i * 7) % 60);
|
|
|
|
contactMessages.push({
|
|
contactId: contact.id,
|
|
kind: "MESSAGE",
|
|
direction: "IN",
|
|
channel: channels[i % channels.length],
|
|
content: `Hi, this is ${contact.name}. Can we sync on timeline this week?`,
|
|
occurredAt: base,
|
|
});
|
|
|
|
contactMessages.push({
|
|
contactId: contact.id,
|
|
kind: "MESSAGE",
|
|
direction: "OUT",
|
|
channel: channels[(i + 1) % channels.length],
|
|
content: `Sure. I suggest two slots and a clear agenda.`,
|
|
occurredAt: plusMinutes(base, 22),
|
|
});
|
|
|
|
contactMessages.push({
|
|
contactId: contact.id,
|
|
kind: "MESSAGE",
|
|
direction: i % 3 === 0 ? "OUT" : "IN",
|
|
channel: channels[(i + 2) % channels.length],
|
|
content: `Status update: legal owner and decision date are the two blockers now.`,
|
|
occurredAt: plusMinutes(base, 65),
|
|
});
|
|
|
|
if (i % 4 === 0) {
|
|
contactMessages.push({
|
|
contactId: contact.id,
|
|
kind: "CALL",
|
|
direction: "OUT",
|
|
channel: "PHONE",
|
|
content: "Voice call from CRM",
|
|
durationSec: 180 + ((i * 23) % 420),
|
|
transcriptJson: [
|
|
`${contact.name}: We need a clear owner for approval.`,
|
|
`You: Agreed, let's lock this today and set the next checkpoint.`,
|
|
],
|
|
occurredAt: plusMinutes(base, 110),
|
|
});
|
|
}
|
|
}
|
|
await prisma.contactMessage.createMany({ data: contactMessages });
|
|
|
|
await prisma.calendarEvent.createMany({
|
|
data: contacts.flatMap((c, idx) => {
|
|
const firstStart = atOffset((idx % 21) - 3, 10 + (idx % 6), (idx * 5) % 60);
|
|
const secondStart = atOffset((idx % 28) + 1, 14 + (idx % 4), (idx * 3) % 60);
|
|
return [
|
|
{
|
|
teamId: team.id,
|
|
contactId: c.id,
|
|
title: `Follow-up with ${c.name}`,
|
|
startsAt: firstStart,
|
|
endsAt: plusMinutes(firstStart, 30),
|
|
note: "Confirm owner, timeline, and next concrete action.",
|
|
status: "planned",
|
|
},
|
|
{
|
|
teamId: team.id,
|
|
contactId: c.id,
|
|
title: `Checkpoint: ${c.company ?? c.name}`,
|
|
startsAt: secondStart,
|
|
endsAt: plusMinutes(secondStart, 45),
|
|
note: "Review progress and unblock pending decisions.",
|
|
status: idx % 6 === 0 ? "done" : "planned",
|
|
},
|
|
];
|
|
}),
|
|
});
|
|
|
|
const stages = ["Qualification", "Proposal", "Negotiation", "Contract"];
|
|
await prisma.deal.createMany({
|
|
data: contacts
|
|
.filter((_, idx) => idx % 5 !== 0)
|
|
.map((c, idx) => ({
|
|
teamId: team.id,
|
|
contactId: c.id,
|
|
title: `${c.company ?? "Account"} expansion`,
|
|
stage: stages[idx % stages.length],
|
|
amount: 8000 + (idx % 17) * 1500,
|
|
nextStep: "Lock next sync and owner on client side.",
|
|
summary: "Deal is active. Focus on speed and explicit decision checkpoints.",
|
|
})),
|
|
});
|
|
|
|
await prisma.contactPin.createMany({
|
|
data: contacts.map((c, idx) => ({
|
|
teamId: team.id,
|
|
contactId: c.id,
|
|
text:
|
|
idx % 3 === 0
|
|
? "Pinned: calendar event is near, prepare a concise follow-up note."
|
|
: "Pinned: keep one explicit ask in each message.",
|
|
})),
|
|
});
|
|
|
|
const proposalKeys = ["create_followup", "open_comm", "call", "draft_message", "run_summary", "prepare_question"];
|
|
await prisma.feedCard.createMany({
|
|
data: contacts
|
|
.filter((_, idx) => idx % 3 === 0)
|
|
.slice(0, 80)
|
|
.map((c, idx) => ({
|
|
teamId: team.id,
|
|
contactId: c.id,
|
|
happenedAt: atOffset(-(idx % 6), 9 + (idx % 8), (idx * 9) % 60),
|
|
text:
|
|
`I analyzed the latest contact activity for ${c.name}. ` +
|
|
`There is enough momentum to push one concrete action now and reduce response latency.`,
|
|
proposalJson: {
|
|
title: idx % 2 === 0 ? "Add focused follow-up event" : "Draft a concise unblock message",
|
|
details: [
|
|
`Contact: ${c.name}`,
|
|
idx % 2 === 0 ? "Timing: in the next 60 minutes" : "Timing: send in the primary active channel",
|
|
"Goal: lock owner and next exact date",
|
|
],
|
|
key: proposalKeys[idx % proposalKeys.length],
|
|
},
|
|
})),
|
|
});
|
|
|
|
await prisma.workspaceDocument.createMany({
|
|
data: [
|
|
{
|
|
teamId: team.id,
|
|
title: "Response time regulation",
|
|
type: "Regulation",
|
|
owner: "Revenue Ops",
|
|
scope: "All active deals",
|
|
summary: "Rules for first response and follow-up SLA across channels.",
|
|
body: "## SLA\n- First response in under 30 minutes for active threads.\n- Escalate if no owner is assigned.\n\n## Rule\nAlways end a message with one explicit next step.",
|
|
updatedAt: atOffset(-1, 11, 10),
|
|
},
|
|
{
|
|
teamId: team.id,
|
|
title: "Discovery playbook",
|
|
type: "Playbook",
|
|
owner: "Sales Lead",
|
|
scope: "Discovery and qualification",
|
|
summary: "Consistent structure for discovery calls and follow-up notes.",
|
|
body: "## Flow\n1. Pain\n2. Impact\n3. Owner\n4. Timeline\n5. Next step\n\n## Output\nStore concise summary in the contact card.",
|
|
updatedAt: atOffset(-2, 15, 0),
|
|
},
|
|
{
|
|
teamId: team.id,
|
|
title: "AI action policy",
|
|
type: "Policy",
|
|
owner: "Founders",
|
|
scope: "AI recommendations",
|
|
summary: "What can be auto-drafted and what always needs explicit approval.",
|
|
body: "## Allowed\n- Draft suggestions\n- Summaries\n\n## Requires approval\n- Outbound send\n- Event creation\n- Deal stage change",
|
|
updatedAt: atOffset(-3, 9, 30),
|
|
},
|
|
{
|
|
teamId: team.id,
|
|
title: "Post-call template",
|
|
type: "Template",
|
|
owner: "Enablement",
|
|
scope: "Any completed call",
|
|
summary: "Template for short post-call summary with owners and deadlines.",
|
|
body: "## Template\n- Aligned\n- Open items\n- Owner per action\n- Next date\n\nKeep it under 6 lines.",
|
|
updatedAt: atOffset(-4, 13, 45),
|
|
},
|
|
{
|
|
teamId: team.id,
|
|
title: "Objection handling map",
|
|
type: "Playbook",
|
|
owner: "Commercial",
|
|
scope: "Late-stage objections",
|
|
summary: "Common objections and concise response strategy.",
|
|
body: "## Objections\n- Timing\n- Budget\n- Legal\n\n## Response\nAcknowledge, clarify owner, set a concrete checkpoint.",
|
|
updatedAt: atOffset(-5, 10, 0),
|
|
},
|
|
{
|
|
teamId: team.id,
|
|
title: "Pipeline hygiene",
|
|
type: "Regulation",
|
|
owner: "Operations",
|
|
scope: "Pipeline updates",
|
|
summary: "Minimal mandatory updates after each interaction.",
|
|
body: "## Required\n- Last touch timestamp\n- Next step\n- Risk marker\n\nNo long forms, only concise text.",
|
|
updatedAt: atOffset(-6, 16, 15),
|
|
},
|
|
],
|
|
});
|
|
|
|
await prisma.chatMessage.createMany({
|
|
data: [
|
|
{
|
|
teamId: team.id,
|
|
conversationId: conversation.id,
|
|
authorUserId: null,
|
|
role: "ASSISTANT",
|
|
text: "Workspace is ready. I connected contacts, communications, calendar, deals, and recommendations.",
|
|
planJson: {
|
|
steps: ["Open any contact", "Review chat + pinned tasks", "Confirm next event or message"],
|
|
tools: ["contacts", "communications", "calendar", "feed"],
|
|
},
|
|
},
|
|
{
|
|
teamId: team.id,
|
|
conversationId: conversation.id,
|
|
authorUserId: null,
|
|
role: "ASSISTANT",
|
|
text: "Dataset loaded with 220 contacts and linked timeline activity.",
|
|
planJson: { steps: ["Filter by country/company", "Open active threads", "Apply one recommendation"], tools: ["search", "pins"] },
|
|
},
|
|
],
|
|
});
|
|
|
|
console.log("Seed completed.");
|
|
console.log(`Login phone: ${LOGIN_PHONE}`);
|
|
console.log(`Login password: ${LOGIN_PASSWORD}`);
|
|
console.log(`Team: ${team.name}`);
|
|
console.log(`Contacts created: ${contacts.length}`);
|
|
}
|
|
|
|
main()
|
|
.catch((e) => {
|
|
console.error(e);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|