templates2.Admin.Next/lib/server/crm.ts
w4c-templates c64f7ffa9e
Some checks failed
CI / quality (push) Has been cancelled
chore(template): publish Admin.Next
2026-10-11 23:25:34 +03:00

179 lines
7.7 KiB
TypeScript

import 'server-only';
import { z } from 'zod';
import { db } from './db';
import { recordAudit } from './audit';
import { canDelete, canImport, canWrite } from '@/lib/rbac';
import { parseCsv, toCsv, validateCustomerRows } from '@/lib/csv';
export const customerSchema = z.object({
name: z.string().min(1).max(160),
email: z.string().email().max(200),
company: z.string().max(160).optional().or(z.literal('')),
status: z.enum(['active', 'lead', 'churned']).optional(),
});
export const dealSchema = z.object({
title: z.string().min(1).max(160),
customerId: z.string().optional().nullable(),
value: z.number().int().min(0),
stage: z.enum(['prospecting', 'proposal', 'won', 'lost']).optional(),
owner: z.string().max(120).optional().or(z.literal('')),
});
type Actor = { id: string; role: string };
export async function listCustomers({ q, status, sort, page = 1, pageSize = 10 }: { q?: string; status?: string; sort?: string; page?: number; pageSize?: number }) {
const where = {
...(status ? { status } : {}),
...(q ? { OR: [{ name: { contains: q } }, { email: { contains: q } }, { company: { contains: q } }] } : {}),
};
const orderBy = sort === 'name' ? { name: 'asc' as const } : sort === 'company' ? { company: 'asc' as const } : { createdAt: 'desc' as const };
const [items, total] = await Promise.all([
db.customer.findMany({ where, orderBy, skip: (page - 1) * pageSize, take: pageSize }),
db.customer.count({ where }),
]);
return { items, total, page, pageSize, pages: Math.max(1, Math.ceil(total / pageSize)) };
}
export function getCustomer(id: string) {
return db.customer.findUnique({ where: { id }, include: { deals: true } });
}
export async function createCustomer(input: z.infer<typeof customerSchema>, actor: Actor) {
if (!canWrite(actor.role)) return { ok: false as const, error: 'Forbidden' };
try {
const customer = await db.customer.create({
data: { name: input.name, email: input.email.toLowerCase(), company: input.company || null, status: input.status ?? 'active' },
});
await recordAudit({ actorId: actor.id, action: 'customer.create', target: customer.email });
return { ok: true as const, customer };
} catch {
return { ok: false as const, error: 'A customer with that email already exists.' };
}
}
export async function updateCustomer(id: string, input: z.infer<typeof customerSchema>, actor: Actor) {
if (!canWrite(actor.role)) return { ok: false as const, error: 'Forbidden' };
const customer = await db.customer.update({
where: { id },
data: { name: input.name, email: input.email.toLowerCase(), company: input.company || null, status: input.status ?? 'active' },
});
await recordAudit({ actorId: actor.id, action: 'customer.update', target: customer.email });
return { ok: true as const, customer };
}
export async function deleteCustomer(id: string, actor: Actor) {
if (!canDelete(actor.role)) return { ok: false as const, error: 'Only admins can delete records.' };
const customer = await db.customer.delete({ where: { id } });
await recordAudit({ actorId: actor.id, action: 'customer.delete', target: customer.email });
return { ok: true as const };
}
export async function listDeals({ stage, page = 1, pageSize = 10 }: { stage?: string; page?: number; pageSize?: number }) {
const where = stage ? { stage } : {};
const [items, total] = await Promise.all([
db.deal.findMany({ where, include: { customer: true }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }),
db.deal.count({ where }),
]);
return { items, total, page, pageSize, pages: Math.max(1, Math.ceil(total / pageSize)) };
}
export async function createDeal(input: z.infer<typeof dealSchema>, actor: Actor) {
if (!canWrite(actor.role)) return { ok: false as const, error: 'Forbidden' };
const deal = await db.deal.create({
data: {
title: input.title,
customerId: input.customerId || null,
value: input.value,
stage: input.stage ?? 'prospecting',
owner: input.owner || null,
},
});
await recordAudit({ actorId: actor.id, action: 'deal.create', target: deal.title });
return { ok: true as const, deal };
}
export async function updateDeal(id: string, input: z.infer<typeof dealSchema>, actor: Actor) {
if (!canWrite(actor.role)) return { ok: false as const, error: 'Forbidden' };
const deal = await db.deal.update({
where: { id },
data: {
title: input.title,
customerId: input.customerId || null,
value: input.value,
stage: input.stage ?? 'prospecting',
owner: input.owner || null,
},
});
await recordAudit({ actorId: actor.id, action: 'deal.update', target: deal.title, meta: { stage: deal.stage } });
return { ok: true as const, deal };
}
export async function deleteDeal(id: string, actor: Actor) {
if (!canDelete(actor.role)) return { ok: false as const, error: 'Only admins can delete records.' };
const deal = await db.deal.delete({ where: { id } });
await recordAudit({ actorId: actor.id, action: 'deal.delete', target: deal.title });
return { ok: true as const };
}
export async function importCustomers(csvText: string, commit: boolean, actor: Actor) {
if (!canImport(actor.role)) return { ok: false as const, error: 'Forbidden' };
const { rows } = parseCsv(csvText);
const parsed = validateCustomerRows(rows);
if (!commit) {
return { ok: true as const, preview: true, total: parsed.total, errors: parsed.errors, duplicatesInFile: parsed.duplicatesInFile, valid: parsed.valid.length, sample: parsed.valid.slice(0, 5) };
}
let imported = 0;
let updated = 0;
for (const row of parsed.valid) {
const existing = await db.customer.findUnique({ where: { email: row.email } });
if (existing) {
await db.customer.update({ where: { email: row.email }, data: { name: row.name, company: row.company || null, status: row.status } });
updated++;
} else {
await db.customer.create({ data: { name: row.name, email: row.email, company: row.company || null, status: row.status } });
imported++;
}
}
await recordAudit({ actorId: actor.id, action: 'customer.import', target: `${imported} created, ${updated} updated`, meta: { errors: parsed.errors.length } });
return { ok: true as const, preview: false, imported, updated, errors: parsed.errors, duplicatesInFile: parsed.duplicatesInFile };
}
export async function exportCustomersCsv() {
const rows = await db.customer.findMany({ orderBy: { createdAt: 'desc' } });
return toCsv(
rows.map((c) => ({ id: c.id, name: c.name, email: c.email, company: c.company ?? '', status: c.status, createdAt: c.createdAt.toISOString() })),
['id', 'name', 'email', 'company', 'status', 'createdAt'],
);
}
export async function overview() {
const [customers, leads, deals, won, recent] = await Promise.all([
db.customer.count(),
db.customer.count({ where: { status: 'lead' } }),
db.deal.count(),
db.deal.aggregate({ where: { stage: 'won' }, _sum: { value: true } }),
db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 6, include: { actor: true } }),
]);
const byStage = await Promise.all(
['prospecting', 'proposal', 'won', 'lost'].map(async (stage) => ({ stage, count: await db.deal.count({ where: { stage } }) })),
);
return {
customers,
leads,
deals,
wonValue: won._sum.value ?? 0,
byStage,
recent: recent.map((r) => ({ id: r.id, action: r.action, target: r.target, actor: r.actor?.email ?? 'system', createdAt: r.createdAt })),
};
}
export function listAudit({ action, take = 100 }: { action?: string; take?: number } = {}) {
return db.auditLog.findMany({
where: action ? { action: { contains: action } } : {},
orderBy: { createdAt: 'desc' },
take,
include: { actor: true },
});
}