@voyantjs/crm 0.106.0 → 0.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +3 -3
  2. package/dist/booking-extension.d.ts +7 -7
  3. package/dist/booking-extension.d.ts.map +1 -1
  4. package/dist/booking-extension.js +8 -5
  5. package/dist/index.d.ts +5 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -2
  8. package/dist/routes/activities.d.ts +2 -2
  9. package/dist/routes/custom-fields.d.ts +6 -6
  10. package/dist/routes/index.d.ts +518 -51
  11. package/dist/routes/index.d.ts.map +1 -1
  12. package/dist/routes/index.js +2 -2
  13. package/dist/routes/pipelines.d.ts +4 -4
  14. package/dist/routes/quote-versions.d.ts +746 -0
  15. package/dist/routes/quote-versions.d.ts.map +1 -0
  16. package/dist/routes/quote-versions.js +175 -0
  17. package/dist/routes/quotes.d.ts +161 -53
  18. package/dist/routes/quotes.d.ts.map +1 -1
  19. package/dist/routes/quotes.js +29 -11
  20. package/dist/schema-activities.d.ts +6 -6
  21. package/dist/schema-relations.d.ts +19 -18
  22. package/dist/schema-relations.d.ts.map +1 -1
  23. package/dist/schema-relations.js +38 -31
  24. package/dist/schema-sales.d.ts +206 -87
  25. package/dist/schema-sales.d.ts.map +1 -1
  26. package/dist/schema-sales.js +62 -50
  27. package/dist/schema-shared.d.ts +3 -3
  28. package/dist/schema-shared.d.ts.map +1 -1
  29. package/dist/schema-shared.js +5 -16
  30. package/dist/schema-signals.d.ts +1 -1
  31. package/dist/schema-signals.js +1 -1
  32. package/dist/service/accounts-merge.js +10 -10
  33. package/dist/service/accounts-people.js +1 -1
  34. package/dist/service/accounts-shared.d.ts +4 -1
  35. package/dist/service/accounts-shared.d.ts.map +1 -1
  36. package/dist/service/accounts-shared.js +20 -6
  37. package/dist/service/activities.d.ts +6 -6
  38. package/dist/service/custom-fields.d.ts +6 -6
  39. package/dist/service/index.d.ts +338 -139
  40. package/dist/service/index.d.ts.map +1 -1
  41. package/dist/service/index.js +2 -2
  42. package/dist/service/pipelines.d.ts +4 -4
  43. package/dist/service/quote-versions.d.ts +674 -0
  44. package/dist/service/quote-versions.d.ts.map +1 -0
  45. package/dist/service/quote-versions.js +399 -0
  46. package/dist/service/quotes.d.ts +426 -94
  47. package/dist/service/quotes.d.ts.map +1 -1
  48. package/dist/service/quotes.js +63 -22
  49. package/package.json +7 -7
  50. package/dist/routes/opportunities.d.ts +0 -387
  51. package/dist/routes/opportunities.d.ts.map +0 -1
  52. package/dist/routes/opportunities.js +0 -70
  53. package/dist/service/opportunities.d.ts +0 -822
  54. package/dist/service/opportunities.d.ts.map +0 -1
  55. package/dist/service/opportunities.js +0 -117
@@ -0,0 +1,399 @@
1
+ import { and, desc, eq, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
2
+ import { quotes, quoteVersionLines, quoteVersions, } from "../schema.js";
3
+ import { paginate } from "./helpers.js";
4
+ export class QuoteVersionConflictError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "QuoteVersionConflictError";
8
+ }
9
+ }
10
+ function normalizeTimestamp(value) {
11
+ return value == null ? value : new Date(value);
12
+ }
13
+ function normalizeNow(value) {
14
+ return value instanceof Date ? value : value ? new Date(value) : new Date();
15
+ }
16
+ function toDateString(value) {
17
+ return value.toISOString().slice(0, 10);
18
+ }
19
+ export const quoteVersionsService = {
20
+ async listQuoteVersions(db, query) {
21
+ const conditions = [];
22
+ if (query.quoteId)
23
+ conditions.push(eq(quoteVersions.quoteId, query.quoteId));
24
+ if (query.status)
25
+ conditions.push(eq(quoteVersions.status, query.status));
26
+ const where = conditions.length ? and(...conditions) : undefined;
27
+ return paginate(db
28
+ .select()
29
+ .from(quoteVersions)
30
+ .where(where)
31
+ .limit(query.limit)
32
+ .offset(query.offset)
33
+ .orderBy(desc(quoteVersions.updatedAt)), db.select({ count: sql `count(*)::int` }).from(quoteVersions).where(where), query.limit, query.offset);
34
+ },
35
+ async getQuoteVersionById(db, id) {
36
+ const [row] = await db.select().from(quoteVersions).where(eq(quoteVersions.id, id)).limit(1);
37
+ return row ?? null;
38
+ },
39
+ async getQuoteVersionProposal(db, id) {
40
+ const [row] = await db
41
+ .select({
42
+ quoteVersion: quoteVersions,
43
+ quote: quotes,
44
+ })
45
+ .from(quoteVersions)
46
+ .innerJoin(quotes, eq(quoteVersions.quoteId, quotes.id))
47
+ .where(eq(quoteVersions.id, id))
48
+ .limit(1);
49
+ if (!row)
50
+ return null;
51
+ return {
52
+ quote: row.quote,
53
+ quoteVersion: row.quoteVersion,
54
+ lines: await db
55
+ .select()
56
+ .from(quoteVersionLines)
57
+ .where(eq(quoteVersionLines.quoteVersionId, id))
58
+ .orderBy(quoteVersionLines.createdAt),
59
+ };
60
+ },
61
+ async createQuoteVersion(db, data) {
62
+ if (data.status && data.status !== "draft") {
63
+ throw new QuoteVersionConflictError("Quote Versions must be created as draft; use lifecycle routes for status changes");
64
+ }
65
+ const values = {
66
+ ...data,
67
+ sentAt: normalizeTimestamp(data.sentAt),
68
+ viewedAt: normalizeTimestamp(data.viewedAt),
69
+ decidedAt: normalizeTimestamp(data.decidedAt),
70
+ };
71
+ const [row] = await db.insert(quoteVersions).values(values).returning();
72
+ return row;
73
+ },
74
+ async updateQuoteVersion(db, id, data) {
75
+ if (data.status !== undefined) {
76
+ throw new QuoteVersionConflictError("Quote Version status changes must use lifecycle routes");
77
+ }
78
+ const values = {
79
+ ...data,
80
+ sentAt: normalizeTimestamp(data.sentAt),
81
+ viewedAt: normalizeTimestamp(data.viewedAt),
82
+ decidedAt: normalizeTimestamp(data.decidedAt),
83
+ updatedAt: new Date(),
84
+ };
85
+ const [row] = await db
86
+ .update(quoteVersions)
87
+ .set(values)
88
+ .where(and(eq(quoteVersions.id, id), eq(quoteVersions.status, "draft")))
89
+ .returning();
90
+ if (row)
91
+ return row;
92
+ const [existing] = await db
93
+ .select({ status: quoteVersions.status })
94
+ .from(quoteVersions)
95
+ .where(eq(quoteVersions.id, id))
96
+ .limit(1);
97
+ if (!existing)
98
+ return null;
99
+ throw new QuoteVersionConflictError("Quote Versions can only be edited while draft");
100
+ },
101
+ async deleteQuoteVersion(db, id) {
102
+ const [row] = await db
103
+ .delete(quoteVersions)
104
+ .where(and(eq(quoteVersions.id, id), eq(quoteVersions.status, "draft")))
105
+ .returning({ id: quoteVersions.id });
106
+ if (row)
107
+ return row;
108
+ const [existing] = await db
109
+ .select({ status: quoteVersions.status })
110
+ .from(quoteVersions)
111
+ .where(eq(quoteVersions.id, id))
112
+ .limit(1);
113
+ if (!existing)
114
+ return null;
115
+ throw new QuoteVersionConflictError("Quote Versions can only be deleted while draft");
116
+ },
117
+ async applyTripSnapshotToQuoteVersion(db, id, data) {
118
+ return db.transaction(async (tx) => {
119
+ const [quoteVersion] = await tx
120
+ .update(quoteVersions)
121
+ .set({
122
+ tripSnapshotId: data.tripSnapshotId,
123
+ currency: data.currency,
124
+ subtotalAmountCents: data.subtotalAmountCents,
125
+ taxAmountCents: data.taxAmountCents,
126
+ totalAmountCents: data.totalAmountCents,
127
+ updatedAt: new Date(),
128
+ })
129
+ .where(and(eq(quoteVersions.id, id), eq(quoteVersions.status, "draft")))
130
+ .returning();
131
+ if (!quoteVersion) {
132
+ const [existing] = await tx
133
+ .select({ status: quoteVersions.status })
134
+ .from(quoteVersions)
135
+ .where(eq(quoteVersions.id, id))
136
+ .limit(1);
137
+ if (!existing)
138
+ return null;
139
+ throw new QuoteVersionConflictError("Trip snapshots can only be applied to draft Quote Versions");
140
+ }
141
+ await tx.delete(quoteVersionLines).where(eq(quoteVersionLines.quoteVersionId, id));
142
+ const lineValues = data.lines.map(({ componentId: _componentId, ...line }) => ({
143
+ ...line,
144
+ quoteVersionId: id,
145
+ }));
146
+ const lines = lineValues.length > 0
147
+ ? await tx.insert(quoteVersionLines).values(lineValues).returning()
148
+ : [];
149
+ return { quoteVersion, lines };
150
+ });
151
+ },
152
+ async sendQuoteVersion(db, id, data = {}) {
153
+ return db.transaction(async (tx) => {
154
+ const [existing] = await tx
155
+ .select()
156
+ .from(quoteVersions)
157
+ .where(eq(quoteVersions.id, id))
158
+ .limit(1);
159
+ if (!existing)
160
+ return null;
161
+ if (existing.status !== "draft") {
162
+ throw new QuoteVersionConflictError("Quote Versions can only be sent from draft");
163
+ }
164
+ if (!existing.tripSnapshotId) {
165
+ throw new QuoteVersionConflictError("Quote Versions must have a Trip snapshot before they can be sent");
166
+ }
167
+ const now = new Date();
168
+ const validUntil = data.validUntil === undefined ? existing.validUntil : data.validUntil;
169
+ if (validUntil && validUntil < toDateString(now)) {
170
+ throw new QuoteVersionConflictError("Quote Version validUntil must be today or later");
171
+ }
172
+ const [row] = await tx
173
+ .update(quoteVersions)
174
+ .set({
175
+ status: "sent",
176
+ validUntil,
177
+ sentAt: now,
178
+ viewedAt: null,
179
+ decidedAt: null,
180
+ updatedAt: now,
181
+ })
182
+ .where(eq(quoteVersions.id, id))
183
+ .returning();
184
+ return row ?? null;
185
+ });
186
+ },
187
+ async markQuoteVersionViewed(db, id) {
188
+ const now = new Date();
189
+ const [row] = await db
190
+ .update(quoteVersions)
191
+ .set({
192
+ viewedAt: now,
193
+ updatedAt: now,
194
+ })
195
+ .where(and(eq(quoteVersions.id, id), eq(quoteVersions.status, "sent"), isNull(quoteVersions.viewedAt)))
196
+ .returning();
197
+ if (row)
198
+ return row;
199
+ const [existing] = await db
200
+ .select()
201
+ .from(quoteVersions)
202
+ .where(eq(quoteVersions.id, id))
203
+ .limit(1);
204
+ return existing ?? null;
205
+ },
206
+ async acceptQuoteVersion(db, id, _data = {}) {
207
+ return db.transaction(async (tx) => {
208
+ const [current] = await tx
209
+ .select({
210
+ quoteVersion: quoteVersions,
211
+ quote: quotes,
212
+ })
213
+ .from(quoteVersions)
214
+ .innerJoin(quotes, eq(quoteVersions.quoteId, quotes.id))
215
+ .where(eq(quoteVersions.id, id))
216
+ .limit(1);
217
+ if (!current)
218
+ return null;
219
+ if (current.quote.acceptedVersionId &&
220
+ current.quote.acceptedVersionId !== current.quoteVersion.id) {
221
+ throw new QuoteVersionConflictError("Quote already has an accepted Quote Version");
222
+ }
223
+ if (current.quoteVersion.status === "accepted") {
224
+ return {
225
+ quote: current.quote,
226
+ quoteVersion: current.quoteVersion,
227
+ closedQuoteVersions: [],
228
+ };
229
+ }
230
+ if (current.quoteVersion.status !== "sent") {
231
+ throw new QuoteVersionConflictError("Quote Versions can only be accepted after they are sent");
232
+ }
233
+ const now = new Date();
234
+ const [quoteVersion] = await tx
235
+ .update(quoteVersions)
236
+ .set({
237
+ status: "accepted",
238
+ decidedAt: now,
239
+ updatedAt: now,
240
+ })
241
+ .where(and(eq(quoteVersions.id, id), eq(quoteVersions.status, "sent")))
242
+ .returning();
243
+ if (!quoteVersion) {
244
+ throw new QuoteVersionConflictError("Quote Versions can only be accepted after they are sent");
245
+ }
246
+ const declinedVersions = await tx
247
+ .update(quoteVersions)
248
+ .set({
249
+ status: "declined",
250
+ decidedAt: now,
251
+ updatedAt: now,
252
+ })
253
+ .where(and(eq(quoteVersions.quoteId, quoteVersion.quoteId), ne(quoteVersions.id, quoteVersion.id), eq(quoteVersions.status, "sent")))
254
+ .returning();
255
+ const supersededVersions = await tx
256
+ .update(quoteVersions)
257
+ .set({
258
+ status: "superseded",
259
+ decidedAt: now,
260
+ updatedAt: now,
261
+ })
262
+ .where(and(eq(quoteVersions.quoteId, quoteVersion.quoteId), ne(quoteVersions.id, quoteVersion.id), eq(quoteVersions.status, "draft")))
263
+ .returning();
264
+ const [quote] = await tx
265
+ .update(quotes)
266
+ .set({
267
+ status: "won",
268
+ acceptedVersionId: quoteVersion.id,
269
+ valueAmountCents: quoteVersion.totalAmountCents,
270
+ valueCurrency: quoteVersion.currency,
271
+ closedAt: now,
272
+ updatedAt: now,
273
+ })
274
+ .where(and(eq(quotes.id, quoteVersion.quoteId), or(isNull(quotes.acceptedVersionId), eq(quotes.acceptedVersionId, quoteVersion.id))))
275
+ .returning();
276
+ if (!quote) {
277
+ throw new QuoteVersionConflictError("Quote already has an accepted Quote Version");
278
+ }
279
+ return {
280
+ quote,
281
+ quoteVersion,
282
+ closedQuoteVersions: [...declinedVersions, ...supersededVersions],
283
+ };
284
+ });
285
+ },
286
+ async declineQuoteVersion(db, id, _data = {}) {
287
+ const now = new Date();
288
+ const [row] = await db
289
+ .update(quoteVersions)
290
+ .set({
291
+ status: "declined",
292
+ decidedAt: now,
293
+ updatedAt: now,
294
+ })
295
+ .where(and(eq(quoteVersions.id, id), eq(quoteVersions.status, "sent")))
296
+ .returning();
297
+ if (row)
298
+ return row;
299
+ const [existing] = await db
300
+ .select({ status: quoteVersions.status })
301
+ .from(quoteVersions)
302
+ .where(eq(quoteVersions.id, id))
303
+ .limit(1);
304
+ if (!existing)
305
+ return null;
306
+ throw new QuoteVersionConflictError("Quote Versions can only be declined after they are sent");
307
+ },
308
+ async expireQuoteVersionIfPastValidUntil(db, id, nowValue) {
309
+ const now = normalizeNow(nowValue);
310
+ const [row] = await db
311
+ .update(quoteVersions)
312
+ .set({
313
+ status: "expired",
314
+ decidedAt: now,
315
+ updatedAt: now,
316
+ })
317
+ .where(and(eq(quoteVersions.id, id), eq(quoteVersions.status, "sent"), isNotNull(quoteVersions.validUntil), lt(quoteVersions.validUntil, toDateString(now))))
318
+ .returning();
319
+ return row ?? null;
320
+ },
321
+ async expireQuoteVersions(db, data = {}) {
322
+ const now = normalizeNow(data.now);
323
+ const rows = await db
324
+ .update(quoteVersions)
325
+ .set({
326
+ status: "expired",
327
+ decidedAt: now,
328
+ updatedAt: now,
329
+ })
330
+ .where(and(eq(quoteVersions.status, "sent"), isNotNull(quoteVersions.validUntil), lt(quoteVersions.validUntil, toDateString(now))))
331
+ .returning();
332
+ return rows;
333
+ },
334
+ listQuoteVersionLines(db, quoteVersionId) {
335
+ return db
336
+ .select()
337
+ .from(quoteVersionLines)
338
+ .where(eq(quoteVersionLines.quoteVersionId, quoteVersionId))
339
+ .orderBy(quoteVersionLines.createdAt);
340
+ },
341
+ async createQuoteVersionLine(db, quoteVersionId, data) {
342
+ const [quoteVersion] = await db
343
+ .select({ status: quoteVersions.status })
344
+ .from(quoteVersions)
345
+ .where(eq(quoteVersions.id, quoteVersionId))
346
+ .limit(1);
347
+ if (!quoteVersion)
348
+ return null;
349
+ if (quoteVersion.status !== "draft") {
350
+ throw new QuoteVersionConflictError("Quote Version lines can only be edited while draft");
351
+ }
352
+ const [row] = await db
353
+ .insert(quoteVersionLines)
354
+ .values({ ...data, quoteVersionId })
355
+ .returning();
356
+ return row;
357
+ },
358
+ async updateQuoteVersionLine(db, id, data) {
359
+ const [existing] = await db
360
+ .select({
361
+ status: quoteVersions.status,
362
+ })
363
+ .from(quoteVersionLines)
364
+ .innerJoin(quoteVersions, eq(quoteVersionLines.quoteVersionId, quoteVersions.id))
365
+ .where(eq(quoteVersionLines.id, id))
366
+ .limit(1);
367
+ if (!existing)
368
+ return null;
369
+ if (existing.status !== "draft") {
370
+ throw new QuoteVersionConflictError("Quote Version lines can only be edited while draft");
371
+ }
372
+ const [row] = await db
373
+ .update(quoteVersionLines)
374
+ .set({ ...data, updatedAt: new Date() })
375
+ .where(eq(quoteVersionLines.id, id))
376
+ .returning();
377
+ return row ?? null;
378
+ },
379
+ async deleteQuoteVersionLine(db, id) {
380
+ const [existing] = await db
381
+ .select({
382
+ status: quoteVersions.status,
383
+ })
384
+ .from(quoteVersionLines)
385
+ .innerJoin(quoteVersions, eq(quoteVersionLines.quoteVersionId, quoteVersions.id))
386
+ .where(eq(quoteVersionLines.id, id))
387
+ .limit(1);
388
+ if (!existing)
389
+ return null;
390
+ if (existing.status !== "draft") {
391
+ throw new QuoteVersionConflictError("Quote Version lines can only be edited while draft");
392
+ }
393
+ const [row] = await db
394
+ .delete(quoteVersionLines)
395
+ .where(eq(quoteVersionLines.id, id))
396
+ .returning({ id: quoteVersionLines.id });
397
+ return row ?? null;
398
+ },
399
+ };