feat(crm): add deal create/update controls with status and payment

This commit is contained in:
Ruslan Bakiev
2026-02-27 09:44:15 +07:00
parent 881a8c6d39
commit 12af9979ab
13 changed files with 907 additions and 94 deletions

View File

@@ -726,12 +726,36 @@ async function getDeals(auth: AuthContext | null) {
take: 500,
});
return dealsRaw.map((d) => ({
return dealsRaw.map((d) => mapDealRecord(d));
}
function mapDealRecord(d: {
id: string;
title: string;
stage: string;
amount: number | null;
paidAmount: number | null;
nextStep: string | null;
summary: string | null;
currentStepId: string | null;
contact: { name: string };
steps: Array<{
id: string;
title: string;
description: string | null;
status: string;
dueAt: Date | null;
order: number;
completedAt: Date | null;
}>;
}) {
return {
id: d.id,
contact: d.contact.name,
title: d.title,
stage: d.stage,
amount: d.amount ? String(d.amount) : "",
amount: d.amount !== null ? String(d.amount) : "",
paidAmount: d.paidAmount !== null ? String(d.paidAmount) : "",
nextStep: d.nextStep ?? "",
summary: d.summary ?? "",
currentStepId: d.currentStepId ?? "",
@@ -744,7 +768,7 @@ async function getDeals(auth: AuthContext | null) {
order: step.order,
completedAt: step.completedAt?.toISOString() ?? "",
})),
}));
};
}
async function getFeed(auth: AuthContext | null) {
@@ -1216,6 +1240,143 @@ async function archiveCalendarEvent(auth: AuthContext | null, input: { id: strin
};
}
function parseOptionalDealMoney(value: unknown, field: "amount" | "paidAmount") {
if (value === null) return null;
const num = Number(value);
if (!Number.isFinite(num)) throw new Error(`${field} is invalid`);
if (!Number.isInteger(num)) throw new Error(`${field} must be an integer`);
if (num < 0) throw new Error(`${field} must be greater than or equal to 0`);
return num;
}
async function updateDeal(auth: AuthContext | null, input: {
id: string;
stage?: string | null;
amount?: number | null;
paidAmount?: number | null;
}) {
const ctx = requireAuth(auth);
const id = String(input?.id ?? "").trim();
if (!id) throw new Error("id is required");
const existing = await prisma.deal.findFirst({
where: { id, teamId: ctx.teamId },
select: { id: true, amount: true, paidAmount: true },
});
if (!existing) throw new Error("deal not found");
const data: {
stage?: string;
amount?: number | null;
paidAmount?: number | null;
} = {};
let nextAmount = existing.amount;
let nextPaidAmount = existing.paidAmount;
if ("stage" in input) {
const stage = String(input.stage ?? "").trim();
if (!stage) throw new Error("stage is required");
data.stage = stage;
}
if ("amount" in input) {
const amount = parseOptionalDealMoney(input.amount, "amount");
data.amount = amount;
nextAmount = amount;
}
if ("paidAmount" in input) {
const paidAmount = parseOptionalDealMoney(input.paidAmount, "paidAmount");
data.paidAmount = paidAmount;
nextPaidAmount = paidAmount;
}
if (nextAmount === null && nextPaidAmount !== null) {
throw new Error("paidAmount requires amount");
}
if (nextAmount !== null && nextPaidAmount !== null && nextPaidAmount > nextAmount) {
throw new Error("paidAmount cannot exceed amount");
}
if (!Object.keys(data).length) {
const current = await prisma.deal.findFirst({
where: { id: existing.id, teamId: ctx.teamId },
include: {
contact: { select: { name: true } },
steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] },
},
});
if (!current) throw new Error("deal not found");
return mapDealRecord(current);
}
const updated = await prisma.deal.update({
where: { id: existing.id },
data,
include: {
contact: { select: { name: true } },
steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] },
},
});
return mapDealRecord(updated);
}
async function createDeal(auth: AuthContext | null, input: {
contactId: string;
title: string;
stage?: string | null;
amount?: number | null;
paidAmount?: number | null;
nextStep?: string | null;
summary?: string | null;
}) {
const ctx = requireAuth(auth);
const contactId = String(input?.contactId ?? "").trim();
const title = String(input?.title ?? "").trim();
const stage = String(input?.stage ?? "").trim() || "Новый";
const nextStep = String(input?.nextStep ?? "").trim() || null;
const summary = String(input?.summary ?? "").trim() || null;
if (!contactId) throw new Error("contactId is required");
if (!title) throw new Error("title is required");
if (!stage) throw new Error("stage is required");
const contact = await prisma.contact.findFirst({
where: { id: contactId, teamId: ctx.teamId },
select: { id: true },
});
if (!contact) throw new Error("contact not found");
const amount = "amount" in input ? parseOptionalDealMoney(input.amount ?? null, "amount") : null;
const paidAmount = "paidAmount" in input ? parseOptionalDealMoney(input.paidAmount ?? null, "paidAmount") : null;
if (amount === null && paidAmount !== null) {
throw new Error("paidAmount requires amount");
}
if (amount !== null && paidAmount !== null && paidAmount > amount) {
throw new Error("paidAmount cannot exceed amount");
}
const created = await prisma.deal.create({
data: {
teamId: ctx.teamId,
contactId: contact.id,
title,
stage,
amount,
paidAmount,
nextStep,
summary,
},
include: {
contact: { select: { name: true } },
steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] },
},
});
return mapDealRecord(created);
}
async function createCommunication(auth: AuthContext | null, input: {
contact: string;
channel?: string;
@@ -1889,6 +2050,8 @@ export const crmGraphqlSchema = buildSchema(`
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
archiveCalendarEvent(input: ArchiveCalendarEventInput!): CalendarEvent!
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
createDeal(input: CreateDealInput!): Deal!
updateDeal(input: UpdateDealInput!): Deal!
createWorkspaceDocument(input: CreateWorkspaceDocumentInput!): WorkspaceDocument!
deleteWorkspaceDocument(id: ID!): MutationWithIdResult!
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
@@ -1951,6 +2114,23 @@ export const crmGraphqlSchema = buildSchema(`
body: String
}
input CreateDealInput {
contactId: ID!
title: String!
stage: String
amount: Int
paidAmount: Int
nextStep: String
summary: String
}
input UpdateDealInput {
id: ID!
stage: String
amount: Int
paidAmount: Int
}
type MePayload {
user: MeUser!
team: MeTeam!
@@ -2089,6 +2269,7 @@ export const crmGraphqlSchema = buildSchema(`
title: String!
stage: String!
amount: String!
paidAmount: String!
nextStep: String!
summary: String!
currentStepId: String!
@@ -2216,6 +2397,26 @@ export const crmGraphqlRoot = {
context: GraphQLContext,
) => createCommunication(context.auth, args.input),
createDeal: async (
args: {
input: {
contactId: string;
title: string;
stage?: string;
amount?: number | null;
paidAmount?: number | null;
nextStep?: string;
summary?: string;
};
},
context: GraphQLContext,
) => createDeal(context.auth, args.input),
updateDeal: async (
args: { input: { id: string; stage?: string; amount?: number | null; paidAmount?: number | null } },
context: GraphQLContext,
) => updateDeal(context.auth, args.input),
createWorkspaceDocument: async (
args: {
input: {