"use server"
import { daysAhead, db, REFERENCE_DATE, type Deal, type Lead, type Result } from "@/lib/sample-data"
/**
* The CRM dashboard's shared server actions: what more than one of its pages
* does to the same rows. The overview's leads table and the leads page both
* convert a lead, so the conversion lives here, once, and both import it —
* one action name, one behaviour, everywhere the dashboard offers it.
*
* Installed as `@lib/dashboards/crm/actions.ts` by the `actions-crm` item,
* which every page that imports it lists in its registry dependencies, so
* installing the dashboard installs it too. Every answer is a `Result`, and
* every name in it is read off the rows the action looked up, never off the
* page that asked.
*/
/** Where a deal converted from a lead says it came from. */
const DEAL_SOURCE: Record<Lead["source"], Deal["source"]> = {
website: "inbound",
ads: "inbound",
referral: "referral",
social: "outbound",
event: "event",
}
/** Midnight UTC on the day `date` falls on — a forecast names a day, not an instant. */
function utcDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}
export type ConvertedLead = {
id: string
name: string
/** The lead's status after the conversion: won. */
status: Lead["status"]
dealId: string
dealName: string
/** The deal's value, in minor units — the lead's own. */
amountCents: number
/** The rep who owns the deal — the lead's own. */
ownerName: string
}
/**
* Turns a lead into a deal on the pipeline. The lead is marked won — it has
* done its job — and a qualified deal opens under its company, for its value,
* owned by its rep, forecast a month out. The lead is written first: a deal
* that failed to open leaves a won lead with no deal, which is visible, where
* the other order would leave a deal on the pipeline for a lead still open,
* which is not. A lead that is already won or lost is refused: there is
* nothing left to convert.
*/
export async function convertLead(id: string): Promise<Result<ConvertedLead>> {
const lead = await db.leads.get(id)
if (!lead) return { ok: false, error: { code: "not_found", message: `No lead with id "${id}".` } }
if (lead.status === "won" || lead.status === "lost") {
return { ok: false, error: { code: "conflict", message: `${lead.name} is already ${lead.status}.` } }
}
const updated = await db.leads.update(id, { status: "won", nextActionAt: null })
if (!updated.ok) return updated
const created = await db.deals.create({
name: `${lead.company} · New business`,
company: lead.company,
stage: "qualified",
amountCents: lead.valueCents,
currency: "USD",
owner: lead.ownerId,
// A qualified deal, as the pipeline forecasts one.
probability: 30,
source: DEAL_SOURCE[lead.source],
createdAt: REFERENCE_DATE,
lastActivityAt: REFERENCE_DATE,
expectedCloseAt: utcDay(daysAhead(30)),
})
if (!created.ok) return created
const owner = await db.members.get(lead.ownerId)
return {
ok: true,
data: {
id,
name: lead.name,
status: updated.data.status,
dealId: created.data.id,
dealName: created.data.name,
amountCents: created.data.amountCents,
ownerName: owner?.name ?? "its rep",
},
}
}