import { TRPCError } from '@trpc/server'; import { asc, desc, eq, sql } from 'drizzle-orm'; import { lead, leadContact, leadTouchpoint, deal, area, user, project } from '@cockpit/db'; import type { Db } from '@cockpit/db'; import { LeadSchemas } from '@cockpit/shared'; import { router, protectedProcedure } from '../trpc.js'; import { parseCsv, normalizeHeader } from '../lib/csv.js'; const SALES_AREA_SLUG = 'vertrieb'; async function getSalesAreaId(db: Db): Promise { const a = (await db.select().from(area).where(eq(area.slug, SALES_AREA_SLUG)).limit(1))[0]; if (!a) throw new Error('Vertriebs-Bereich fehlt — bitte seed laufen lassen.'); return a.id; } export const leadRouter = router({ // Liste aller Leads, mit Owner-Namen. Sortiert: zuletzt aktualisiert zuerst. list: protectedProcedure.query(async ({ ctx }) => { const rows = await ctx.db .select({ lead: lead, ownerName: user.name, projectName: project.name, }) .from(lead) .leftJoin(user, eq(user.id, lead.ownerUserId)) .leftJoin(project, eq(project.id, lead.projectId)) .orderBy(desc(lead.updatedAt)); return rows.map((r) => ({ ...r.lead, ownerName: r.ownerName, projectName: r.projectName })); }), // Leads, die einem Projekt zugeordnet sind (Vertriebs-Sicht im Projekt). forProject: protectedProcedure .input(LeadSchemas.leadsForProjectInput) .query(async ({ ctx, input }) => { const rows = await ctx.db .select({ lead: lead, ownerName: user.name }) .from(lead) .leftJoin(user, eq(user.id, lead.ownerUserId)) .where(eq(lead.projectId, input.projectId)) .orderBy(desc(lead.score), desc(lead.updatedAt)); return rows.map((r) => ({ ...r.lead, ownerName: r.ownerName })); }), byId: protectedProcedure.input(LeadSchemas.leadIdInput).query(async ({ ctx, input }) => { const l = (await ctx.db.select().from(lead).where(eq(lead.id, input.id)).limit(1))[0]; if (!l) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lead nicht gefunden.' }); const contacts = await ctx.db .select() .from(leadContact) .where(eq(leadContact.leadId, l.id)) .orderBy(desc(leadContact.isPrimary), asc(leadContact.order)); const touchpoints = await ctx.db .select({ touchpoint: leadTouchpoint, bearbeiterName: user.name, }) .from(leadTouchpoint) .leftJoin(user, eq(user.id, leadTouchpoint.bearbeiterUserId)) .where(eq(leadTouchpoint.leadId, l.id)) .orderBy(desc(leadTouchpoint.datum), desc(leadTouchpoint.createdAt)); const linkedProject = l.projectId ? (await ctx.db .select({ id: project.id, name: project.name }) .from(project) .where(eq(project.id, l.projectId)) .limit(1))[0] ?? null : null; return { lead: l, contacts, project: linkedProject, touchpoints: touchpoints.map((t) => ({ ...t.touchpoint, bearbeiterName: t.bearbeiterName })), }; }), create: protectedProcedure .input(LeadSchemas.createLeadInput) .mutation(async ({ ctx, input }) => { const areaId = await getSalesAreaId(ctx.db); const [created] = await ctx.db .insert(lead) .values({ areaId, firma: input.firma, status: input.status, score: input.score, ownerUserId: input.ownerUserId ?? null, projectId: input.projectId ?? null, branche: input.branche ?? null, subSegment: input.subSegment ?? null, website: input.website ?? null, quelle: input.quelle ?? null, createdBy: ctx.user.id, }) .returning(); if (!created) throw new Error('Lead konnte nicht angelegt werden.'); return created; }), update: protectedProcedure .input(LeadSchemas.updateLeadInput) .mutation(async ({ ctx, input }) => { const { id, ...patch } = input; if (Object.keys(patch).length === 0) { const existing = (await ctx.db.select().from(lead).where(eq(lead.id, id)).limit(1))[0]; if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lead nicht gefunden.' }); return existing; } const [updated] = await ctx.db .update(lead) .set({ ...patch, updatedAt: new Date() }) .where(eq(lead.id, id)) .returning(); if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lead nicht gefunden.' }); return updated; }), // Kontaktpersonen ----------------------------------------------------------- addContact: protectedProcedure .input(LeadSchemas.addContactInput) .mutation(async ({ ctx, input }) => { const count = ( await ctx.db .select({ c: sql`count(*)::int` }) .from(leadContact) .where(eq(leadContact.leadId, input.leadId)) )[0]?.c ?? 0; const [created] = await ctx.db .insert(leadContact) .values({ leadId: input.leadId, name: input.name, rolle: input.rolle ?? null, funktion: input.funktion ?? null, email: input.email ?? null, telefon: input.telefon ?? null, linkedin: input.linkedin ?? null, imBuyingCenterSeit: input.imBuyingCenterSeit ?? null, sprache: input.sprache ?? null, isPrimary: input.isPrimary, order: count, }) .returning(); return created; }), removeContact: protectedProcedure .input(LeadSchemas.removeContactInput) .mutation(async ({ ctx, input }) => { await ctx.db.delete(leadContact).where(eq(leadContact.id, input.contactId)); return { ok: true as const }; }), // Austausch-Log ------------------------------------------------------------- addTouchpoint: protectedProcedure .input(LeadSchemas.addTouchpointInput) .mutation(async ({ ctx, input }) => { const [created] = await ctx.db .insert(leadTouchpoint) .values({ leadId: input.leadId, datum: input.datum, kanal: input.kanal, bearbeiterUserId: ctx.user.id, inhalt: input.inhalt, outcome: input.outcome ?? null, followUpBis: input.followUpBis ?? null, }) .returning(); // Touchpoint zählt als Aktivität → Lead als aktualisiert markieren. await ctx.db.update(lead).set({ updatedAt: new Date() }).where(eq(lead.id, input.leadId)); return created; }), removeTouchpoint: protectedProcedure .input(LeadSchemas.removeTouchpointInput) .mutation(async ({ ctx, input }) => { await ctx.db.delete(leadTouchpoint).where(eq(leadTouchpoint.id, input.touchpointId)); return { ok: true as const }; }), // Lead → Pipeline-Deal promoten -------------------------------------------- promoteToDeal: protectedProcedure .input(LeadSchemas.promoteToDealInput) .mutation(async ({ ctx, input }) => { const l = (await ctx.db.select().from(lead).where(eq(lead.id, input.leadId)).limit(1))[0]; if (!l) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lead nicht gefunden.' }); if (l.promotedDealId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Dieser Lead wurde bereits zu einem Deal gemacht.', }); } const result = await ctx.db.transaction(async (tx) => { const [createdDeal] = await tx .insert(deal) .values({ areaId: l.areaId, name: l.firma, ownerUserId: l.ownerUserId, valueEur: input.valueEur, probability: input.probability, stage: 'lead', }) .returning(); if (!createdDeal) throw new Error('Deal konnte nicht angelegt werden.'); await tx .update(lead) .set({ promotedDealId: createdDeal.id, updatedAt: new Date() }) .where(eq(lead.id, l.id)); return createdDeal; }); return result; }), // CSV-Import: erzeugt je Zeile einen Lead + primären Kontakt; Adresse → // standorte, Notizen → erster Austausch-Log-Eintrag. Dedup über Firmenname. importCsv: protectedProcedure .input(LeadSchemas.importLeadsCsvInput) .mutation(async ({ ctx, input }) => { const areaId = await getSalesAreaId(ctx.db); const rows = parseCsv(input.csv); const summary = { created: 0, skippedEmpty: 0, skippedDuplicate: 0, contacts: 0, touchpoints: 0 }; if (rows.length < 2 || !rows[0]) return summary; const header = rows[0].map(normalizeHeader); const col = (key: string) => header.indexOf(key); const cells = { quelle: col('quelle'), vorname: col('vorname'), nachname: col('nachname'), position: col('position'), firma: col('firma'), strasse: col('strasse'), plz: col('plz'), ort: col('ort'), telefon: col('telefon'), mobil: col('mobil'), email: col('email'), website: col('website'), branche: col('branche'), notizen: col('notizen'), }; const get = (row: string[], i: number) => (i >= 0 && i < row.length ? (row[i] ?? '').trim() : ''); const existing = await ctx.db.select({ firma: lead.firma }).from(lead); const seen = new Set(existing.map((e) => e.firma.trim().toLowerCase())); const today = new Date().toISOString().slice(0, 10); for (let r = 1; r < rows.length; r++) { const row = rows[r]; if (!row || row.every((c) => c.trim() === '')) continue; // Leerzeile const vorname = get(row, cells.vorname); const nachname = get(row, cells.nachname); const personName = `${vorname} ${nachname}`.trim(); const firma = get(row, cells.firma) || personName; if (!firma) { summary.skippedEmpty++; continue; } const key = firma.toLowerCase(); if (seen.has(key)) { summary.skippedDuplicate++; continue; } seen.add(key); const standorte = [ get(row, cells.strasse), [get(row, cells.plz), get(row, cells.ort)].filter(Boolean).join(' '), ] .filter(Boolean) .join(', '); await ctx.db.transaction(async (tx) => { const [createdLead] = await tx .insert(lead) .values({ areaId, firma, status: 'cold', score: 0, projectId: input.projectId ?? null, branche: get(row, cells.branche) || null, website: get(row, cells.website) || null, quelle: get(row, cells.quelle) || null, standorte: standorte || null, createdBy: ctx.user.id, }) .returning(); if (!createdLead) throw new Error('Lead konnte nicht angelegt werden.'); summary.created++; const email = get(row, cells.email); const telefon = get(row, cells.telefon); const mobil = get(row, cells.mobil); const position = get(row, cells.position); if (personName || email || telefon || mobil) { const tel = [telefon, mobil ? `Mobil: ${mobil}` : ''].filter(Boolean).join(' · '); await tx.insert(leadContact).values({ leadId: createdLead.id, name: personName || firma, rolle: position || null, email: email || null, telefon: tel || null, isPrimary: true, order: 0, }); summary.contacts++; } const notizen = get(row, cells.notizen); if (notizen) { await tx.insert(leadTouchpoint).values({ leadId: createdLead.id, datum: today, kanal: 'sonstiges', bearbeiterUserId: ctx.user.id, inhalt: notizen.slice(0, 2000), }); summary.touchpoints++; } }); } return summary; }), });