@fonderie/customers 1.0.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.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/index.cjs +2220 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +237 -0
- package/dist/index.d.ts +237 -0
- package/dist/index.js +2178 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/index.js +7 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/sql/001_customers.sql +111 -0
- package/dist/migrations/sql/002_customers_sex.sql +2 -0
- package/dist/migrations/sql/003_customer_sequences.sql +6 -0
- package/dist/migrations/sql/004_customer_sequences_per_workspace.sql +1 -0
- package/dist/migrations/sql/005_drop_job_title.sql +1 -0
- package/dist/migrations/sql/006_unique_email_phone.sql +23 -0
- package/dist/migrations/sql/007_rename_archived_to_blacklisted.sql +13 -0
- package/dist/migrations/sql/008_blacklist_reason.sql +2 -0
- package/dist/migrations/sql/009_customer_relationships.sql +25 -0
- package/dist/migrations/sql/010_customer_labels.sql +75 -0
- package/dist/migrations/sql/011_label_fk_set_null.sql +17 -0
- package/dist/types.cjs +19 -0
- package/dist/types.cjs.map +1 -0
- package/dist/types.d.cts +124 -0
- package/dist/types.d.ts +124 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +85 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2178 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
var EVENT_KEYS = {
|
|
3
|
+
customerCreated: "fonderie.customer.created",
|
|
4
|
+
customerUpdated: "fonderie.customer.updated",
|
|
5
|
+
customerDeleted: "fonderie.customer.deleted",
|
|
6
|
+
customerBlacklisted: "fonderie.customer.blacklisted",
|
|
7
|
+
customerUnblacklisted: "fonderie.customer.unblacklisted"
|
|
8
|
+
};
|
|
9
|
+
var DEFAULT_REFERENCE_CODE_PREFIX = "CLT";
|
|
10
|
+
|
|
11
|
+
// src/dtos/customer.ts
|
|
12
|
+
import { arrayOrEmpty, booleanOrFalse, dateOrEmpty, stringOrEmpty } from "@fonderie/core";
|
|
13
|
+
var VALID_SEX = ["UNKNOWN", "MALE", "FEMALE"];
|
|
14
|
+
function toCustomerDTO(c) {
|
|
15
|
+
return {
|
|
16
|
+
id: stringOrEmpty(c.id),
|
|
17
|
+
type: stringOrEmpty(c.type),
|
|
18
|
+
sex: VALID_SEX.includes(c.sex) ? c.sex : "UNKNOWN",
|
|
19
|
+
firstName: stringOrEmpty(c.firstName),
|
|
20
|
+
lastName: stringOrEmpty(c.lastName),
|
|
21
|
+
companyName: stringOrEmpty(c.companyName),
|
|
22
|
+
avatarUrl: stringOrEmpty(c.avatarUrl),
|
|
23
|
+
locale: stringOrEmpty(c.locale),
|
|
24
|
+
referenceCode: stringOrEmpty(c.referenceCode),
|
|
25
|
+
blacklisted: { status: booleanOrFalse(c.isBlacklisted), reason: c.blacklistReason ?? null },
|
|
26
|
+
createdBy: stringOrEmpty(c.createdBy),
|
|
27
|
+
createdAt: dateOrEmpty(c.createdAt),
|
|
28
|
+
updatedAt: dateOrEmpty(c.updatedAt)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function toCustomerRelationshipDTO(r) {
|
|
32
|
+
return {
|
|
33
|
+
id: stringOrEmpty(r.id),
|
|
34
|
+
relatedId: stringOrEmpty(r.relatedId),
|
|
35
|
+
relationship: stringOrEmpty(r.relationship),
|
|
36
|
+
isPrimary: booleanOrFalse(r.isPrimary),
|
|
37
|
+
createdAt: dateOrEmpty(r.createdAt)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function toCustomerShallowDTO(c) {
|
|
41
|
+
return {
|
|
42
|
+
...toCustomerDTO(c),
|
|
43
|
+
emails: arrayOrEmpty(c.emails).map(toCustomerEmailDTO),
|
|
44
|
+
phones: arrayOrEmpty(c.phones).map(toCustomerPhoneDTO),
|
|
45
|
+
addresses: arrayOrEmpty(c.addresses).map(toCustomerAddressDTO),
|
|
46
|
+
notes: arrayOrEmpty(c.notes).map(toCustomerNoteDTO),
|
|
47
|
+
tags: arrayOrEmpty(c.tags)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function toCustomerRelationshipExpandedDTO(r) {
|
|
51
|
+
const { id: customerId, ...customerFields } = toCustomerShallowDTO(r.customer);
|
|
52
|
+
return {
|
|
53
|
+
id: stringOrEmpty(r.id),
|
|
54
|
+
customerId,
|
|
55
|
+
relationship: stringOrEmpty(r.relationship),
|
|
56
|
+
isPrimary: booleanOrFalse(r.isPrimary),
|
|
57
|
+
...customerFields
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function toCustomerDetailDTO(c) {
|
|
61
|
+
return {
|
|
62
|
+
...toCustomerDTO(c),
|
|
63
|
+
emails: arrayOrEmpty(c.emails).map(toCustomerEmailDTO),
|
|
64
|
+
phones: arrayOrEmpty(c.phones).map(toCustomerPhoneDTO),
|
|
65
|
+
addresses: arrayOrEmpty(c.addresses).map(toCustomerAddressDTO),
|
|
66
|
+
notes: arrayOrEmpty(c.notes).map(toCustomerNoteDTO),
|
|
67
|
+
relationships: arrayOrEmpty(c.relationships).map(toCustomerRelationshipExpandedDTO),
|
|
68
|
+
tags: arrayOrEmpty(c.tags)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function toCustomerRelationshipExpandedD2DTO(r) {
|
|
72
|
+
const { id: customerId, ...customerFields } = toCustomerShallowDTO(r.customer);
|
|
73
|
+
return {
|
|
74
|
+
id: stringOrEmpty(r.id),
|
|
75
|
+
customerId,
|
|
76
|
+
relationship: stringOrEmpty(r.relationship),
|
|
77
|
+
isPrimary: booleanOrFalse(r.isPrimary),
|
|
78
|
+
...customerFields,
|
|
79
|
+
relationships: arrayOrEmpty(r.customer.relationships).map(toCustomerRelationshipExpandedDTO)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function toCustomerDetailD2DTO(c) {
|
|
83
|
+
return {
|
|
84
|
+
...toCustomerDTO(c),
|
|
85
|
+
emails: arrayOrEmpty(c.emails).map(toCustomerEmailDTO),
|
|
86
|
+
phones: arrayOrEmpty(c.phones).map(toCustomerPhoneDTO),
|
|
87
|
+
addresses: arrayOrEmpty(c.addresses).map(toCustomerAddressDTO),
|
|
88
|
+
notes: arrayOrEmpty(c.notes).map(toCustomerNoteDTO),
|
|
89
|
+
relationships: arrayOrEmpty(c.relationships).map(toCustomerRelationshipExpandedD2DTO),
|
|
90
|
+
tags: arrayOrEmpty(c.tags)
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function toAddressDTO(a) {
|
|
94
|
+
return {
|
|
95
|
+
countryIso: stringOrEmpty(a.countryIso),
|
|
96
|
+
subdivision1Iso: stringOrEmpty(a.subdivision1Iso),
|
|
97
|
+
subdivision2Iso: stringOrEmpty(a.subdivision2Iso),
|
|
98
|
+
zipPostalCode: stringOrEmpty(a.zipPostalCode),
|
|
99
|
+
unit: stringOrEmpty(a.unit),
|
|
100
|
+
line1: stringOrEmpty(a.line1),
|
|
101
|
+
line2: stringOrEmpty(a.line2)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function toCustomerEmailDTO(e) {
|
|
105
|
+
return {
|
|
106
|
+
id: stringOrEmpty(e.id),
|
|
107
|
+
email: stringOrEmpty(e.email),
|
|
108
|
+
label: stringOrEmpty(e.label),
|
|
109
|
+
isPrimary: booleanOrFalse(e.isPrimary),
|
|
110
|
+
createdAt: dateOrEmpty(e.createdAt)
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function toCustomerPhoneDTO(p) {
|
|
114
|
+
return {
|
|
115
|
+
id: stringOrEmpty(p.id),
|
|
116
|
+
phone: stringOrEmpty(p.phone),
|
|
117
|
+
label: stringOrEmpty(p.label),
|
|
118
|
+
isPrimary: booleanOrFalse(p.isPrimary),
|
|
119
|
+
createdAt: dateOrEmpty(p.createdAt)
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function toCustomerAddressDTO(ca) {
|
|
123
|
+
return {
|
|
124
|
+
id: stringOrEmpty(ca.addrId),
|
|
125
|
+
label: stringOrEmpty(ca.label),
|
|
126
|
+
isPrimary: booleanOrFalse(ca.isPrimary),
|
|
127
|
+
address: toAddressDTO(ca.address)
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function toCustomerNoteDTO(n) {
|
|
131
|
+
return {
|
|
132
|
+
id: stringOrEmpty(n.id),
|
|
133
|
+
authorId: stringOrEmpty(n.authorId),
|
|
134
|
+
body: stringOrEmpty(n.body),
|
|
135
|
+
createdAt: dateOrEmpty(n.createdAt),
|
|
136
|
+
updatedAt: dateOrEmpty(n.updatedAt)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function toCustomerTagDTO(t) {
|
|
140
|
+
return {
|
|
141
|
+
tag: stringOrEmpty(t.tag)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/models/customer.model.ts
|
|
146
|
+
function groupByCustomer(rows) {
|
|
147
|
+
return rows.reduce((m, r) => {
|
|
148
|
+
if (!m.has(r.customerId)) m.set(r.customerId, []);
|
|
149
|
+
m.get(r.customerId).push(r);
|
|
150
|
+
return m;
|
|
151
|
+
}, /* @__PURE__ */ new Map());
|
|
152
|
+
}
|
|
153
|
+
var SELECT_CUSTOMER = `
|
|
154
|
+
id,
|
|
155
|
+
workspace_id AS "workspaceId",
|
|
156
|
+
type,
|
|
157
|
+
sex,
|
|
158
|
+
first_name AS "firstName",
|
|
159
|
+
last_name AS "lastName",
|
|
160
|
+
company_name AS "companyName",
|
|
161
|
+
avatar_url AS "avatarUrl",
|
|
162
|
+
locale,
|
|
163
|
+
reference_code AS "referenceCode",
|
|
164
|
+
is_blacklisted AS "isBlacklisted",
|
|
165
|
+
blacklist_reason AS "blacklistReason",
|
|
166
|
+
created_by AS "createdBy",
|
|
167
|
+
created_at AS "createdAt",
|
|
168
|
+
updated_at AS "updatedAt"
|
|
169
|
+
`;
|
|
170
|
+
var CustomerModel = class {
|
|
171
|
+
constructor(store) {
|
|
172
|
+
this.store = store;
|
|
173
|
+
}
|
|
174
|
+
store;
|
|
175
|
+
async allocateCode(workspaceId, prefix) {
|
|
176
|
+
const [row] = await this.store.query(
|
|
177
|
+
`INSERT INTO fonderie_customer_sequences (workspace_id, prefix, next_val)
|
|
178
|
+
VALUES ($1, $2, 1)
|
|
179
|
+
ON CONFLICT (workspace_id, prefix) DO UPDATE
|
|
180
|
+
SET next_val = fonderie_customer_sequences.next_val + 1
|
|
181
|
+
RETURNING next_val AS "nextVal"`,
|
|
182
|
+
[workspaceId, prefix]
|
|
183
|
+
);
|
|
184
|
+
return `${prefix}-${String(row.nextVal).padStart(4, "0")}`;
|
|
185
|
+
}
|
|
186
|
+
async list(opts) {
|
|
187
|
+
const conditions = ["workspace_id = $1"];
|
|
188
|
+
const params = [opts.workspaceId];
|
|
189
|
+
if (opts.blacklisted !== void 0) {
|
|
190
|
+
params.push(opts.blacklisted);
|
|
191
|
+
conditions.push(`is_blacklisted = $${params.length}`);
|
|
192
|
+
}
|
|
193
|
+
if (opts.search) {
|
|
194
|
+
params.push(`%${opts.search}%`);
|
|
195
|
+
const idx = params.length;
|
|
196
|
+
conditions.push(
|
|
197
|
+
`(first_name ILIKE $${idx} OR last_name ILIKE $${idx} OR company_name ILIKE $${idx} OR reference_code ILIKE $${idx})`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const limit = Math.min(opts.limit ?? 50, 200);
|
|
201
|
+
const offset = opts.offset ?? 0;
|
|
202
|
+
params.push(limit, offset);
|
|
203
|
+
const limitIdx = params.length - 1;
|
|
204
|
+
const offsetIdx = params.length;
|
|
205
|
+
return this.store.query(
|
|
206
|
+
`SELECT ${SELECT_CUSTOMER}
|
|
207
|
+
FROM fonderie_customers
|
|
208
|
+
WHERE ${conditions.join(" AND ")}
|
|
209
|
+
ORDER BY created_at DESC
|
|
210
|
+
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
|
211
|
+
params
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
async findById(id, workspaceId) {
|
|
215
|
+
const [row] = await this.store.query(
|
|
216
|
+
`SELECT ${SELECT_CUSTOMER}
|
|
217
|
+
FROM fonderie_customers
|
|
218
|
+
WHERE id = $1 AND workspace_id = $2`,
|
|
219
|
+
[id, workspaceId]
|
|
220
|
+
);
|
|
221
|
+
return row ?? null;
|
|
222
|
+
}
|
|
223
|
+
async findDetail(id, workspaceId, depth = 1) {
|
|
224
|
+
const [row] = await this.store.query(
|
|
225
|
+
`SELECT ${SELECT_CUSTOMER}
|
|
226
|
+
FROM fonderie_customers
|
|
227
|
+
WHERE id = $1 AND workspace_id = $2`,
|
|
228
|
+
[id, workspaceId]
|
|
229
|
+
);
|
|
230
|
+
if (!row) return null;
|
|
231
|
+
const [emailRows, phoneRows, addressRows, noteRows, relationshipRows, tagRows] = await Promise.all([
|
|
232
|
+
this.store.query(
|
|
233
|
+
`SELECT id,
|
|
234
|
+
customer_id AS "customerId",
|
|
235
|
+
email,
|
|
236
|
+
label_id AS "labelId",
|
|
237
|
+
(SELECT value FROM fonderie_customer_labels WHERE id = label_id) AS label,
|
|
238
|
+
is_primary AS "isPrimary",
|
|
239
|
+
created_at AS "createdAt"
|
|
240
|
+
FROM fonderie_customer_emails
|
|
241
|
+
WHERE customer_id = $1
|
|
242
|
+
ORDER BY is_primary DESC, created_at ASC`,
|
|
243
|
+
[id]
|
|
244
|
+
),
|
|
245
|
+
this.store.query(
|
|
246
|
+
`SELECT id,
|
|
247
|
+
customer_id AS "customerId",
|
|
248
|
+
phone,
|
|
249
|
+
label_id AS "labelId",
|
|
250
|
+
(SELECT value FROM fonderie_customer_labels WHERE id = label_id) AS label,
|
|
251
|
+
is_primary AS "isPrimary",
|
|
252
|
+
created_at AS "createdAt"
|
|
253
|
+
FROM fonderie_customer_phones
|
|
254
|
+
WHERE customer_id = $1
|
|
255
|
+
ORDER BY is_primary DESC, created_at ASC`,
|
|
256
|
+
[id]
|
|
257
|
+
),
|
|
258
|
+
this.store.query(
|
|
259
|
+
`SELECT ca.addr_id AS "addrId",
|
|
260
|
+
ca.customer_id AS "customerId",
|
|
261
|
+
ca.label_id AS "labelId",
|
|
262
|
+
(SELECT value FROM fonderie_customer_labels WHERE id = ca.label_id) AS label,
|
|
263
|
+
ca.is_primary AS "isPrimary",
|
|
264
|
+
jsonb_build_object(
|
|
265
|
+
'id', a.id,
|
|
266
|
+
'countryIso', a.country_iso,
|
|
267
|
+
'subdivision1Iso', a.subdivision1_iso,
|
|
268
|
+
'subdivision2Iso', a.subdivision2_iso,
|
|
269
|
+
'zipPostalCode', a.zip_postal_code,
|
|
270
|
+
'unit', a.unit,
|
|
271
|
+
'line1', a.line1,
|
|
272
|
+
'line2', a.line2
|
|
273
|
+
) AS address
|
|
274
|
+
FROM fonderie_customer_addresses ca
|
|
275
|
+
JOIN fonderie_addresses a ON a.id = ca.addr_id
|
|
276
|
+
WHERE ca.customer_id = $1
|
|
277
|
+
ORDER BY ca.is_primary DESC`,
|
|
278
|
+
[id]
|
|
279
|
+
),
|
|
280
|
+
this.store.query(
|
|
281
|
+
`SELECT id,
|
|
282
|
+
customer_id AS "customerId",
|
|
283
|
+
author_id AS "authorId",
|
|
284
|
+
body,
|
|
285
|
+
created_at AS "createdAt",
|
|
286
|
+
updated_at AS "updatedAt"
|
|
287
|
+
FROM fonderie_customer_notes
|
|
288
|
+
WHERE customer_id = $1
|
|
289
|
+
ORDER BY created_at DESC`,
|
|
290
|
+
[id]
|
|
291
|
+
),
|
|
292
|
+
this.store.query(
|
|
293
|
+
`SELECT id,
|
|
294
|
+
workspace_id AS "workspaceId",
|
|
295
|
+
customer_id AS "customerId",
|
|
296
|
+
related_id AS "relatedId",
|
|
297
|
+
relationship,
|
|
298
|
+
is_primary AS "isPrimary",
|
|
299
|
+
created_at AS "createdAt"
|
|
300
|
+
FROM fonderie_customer_relationships
|
|
301
|
+
WHERE customer_id = $1
|
|
302
|
+
ORDER BY is_primary DESC, created_at ASC`,
|
|
303
|
+
[id]
|
|
304
|
+
),
|
|
305
|
+
this.store.query(
|
|
306
|
+
`SELECT tag FROM fonderie_customer_tags WHERE customer_id = $1 ORDER BY tag ASC`,
|
|
307
|
+
[id]
|
|
308
|
+
)
|
|
309
|
+
]);
|
|
310
|
+
const relatedIds = relationshipRows.map((r) => r.relatedId);
|
|
311
|
+
let expandedRelationships = [];
|
|
312
|
+
if (relatedIds.length > 0) {
|
|
313
|
+
const [relCustomers, relEmails, relPhones, relAddresses, relNotes, relTags] = await Promise.all([
|
|
314
|
+
this.store.query(
|
|
315
|
+
`SELECT ${SELECT_CUSTOMER} FROM fonderie_customers WHERE id = ANY($1::uuid[]) AND workspace_id = $2`,
|
|
316
|
+
[relatedIds, workspaceId]
|
|
317
|
+
),
|
|
318
|
+
this.store.query(
|
|
319
|
+
`SELECT id, customer_id AS "customerId", email, label_id AS "labelId",
|
|
320
|
+
(SELECT value FROM fonderie_customer_labels WHERE id = label_id) AS label,
|
|
321
|
+
is_primary AS "isPrimary", created_at AS "createdAt"
|
|
322
|
+
FROM fonderie_customer_emails WHERE customer_id = ANY($1::uuid[]) ORDER BY is_primary DESC, created_at ASC`,
|
|
323
|
+
[relatedIds]
|
|
324
|
+
),
|
|
325
|
+
this.store.query(
|
|
326
|
+
`SELECT id, customer_id AS "customerId", phone, label_id AS "labelId",
|
|
327
|
+
(SELECT value FROM fonderie_customer_labels WHERE id = label_id) AS label,
|
|
328
|
+
is_primary AS "isPrimary", created_at AS "createdAt"
|
|
329
|
+
FROM fonderie_customer_phones WHERE customer_id = ANY($1::uuid[]) ORDER BY is_primary DESC, created_at ASC`,
|
|
330
|
+
[relatedIds]
|
|
331
|
+
),
|
|
332
|
+
this.store.query(
|
|
333
|
+
`SELECT ca.addr_id AS "addrId",
|
|
334
|
+
ca.customer_id AS "customerId",
|
|
335
|
+
ca.label_id AS "labelId",
|
|
336
|
+
(SELECT value FROM fonderie_customer_labels WHERE id = ca.label_id) AS label,
|
|
337
|
+
ca.is_primary AS "isPrimary",
|
|
338
|
+
jsonb_build_object(
|
|
339
|
+
'id', a.id,
|
|
340
|
+
'countryIso', a.country_iso,
|
|
341
|
+
'subdivision1Iso', a.subdivision1_iso,
|
|
342
|
+
'subdivision2Iso', a.subdivision2_iso,
|
|
343
|
+
'zipPostalCode', a.zip_postal_code,
|
|
344
|
+
'unit', a.unit,
|
|
345
|
+
'line1', a.line1,
|
|
346
|
+
'line2', a.line2
|
|
347
|
+
) AS address
|
|
348
|
+
FROM fonderie_customer_addresses ca
|
|
349
|
+
JOIN fonderie_addresses a ON a.id = ca.addr_id
|
|
350
|
+
WHERE ca.customer_id = ANY($1::uuid[])
|
|
351
|
+
ORDER BY ca.is_primary DESC`,
|
|
352
|
+
[relatedIds]
|
|
353
|
+
),
|
|
354
|
+
this.store.query(
|
|
355
|
+
`SELECT id, customer_id AS "customerId", author_id AS "authorId", body, created_at AS "createdAt", updated_at AS "updatedAt"
|
|
356
|
+
FROM fonderie_customer_notes WHERE customer_id = ANY($1::uuid[]) ORDER BY created_at DESC`,
|
|
357
|
+
[relatedIds]
|
|
358
|
+
),
|
|
359
|
+
this.store.query(
|
|
360
|
+
`SELECT customer_id AS "customerId", tag FROM fonderie_customer_tags WHERE customer_id = ANY($1::uuid[]) ORDER BY tag ASC`,
|
|
361
|
+
[relatedIds]
|
|
362
|
+
)
|
|
363
|
+
]);
|
|
364
|
+
const customerMap = new Map(relCustomers.map((c) => [c.id, c]));
|
|
365
|
+
const emailMap = groupByCustomer(relEmails);
|
|
366
|
+
const phoneMap = groupByCustomer(relPhones);
|
|
367
|
+
const addrMap = groupByCustomer(relAddresses);
|
|
368
|
+
const noteMap = groupByCustomer(relNotes);
|
|
369
|
+
const tagMap = relTags.reduce((m, t) => {
|
|
370
|
+
if (!m.has(t.customerId)) m.set(t.customerId, []);
|
|
371
|
+
m.get(t.customerId).push(t.tag);
|
|
372
|
+
return m;
|
|
373
|
+
}, /* @__PURE__ */ new Map());
|
|
374
|
+
expandedRelationships = relationshipRows.map((rel) => ({
|
|
375
|
+
id: rel.id,
|
|
376
|
+
workspaceId: rel.workspaceId,
|
|
377
|
+
customerId: rel.customerId,
|
|
378
|
+
relationship: rel.relationship,
|
|
379
|
+
isPrimary: rel.isPrimary,
|
|
380
|
+
createdAt: rel.createdAt,
|
|
381
|
+
customer: {
|
|
382
|
+
...customerMap.get(rel.relatedId) ?? { id: rel.relatedId },
|
|
383
|
+
emails: emailMap.get(rel.relatedId) ?? [],
|
|
384
|
+
phones: phoneMap.get(rel.relatedId) ?? [],
|
|
385
|
+
addresses: addrMap.get(rel.relatedId) ?? [],
|
|
386
|
+
notes: noteMap.get(rel.relatedId) ?? [],
|
|
387
|
+
tags: tagMap.get(rel.relatedId) ?? []
|
|
388
|
+
}
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
if (depth >= 2 && expandedRelationships.length > 0) {
|
|
392
|
+
const visited = /* @__PURE__ */ new Set([id]);
|
|
393
|
+
const d1Ids = expandedRelationships.map((r) => r.customer.id);
|
|
394
|
+
const d2RelRows = await this.store.query(
|
|
395
|
+
`SELECT id,
|
|
396
|
+
workspace_id AS "workspaceId",
|
|
397
|
+
customer_id AS "customerId",
|
|
398
|
+
related_id AS "relatedId",
|
|
399
|
+
relationship,
|
|
400
|
+
is_primary AS "isPrimary",
|
|
401
|
+
created_at AS "createdAt"
|
|
402
|
+
FROM fonderie_customer_relationships
|
|
403
|
+
WHERE customer_id = ANY($1::uuid[])
|
|
404
|
+
ORDER BY is_primary DESC, created_at ASC`,
|
|
405
|
+
[d1Ids]
|
|
406
|
+
);
|
|
407
|
+
const validD2Rows = d2RelRows.filter((r) => !visited.has(r.relatedId));
|
|
408
|
+
const d2Ids = [...new Set(validD2Rows.map((r) => r.relatedId))];
|
|
409
|
+
let d2CustomerMap = /* @__PURE__ */ new Map();
|
|
410
|
+
let d2EmailMap = /* @__PURE__ */ new Map();
|
|
411
|
+
let d2PhoneMap = /* @__PURE__ */ new Map();
|
|
412
|
+
let d2NoteMap = /* @__PURE__ */ new Map();
|
|
413
|
+
let d2TagMap = /* @__PURE__ */ new Map();
|
|
414
|
+
if (d2Ids.length > 0) {
|
|
415
|
+
const [d2Customers, d2Emails, d2Phones, d2Notes, d2Tags] = await Promise.all([
|
|
416
|
+
this.store.query(
|
|
417
|
+
`SELECT ${SELECT_CUSTOMER} FROM fonderie_customers WHERE id = ANY($1::uuid[]) AND workspace_id = $2`,
|
|
418
|
+
[d2Ids, workspaceId]
|
|
419
|
+
),
|
|
420
|
+
this.store.query(
|
|
421
|
+
`SELECT id, customer_id AS "customerId", email, label_id AS "labelId",
|
|
422
|
+
(SELECT value FROM fonderie_customer_labels WHERE id = label_id) AS label,
|
|
423
|
+
is_primary AS "isPrimary", created_at AS "createdAt"
|
|
424
|
+
FROM fonderie_customer_emails WHERE customer_id = ANY($1::uuid[]) ORDER BY is_primary DESC, created_at ASC`,
|
|
425
|
+
[d2Ids]
|
|
426
|
+
),
|
|
427
|
+
this.store.query(
|
|
428
|
+
`SELECT id, customer_id AS "customerId", phone, label_id AS "labelId",
|
|
429
|
+
(SELECT value FROM fonderie_customer_labels WHERE id = label_id) AS label,
|
|
430
|
+
is_primary AS "isPrimary", created_at AS "createdAt"
|
|
431
|
+
FROM fonderie_customer_phones WHERE customer_id = ANY($1::uuid[]) ORDER BY is_primary DESC, created_at ASC`,
|
|
432
|
+
[d2Ids]
|
|
433
|
+
),
|
|
434
|
+
this.store.query(
|
|
435
|
+
`SELECT id, customer_id AS "customerId", author_id AS "authorId", body, created_at AS "createdAt", updated_at AS "updatedAt"
|
|
436
|
+
FROM fonderie_customer_notes WHERE customer_id = ANY($1::uuid[]) ORDER BY created_at DESC`,
|
|
437
|
+
[d2Ids]
|
|
438
|
+
),
|
|
439
|
+
this.store.query(
|
|
440
|
+
`SELECT customer_id AS "customerId", tag FROM fonderie_customer_tags WHERE customer_id = ANY($1::uuid[]) ORDER BY tag ASC`,
|
|
441
|
+
[d2Ids]
|
|
442
|
+
)
|
|
443
|
+
]);
|
|
444
|
+
d2CustomerMap = new Map(d2Customers.map((c) => [c.id, c]));
|
|
445
|
+
d2EmailMap = groupByCustomer(d2Emails);
|
|
446
|
+
d2PhoneMap = groupByCustomer(d2Phones);
|
|
447
|
+
d2NoteMap = groupByCustomer(d2Notes);
|
|
448
|
+
d2TagMap = d2Tags.reduce((m, t) => {
|
|
449
|
+
if (!m.has(t.customerId)) m.set(t.customerId, []);
|
|
450
|
+
m.get(t.customerId).push(t.tag);
|
|
451
|
+
return m;
|
|
452
|
+
}, /* @__PURE__ */ new Map());
|
|
453
|
+
}
|
|
454
|
+
const d2Relationships = validD2Rows.map((d2rel) => ({
|
|
455
|
+
id: d2rel.id,
|
|
456
|
+
workspaceId: d2rel.workspaceId,
|
|
457
|
+
customerId: d2rel.customerId,
|
|
458
|
+
relationship: d2rel.relationship,
|
|
459
|
+
isPrimary: d2rel.isPrimary,
|
|
460
|
+
createdAt: d2rel.createdAt,
|
|
461
|
+
customer: {
|
|
462
|
+
...d2CustomerMap.get(d2rel.relatedId) ?? { id: d2rel.relatedId },
|
|
463
|
+
emails: d2EmailMap.get(d2rel.relatedId) ?? [],
|
|
464
|
+
phones: d2PhoneMap.get(d2rel.relatedId) ?? [],
|
|
465
|
+
addresses: [],
|
|
466
|
+
notes: d2NoteMap.get(d2rel.relatedId) ?? [],
|
|
467
|
+
tags: d2TagMap.get(d2rel.relatedId) ?? []
|
|
468
|
+
}
|
|
469
|
+
}));
|
|
470
|
+
const d2RelsByD1Id = groupByCustomer(d2Relationships);
|
|
471
|
+
const d2ExpandedRelationships = expandedRelationships.map((rel) => ({
|
|
472
|
+
...rel,
|
|
473
|
+
customer: {
|
|
474
|
+
...rel.customer,
|
|
475
|
+
relationships: d2RelsByD1Id.get(rel.customer.id) ?? []
|
|
476
|
+
}
|
|
477
|
+
}));
|
|
478
|
+
return {
|
|
479
|
+
...row,
|
|
480
|
+
emails: emailRows,
|
|
481
|
+
phones: phoneRows,
|
|
482
|
+
addresses: addressRows,
|
|
483
|
+
notes: noteRows,
|
|
484
|
+
relationships: d2ExpandedRelationships,
|
|
485
|
+
tags: tagRows.map((t) => t.tag)
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
...row,
|
|
490
|
+
// DB returns TEXT for label; cast to the narrow union that callers expect.
|
|
491
|
+
emails: emailRows,
|
|
492
|
+
phones: phoneRows,
|
|
493
|
+
addresses: addressRows,
|
|
494
|
+
notes: noteRows,
|
|
495
|
+
relationships: expandedRelationships,
|
|
496
|
+
tags: tagRows.map((t) => t.tag)
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
async create(opts) {
|
|
500
|
+
const referenceCode = opts.referenceCode ?? await this.allocateCode(opts.workspaceId, opts.referenceCodePrefix ?? DEFAULT_REFERENCE_CODE_PREFIX);
|
|
501
|
+
const [row] = await this.store.query(
|
|
502
|
+
`INSERT INTO fonderie_customers
|
|
503
|
+
(workspace_id, type, sex, first_name, last_name, company_name, avatar_url, locale, reference_code, created_by)
|
|
504
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
505
|
+
RETURNING ${SELECT_CUSTOMER}`,
|
|
506
|
+
[
|
|
507
|
+
opts.workspaceId,
|
|
508
|
+
opts.type ?? "individual",
|
|
509
|
+
opts.sex ?? "UNKNOWN",
|
|
510
|
+
opts.firstName ?? null,
|
|
511
|
+
opts.lastName ?? null,
|
|
512
|
+
opts.companyName ?? null,
|
|
513
|
+
opts.avatarUrl ?? null,
|
|
514
|
+
opts.locale ?? "en-US",
|
|
515
|
+
referenceCode,
|
|
516
|
+
opts.createdBy ?? null
|
|
517
|
+
]
|
|
518
|
+
);
|
|
519
|
+
if (!row) throw new Error("Failed to create customer");
|
|
520
|
+
return row;
|
|
521
|
+
}
|
|
522
|
+
async update(id, workspaceId, opts, referenceCodePrefix = DEFAULT_REFERENCE_CODE_PREFIX) {
|
|
523
|
+
let autoCode;
|
|
524
|
+
if (!opts.referenceCode) {
|
|
525
|
+
const current = await this.findById(id, workspaceId);
|
|
526
|
+
if (!current) return null;
|
|
527
|
+
if (!current.referenceCode) {
|
|
528
|
+
autoCode = await this.allocateCode(workspaceId, referenceCodePrefix);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const sets = ["updated_at = now()"];
|
|
532
|
+
const params = [id, workspaceId];
|
|
533
|
+
if (opts.type !== void 0) {
|
|
534
|
+
params.push(opts.type);
|
|
535
|
+
sets.push(`type = $${params.length}`);
|
|
536
|
+
}
|
|
537
|
+
if (opts.sex !== void 0) {
|
|
538
|
+
params.push(opts.sex);
|
|
539
|
+
sets.push(`sex = $${params.length}`);
|
|
540
|
+
}
|
|
541
|
+
if (opts.firstName !== void 0) {
|
|
542
|
+
params.push(opts.firstName);
|
|
543
|
+
sets.push(`first_name = $${params.length}`);
|
|
544
|
+
}
|
|
545
|
+
if (opts.lastName !== void 0) {
|
|
546
|
+
params.push(opts.lastName);
|
|
547
|
+
sets.push(`last_name = $${params.length}`);
|
|
548
|
+
}
|
|
549
|
+
if (opts.companyName !== void 0) {
|
|
550
|
+
params.push(opts.companyName);
|
|
551
|
+
sets.push(`company_name = $${params.length}`);
|
|
552
|
+
}
|
|
553
|
+
if (opts.avatarUrl !== void 0) {
|
|
554
|
+
params.push(opts.avatarUrl);
|
|
555
|
+
sets.push(`avatar_url = $${params.length}`);
|
|
556
|
+
}
|
|
557
|
+
if (opts.locale !== void 0) {
|
|
558
|
+
params.push(opts.locale);
|
|
559
|
+
sets.push(`locale = $${params.length}`);
|
|
560
|
+
}
|
|
561
|
+
const resolvedCode = opts.referenceCode ?? autoCode;
|
|
562
|
+
if (resolvedCode !== void 0) {
|
|
563
|
+
params.push(resolvedCode);
|
|
564
|
+
sets.push(`reference_code = $${params.length}`);
|
|
565
|
+
}
|
|
566
|
+
const [row] = await this.store.query(
|
|
567
|
+
`UPDATE fonderie_customers
|
|
568
|
+
SET ${sets.join(", ")}
|
|
569
|
+
WHERE id = $1 AND workspace_id = $2
|
|
570
|
+
RETURNING ${SELECT_CUSTOMER}`,
|
|
571
|
+
params
|
|
572
|
+
);
|
|
573
|
+
return row ?? null;
|
|
574
|
+
}
|
|
575
|
+
async delete(id, workspaceId) {
|
|
576
|
+
await Promise.all([
|
|
577
|
+
this.store.query(`DELETE FROM fonderie_customer_emails WHERE customer_id = $1`, [id]),
|
|
578
|
+
this.store.query(`DELETE FROM fonderie_customer_phones WHERE customer_id = $1`, [id]),
|
|
579
|
+
this.store.query(
|
|
580
|
+
`DELETE FROM fonderie_addresses WHERE id IN (
|
|
581
|
+
SELECT addr_id FROM fonderie_customer_addresses WHERE customer_id = $1
|
|
582
|
+
)`,
|
|
583
|
+
[id]
|
|
584
|
+
),
|
|
585
|
+
this.store.query(`DELETE FROM fonderie_customer_notes WHERE customer_id = $1`, [id]),
|
|
586
|
+
this.store.query(`DELETE FROM fonderie_customer_tags WHERE customer_id = $1`, [id]),
|
|
587
|
+
this.store.query(
|
|
588
|
+
`DELETE FROM fonderie_customer_relationships WHERE customer_id = $1 OR related_id = $1`,
|
|
589
|
+
[id]
|
|
590
|
+
)
|
|
591
|
+
]);
|
|
592
|
+
await this.store.query(`DELETE FROM fonderie_customers WHERE id = $1 AND workspace_id = $2`, [
|
|
593
|
+
id,
|
|
594
|
+
workspaceId
|
|
595
|
+
]);
|
|
596
|
+
}
|
|
597
|
+
async blacklist(id, workspaceId, reason) {
|
|
598
|
+
await this.store.query(
|
|
599
|
+
`UPDATE fonderie_customers
|
|
600
|
+
SET is_blacklisted = true, blacklist_reason = $3, updated_at = now()
|
|
601
|
+
WHERE id = $1 AND workspace_id = $2`,
|
|
602
|
+
[id, workspaceId, reason ?? null]
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
async unblacklist(id, workspaceId) {
|
|
606
|
+
await this.store.query(
|
|
607
|
+
`UPDATE fonderie_customers
|
|
608
|
+
SET is_blacklisted = false, blacklist_reason = null, updated_at = now()
|
|
609
|
+
WHERE id = $1 AND workspace_id = $2`,
|
|
610
|
+
[id, workspaceId]
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
// src/models/customer-relationship.model.ts
|
|
616
|
+
var CustomerRelationshipModel = class {
|
|
617
|
+
constructor(store) {
|
|
618
|
+
this.store = store;
|
|
619
|
+
}
|
|
620
|
+
store;
|
|
621
|
+
async list(customerId) {
|
|
622
|
+
return this.store.query(
|
|
623
|
+
`SELECT id,
|
|
624
|
+
workspace_id AS "workspaceId",
|
|
625
|
+
customer_id AS "customerId",
|
|
626
|
+
related_id AS "relatedId",
|
|
627
|
+
relationship,
|
|
628
|
+
is_primary AS "isPrimary",
|
|
629
|
+
created_at AS "createdAt"
|
|
630
|
+
FROM fonderie_customer_relationships
|
|
631
|
+
WHERE customer_id = $1
|
|
632
|
+
ORDER BY is_primary DESC, created_at ASC`,
|
|
633
|
+
[customerId]
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
async add(opts) {
|
|
637
|
+
return this.store.transaction(async (tx) => {
|
|
638
|
+
if (opts.isPrimary) {
|
|
639
|
+
await tx.query(
|
|
640
|
+
`UPDATE fonderie_customer_relationships
|
|
641
|
+
SET is_primary = false
|
|
642
|
+
WHERE customer_id = $1`,
|
|
643
|
+
[opts.customerId]
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
const [row] = await tx.query(
|
|
647
|
+
`INSERT INTO fonderie_customer_relationships
|
|
648
|
+
(workspace_id, customer_id, related_id, relationship, is_primary)
|
|
649
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
650
|
+
ON CONFLICT (customer_id, related_id) DO UPDATE
|
|
651
|
+
SET relationship = EXCLUDED.relationship,
|
|
652
|
+
is_primary = EXCLUDED.is_primary
|
|
653
|
+
RETURNING
|
|
654
|
+
id,
|
|
655
|
+
workspace_id AS "workspaceId",
|
|
656
|
+
customer_id AS "customerId",
|
|
657
|
+
related_id AS "relatedId",
|
|
658
|
+
relationship,
|
|
659
|
+
is_primary AS "isPrimary",
|
|
660
|
+
created_at AS "createdAt"`,
|
|
661
|
+
[opts.workspaceId, opts.customerId, opts.relatedId, opts.relationship, opts.isPrimary ?? false]
|
|
662
|
+
);
|
|
663
|
+
if (!row) throw new Error("Failed to create relationship");
|
|
664
|
+
return row;
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
async setPrimary(customerId, relatedId) {
|
|
668
|
+
await this.store.transaction(async (tx) => {
|
|
669
|
+
await tx.query(
|
|
670
|
+
`UPDATE fonderie_customer_relationships SET is_primary = false WHERE customer_id = $1`,
|
|
671
|
+
[customerId]
|
|
672
|
+
);
|
|
673
|
+
await tx.query(
|
|
674
|
+
`UPDATE fonderie_customer_relationships
|
|
675
|
+
SET is_primary = true
|
|
676
|
+
WHERE customer_id = $1 AND related_id = $2`,
|
|
677
|
+
[customerId, relatedId]
|
|
678
|
+
);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
async remove(customerId, relatedId) {
|
|
682
|
+
await this.store.query(
|
|
683
|
+
`DELETE FROM fonderie_customer_relationships
|
|
684
|
+
WHERE customer_id = $1 AND related_id = $2`,
|
|
685
|
+
[customerId, relatedId]
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
// src/models/customer-address.model.ts
|
|
691
|
+
var SELECT_CUSTOMER_ADDRESS = `
|
|
692
|
+
ca.addr_id AS "addrId",
|
|
693
|
+
ca.customer_id AS "customerId",
|
|
694
|
+
ca.label_id AS "labelId",
|
|
695
|
+
(SELECT value FROM fonderie_customer_labels WHERE id = ca.label_id) AS label,
|
|
696
|
+
ca.is_primary AS "isPrimary",
|
|
697
|
+
jsonb_build_object(
|
|
698
|
+
'id', a.id,
|
|
699
|
+
'countryIso', a.country_iso,
|
|
700
|
+
'subdivision1Iso', a.subdivision1_iso,
|
|
701
|
+
'subdivision2Iso', a.subdivision2_iso,
|
|
702
|
+
'zipPostalCode', a.zip_postal_code,
|
|
703
|
+
'unit', a.unit,
|
|
704
|
+
'line1', a.line1,
|
|
705
|
+
'line2', a.line2
|
|
706
|
+
) AS address
|
|
707
|
+
`;
|
|
708
|
+
var CustomerAddressModel = class {
|
|
709
|
+
constructor(store) {
|
|
710
|
+
this.store = store;
|
|
711
|
+
}
|
|
712
|
+
store;
|
|
713
|
+
list(customerId) {
|
|
714
|
+
return this.store.query(
|
|
715
|
+
`SELECT ${SELECT_CUSTOMER_ADDRESS}
|
|
716
|
+
FROM fonderie_customer_addresses ca
|
|
717
|
+
JOIN fonderie_addresses a ON a.id = ca.addr_id
|
|
718
|
+
WHERE ca.customer_id = $1
|
|
719
|
+
ORDER BY ca.is_primary DESC`,
|
|
720
|
+
[customerId]
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
async add(opts) {
|
|
724
|
+
const [existing] = await this.store.query(
|
|
725
|
+
`SELECT ca.addr_id AS "addrId"
|
|
726
|
+
FROM fonderie_customer_addresses ca
|
|
727
|
+
JOIN fonderie_addresses a ON a.id = ca.addr_id
|
|
728
|
+
WHERE ca.customer_id = $1
|
|
729
|
+
AND a.country_iso = $2
|
|
730
|
+
AND a.zip_postal_code = $3
|
|
731
|
+
AND a.subdivision1_iso IS NOT DISTINCT FROM $4
|
|
732
|
+
AND a.subdivision2_iso IS NOT DISTINCT FROM $5
|
|
733
|
+
AND a.unit IS NOT DISTINCT FROM $6
|
|
734
|
+
AND a.line1 IS NOT DISTINCT FROM $7
|
|
735
|
+
AND a.line2 IS NOT DISTINCT FROM $8
|
|
736
|
+
LIMIT 1`,
|
|
737
|
+
[
|
|
738
|
+
opts.customerId,
|
|
739
|
+
opts.countryIso,
|
|
740
|
+
opts.zipPostalCode,
|
|
741
|
+
opts.subdivision1Iso ?? null,
|
|
742
|
+
opts.subdivision2Iso ?? null,
|
|
743
|
+
opts.unit ?? null,
|
|
744
|
+
opts.line1 ?? null,
|
|
745
|
+
opts.line2 ?? null
|
|
746
|
+
]
|
|
747
|
+
);
|
|
748
|
+
if (existing) {
|
|
749
|
+
throw Object.assign(new Error("Duplicate address for this customer"), { code: "DUPLICATE_ADDRESS" });
|
|
750
|
+
}
|
|
751
|
+
return this.store.transaction(async (tx) => {
|
|
752
|
+
if (opts.isPrimary) {
|
|
753
|
+
await tx.query(
|
|
754
|
+
`UPDATE fonderie_customer_addresses
|
|
755
|
+
SET is_primary = false
|
|
756
|
+
WHERE customer_id = $1`,
|
|
757
|
+
[opts.customerId]
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
const [addr] = await tx.query(
|
|
761
|
+
`INSERT INTO fonderie_addresses
|
|
762
|
+
(id, country_iso, subdivision1_iso, subdivision2_iso, zip_postal_code, unit, line1, line2)
|
|
763
|
+
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7)
|
|
764
|
+
RETURNING id`,
|
|
765
|
+
[
|
|
766
|
+
opts.countryIso,
|
|
767
|
+
opts.subdivision1Iso ?? null,
|
|
768
|
+
opts.subdivision2Iso ?? null,
|
|
769
|
+
opts.zipPostalCode,
|
|
770
|
+
opts.unit ?? null,
|
|
771
|
+
opts.line1 ?? null,
|
|
772
|
+
opts.line2 ?? null
|
|
773
|
+
]
|
|
774
|
+
);
|
|
775
|
+
if (!addr) throw new Error("Failed to create address");
|
|
776
|
+
const [row] = await tx.query(
|
|
777
|
+
`INSERT INTO fonderie_customer_addresses (addr_id, customer_id, label_id, is_primary)
|
|
778
|
+
VALUES ($1, $2, $3, $4)
|
|
779
|
+
RETURNING
|
|
780
|
+
addr_id AS "addrId",
|
|
781
|
+
customer_id AS "customerId",
|
|
782
|
+
label_id AS "labelId",
|
|
783
|
+
NULL::text AS label,
|
|
784
|
+
is_primary AS "isPrimary",
|
|
785
|
+
NULL::jsonb AS address`,
|
|
786
|
+
[addr.id, opts.customerId, opts.labelId, opts.isPrimary ?? false]
|
|
787
|
+
);
|
|
788
|
+
if (!row) throw new Error("Failed to link address");
|
|
789
|
+
const [full] = await tx.query(
|
|
790
|
+
`SELECT ${SELECT_CUSTOMER_ADDRESS}
|
|
791
|
+
FROM fonderie_customer_addresses ca
|
|
792
|
+
JOIN fonderie_addresses a ON a.id = ca.addr_id
|
|
793
|
+
WHERE ca.addr_id = $1 AND ca.customer_id = $2`,
|
|
794
|
+
[addr.id, opts.customerId]
|
|
795
|
+
);
|
|
796
|
+
if (!full) throw new Error("Failed to fetch created address");
|
|
797
|
+
return full;
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
async updateLabel(addrId, customerId, labelId) {
|
|
801
|
+
const [row] = await this.store.query(
|
|
802
|
+
`UPDATE fonderie_customer_addresses
|
|
803
|
+
SET label_id = $3
|
|
804
|
+
WHERE addr_id = $1 AND customer_id = $2
|
|
805
|
+
RETURNING addr_id AS "addrId", customer_id AS "customerId", label_id AS "labelId", NULL::text AS label, is_primary AS "isPrimary", NULL::jsonb AS address`,
|
|
806
|
+
[addrId, customerId, labelId]
|
|
807
|
+
);
|
|
808
|
+
if (!row) throw Object.assign(new Error("Address not found"), { code: "NOT_FOUND" });
|
|
809
|
+
const [full] = await this.store.query(
|
|
810
|
+
`SELECT ${SELECT_CUSTOMER_ADDRESS}
|
|
811
|
+
FROM fonderie_customer_addresses ca
|
|
812
|
+
JOIN fonderie_addresses a ON a.id = ca.addr_id
|
|
813
|
+
WHERE ca.addr_id = $1 AND ca.customer_id = $2`,
|
|
814
|
+
[addrId, customerId]
|
|
815
|
+
);
|
|
816
|
+
if (!full) throw new Error("Failed to fetch updated address");
|
|
817
|
+
return full;
|
|
818
|
+
}
|
|
819
|
+
async setPrimary(addrId, customerId) {
|
|
820
|
+
await this.store.transaction(async (tx) => {
|
|
821
|
+
await tx.query(
|
|
822
|
+
`UPDATE fonderie_customer_addresses SET is_primary = false WHERE customer_id = $1`,
|
|
823
|
+
[customerId]
|
|
824
|
+
);
|
|
825
|
+
await tx.query(
|
|
826
|
+
`UPDATE fonderie_customer_addresses SET is_primary = true WHERE addr_id = $1 AND customer_id = $2`,
|
|
827
|
+
[addrId, customerId]
|
|
828
|
+
);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
async remove(addrId, customerId) {
|
|
832
|
+
await this.store.transaction(async (tx) => {
|
|
833
|
+
const [deleted] = await tx.query(
|
|
834
|
+
`DELETE FROM fonderie_customer_addresses
|
|
835
|
+
WHERE addr_id = $1 AND customer_id = $2
|
|
836
|
+
RETURNING is_primary AS "isPrimary"`,
|
|
837
|
+
[addrId, customerId]
|
|
838
|
+
);
|
|
839
|
+
await tx.query(`DELETE FROM fonderie_addresses WHERE id = $1`, [addrId]);
|
|
840
|
+
if (deleted?.isPrimary) {
|
|
841
|
+
await tx.query(
|
|
842
|
+
`UPDATE fonderie_customer_addresses
|
|
843
|
+
SET is_primary = true
|
|
844
|
+
WHERE addr_id = (
|
|
845
|
+
SELECT addr_id FROM fonderie_customer_addresses
|
|
846
|
+
WHERE customer_id = $1
|
|
847
|
+
ORDER BY addr_id ASC
|
|
848
|
+
LIMIT 1
|
|
849
|
+
)`,
|
|
850
|
+
[customerId]
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
// src/models/customer-email.model.ts
|
|
858
|
+
var SELECT_EMAIL = `
|
|
859
|
+
id,
|
|
860
|
+
customer_id AS "customerId",
|
|
861
|
+
email,
|
|
862
|
+
label_id AS "labelId",
|
|
863
|
+
(SELECT value FROM fonderie_customer_labels WHERE id = label_id) AS label,
|
|
864
|
+
is_primary AS "isPrimary",
|
|
865
|
+
created_at AS "createdAt"
|
|
866
|
+
`;
|
|
867
|
+
var CustomerEmailModel = class {
|
|
868
|
+
constructor(store) {
|
|
869
|
+
this.store = store;
|
|
870
|
+
}
|
|
871
|
+
store;
|
|
872
|
+
list(customerId) {
|
|
873
|
+
return this.store.query(
|
|
874
|
+
`SELECT ${SELECT_EMAIL}
|
|
875
|
+
FROM fonderie_customer_emails
|
|
876
|
+
WHERE customer_id = $1
|
|
877
|
+
ORDER BY is_primary DESC, created_at ASC`,
|
|
878
|
+
[customerId]
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
async add(opts) {
|
|
882
|
+
return this.store.transaction(async (tx) => {
|
|
883
|
+
if (opts.isPrimary) {
|
|
884
|
+
await tx.query(
|
|
885
|
+
`UPDATE fonderie_customer_emails SET is_primary = false WHERE customer_id = $1`,
|
|
886
|
+
[opts.customerId]
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
const [row] = await tx.query(
|
|
890
|
+
`INSERT INTO fonderie_customer_emails (id, customer_id, email, label_id, is_primary)
|
|
891
|
+
VALUES (gen_random_uuid(), $1, $2, $3, $4)
|
|
892
|
+
RETURNING ${SELECT_EMAIL}`,
|
|
893
|
+
[opts.customerId, opts.email, opts.labelId, opts.isPrimary ?? false]
|
|
894
|
+
);
|
|
895
|
+
if (!row) throw new Error("Failed to add customer email");
|
|
896
|
+
return row;
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
async updateLabel(emailId, customerId, labelId) {
|
|
900
|
+
const [row] = await this.store.query(
|
|
901
|
+
`UPDATE fonderie_customer_emails
|
|
902
|
+
SET label_id = $3
|
|
903
|
+
WHERE id = $1 AND customer_id = $2
|
|
904
|
+
RETURNING ${SELECT_EMAIL}`,
|
|
905
|
+
[emailId, customerId, labelId]
|
|
906
|
+
);
|
|
907
|
+
if (!row) throw Object.assign(new Error("Email not found"), { code: "NOT_FOUND" });
|
|
908
|
+
return row;
|
|
909
|
+
}
|
|
910
|
+
async setPrimary(emailId, customerId) {
|
|
911
|
+
await this.store.transaction(async (tx) => {
|
|
912
|
+
await tx.query(
|
|
913
|
+
`UPDATE fonderie_customer_emails SET is_primary = false WHERE customer_id = $1`,
|
|
914
|
+
[customerId]
|
|
915
|
+
);
|
|
916
|
+
await tx.query(
|
|
917
|
+
`UPDATE fonderie_customer_emails SET is_primary = true WHERE id = $1 AND customer_id = $2`,
|
|
918
|
+
[emailId, customerId]
|
|
919
|
+
);
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
async remove(emailId, customerId) {
|
|
923
|
+
await this.store.transaction(async (tx) => {
|
|
924
|
+
const [deleted] = await tx.query(
|
|
925
|
+
`DELETE FROM fonderie_customer_emails
|
|
926
|
+
WHERE id = $1 AND customer_id = $2
|
|
927
|
+
RETURNING is_primary AS "isPrimary"`,
|
|
928
|
+
[emailId, customerId]
|
|
929
|
+
);
|
|
930
|
+
if (deleted?.isPrimary) {
|
|
931
|
+
await tx.query(
|
|
932
|
+
`UPDATE fonderie_customer_emails
|
|
933
|
+
SET is_primary = true
|
|
934
|
+
WHERE id = (
|
|
935
|
+
SELECT id FROM fonderie_customer_emails
|
|
936
|
+
WHERE customer_id = $1
|
|
937
|
+
ORDER BY created_at ASC
|
|
938
|
+
LIMIT 1
|
|
939
|
+
)`,
|
|
940
|
+
[customerId]
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
// src/models/customer-label.model.ts
|
|
948
|
+
var CustomerLabelModel = class {
|
|
949
|
+
constructor(store) {
|
|
950
|
+
this.store = store;
|
|
951
|
+
}
|
|
952
|
+
store;
|
|
953
|
+
list(type) {
|
|
954
|
+
return this.store.query(
|
|
955
|
+
`SELECT id, type, value, created_at AS "createdAt"
|
|
956
|
+
FROM fonderie_customer_labels
|
|
957
|
+
WHERE type = $1
|
|
958
|
+
ORDER BY value ASC`,
|
|
959
|
+
[type]
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
remove(id) {
|
|
963
|
+
return this.store.query(
|
|
964
|
+
`DELETE FROM fonderie_customer_labels WHERE id = $1`,
|
|
965
|
+
[id]
|
|
966
|
+
).then(() => void 0);
|
|
967
|
+
}
|
|
968
|
+
async findOrCreate(type, raw) {
|
|
969
|
+
const value = raw.trim().toLowerCase();
|
|
970
|
+
const [row] = await this.store.query(
|
|
971
|
+
`INSERT INTO fonderie_customer_labels (type, value)
|
|
972
|
+
VALUES ($1, $2)
|
|
973
|
+
ON CONFLICT (type, value) DO UPDATE SET value = EXCLUDED.value
|
|
974
|
+
RETURNING id, type, value, created_at AS "createdAt"`,
|
|
975
|
+
[type, value]
|
|
976
|
+
);
|
|
977
|
+
if (!row) throw new Error("Failed to find or create label");
|
|
978
|
+
return row;
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
// src/models/customer-note.model.ts
|
|
983
|
+
var SELECT_NOTE = `
|
|
984
|
+
id,
|
|
985
|
+
customer_id AS "customerId",
|
|
986
|
+
author_id AS "authorId",
|
|
987
|
+
body,
|
|
988
|
+
created_at AS "createdAt",
|
|
989
|
+
updated_at AS "updatedAt"
|
|
990
|
+
`;
|
|
991
|
+
var CustomerNoteModel = class {
|
|
992
|
+
constructor(store) {
|
|
993
|
+
this.store = store;
|
|
994
|
+
}
|
|
995
|
+
store;
|
|
996
|
+
list(customerId) {
|
|
997
|
+
return this.store.query(
|
|
998
|
+
`SELECT ${SELECT_NOTE}
|
|
999
|
+
FROM fonderie_customer_notes
|
|
1000
|
+
WHERE customer_id = $1
|
|
1001
|
+
ORDER BY created_at DESC`,
|
|
1002
|
+
[customerId]
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
async create(opts) {
|
|
1006
|
+
const [row] = await this.store.query(
|
|
1007
|
+
`INSERT INTO fonderie_customer_notes (id, customer_id, author_id, body)
|
|
1008
|
+
VALUES (gen_random_uuid(), $1, $2, $3)
|
|
1009
|
+
RETURNING ${SELECT_NOTE}`,
|
|
1010
|
+
[opts.customerId, opts.authorId ?? null, opts.body]
|
|
1011
|
+
);
|
|
1012
|
+
if (!row) throw new Error("Failed to create note");
|
|
1013
|
+
return row;
|
|
1014
|
+
}
|
|
1015
|
+
async update(noteId, customerId, body) {
|
|
1016
|
+
const [row] = await this.store.query(
|
|
1017
|
+
`UPDATE fonderie_customer_notes
|
|
1018
|
+
SET body = $1, updated_at = now()
|
|
1019
|
+
WHERE id = $2 AND customer_id = $3
|
|
1020
|
+
RETURNING ${SELECT_NOTE}`,
|
|
1021
|
+
[body, noteId, customerId]
|
|
1022
|
+
);
|
|
1023
|
+
return row ?? null;
|
|
1024
|
+
}
|
|
1025
|
+
async delete(noteId, customerId) {
|
|
1026
|
+
await this.store.query(
|
|
1027
|
+
`DELETE FROM fonderie_customer_notes
|
|
1028
|
+
WHERE id = $1 AND customer_id = $2`,
|
|
1029
|
+
[noteId, customerId]
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
// src/models/customer-phone.model.ts
|
|
1035
|
+
var SELECT_PHONE = `
|
|
1036
|
+
id,
|
|
1037
|
+
customer_id AS "customerId",
|
|
1038
|
+
phone,
|
|
1039
|
+
label_id AS "labelId",
|
|
1040
|
+
(SELECT value FROM fonderie_customer_labels WHERE id = label_id) AS label,
|
|
1041
|
+
is_primary AS "isPrimary",
|
|
1042
|
+
created_at AS "createdAt"
|
|
1043
|
+
`;
|
|
1044
|
+
var CustomerPhoneModel = class {
|
|
1045
|
+
constructor(store) {
|
|
1046
|
+
this.store = store;
|
|
1047
|
+
}
|
|
1048
|
+
store;
|
|
1049
|
+
list(customerId) {
|
|
1050
|
+
return this.store.query(
|
|
1051
|
+
`SELECT ${SELECT_PHONE}
|
|
1052
|
+
FROM fonderie_customer_phones
|
|
1053
|
+
WHERE customer_id = $1
|
|
1054
|
+
ORDER BY is_primary DESC, created_at ASC`,
|
|
1055
|
+
[customerId]
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
async add(opts) {
|
|
1059
|
+
return this.store.transaction(async (tx) => {
|
|
1060
|
+
if (opts.isPrimary) {
|
|
1061
|
+
await tx.query(
|
|
1062
|
+
`UPDATE fonderie_customer_phones SET is_primary = false WHERE customer_id = $1`,
|
|
1063
|
+
[opts.customerId]
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
const [row] = await tx.query(
|
|
1067
|
+
`INSERT INTO fonderie_customer_phones (id, customer_id, phone, label_id, is_primary)
|
|
1068
|
+
VALUES (gen_random_uuid(), $1, $2, $3, $4)
|
|
1069
|
+
RETURNING ${SELECT_PHONE}`,
|
|
1070
|
+
[opts.customerId, opts.phone, opts.labelId, opts.isPrimary ?? false]
|
|
1071
|
+
);
|
|
1072
|
+
if (!row) throw new Error("Failed to add customer phone");
|
|
1073
|
+
return row;
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
async updateLabel(phoneId, customerId, labelId) {
|
|
1077
|
+
const [row] = await this.store.query(
|
|
1078
|
+
`UPDATE fonderie_customer_phones
|
|
1079
|
+
SET label_id = $3
|
|
1080
|
+
WHERE id = $1 AND customer_id = $2
|
|
1081
|
+
RETURNING ${SELECT_PHONE}`,
|
|
1082
|
+
[phoneId, customerId, labelId]
|
|
1083
|
+
);
|
|
1084
|
+
if (!row) throw Object.assign(new Error("Phone not found"), { code: "NOT_FOUND" });
|
|
1085
|
+
return row;
|
|
1086
|
+
}
|
|
1087
|
+
async setPrimary(phoneId, customerId) {
|
|
1088
|
+
await this.store.transaction(async (tx) => {
|
|
1089
|
+
await tx.query(
|
|
1090
|
+
`UPDATE fonderie_customer_phones SET is_primary = false WHERE customer_id = $1`,
|
|
1091
|
+
[customerId]
|
|
1092
|
+
);
|
|
1093
|
+
await tx.query(
|
|
1094
|
+
`UPDATE fonderie_customer_phones SET is_primary = true WHERE id = $1 AND customer_id = $2`,
|
|
1095
|
+
[phoneId, customerId]
|
|
1096
|
+
);
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
async remove(phoneId, customerId) {
|
|
1100
|
+
await this.store.transaction(async (tx) => {
|
|
1101
|
+
const [deleted] = await tx.query(
|
|
1102
|
+
`DELETE FROM fonderie_customer_phones
|
|
1103
|
+
WHERE id = $1 AND customer_id = $2
|
|
1104
|
+
RETURNING is_primary AS "isPrimary"`,
|
|
1105
|
+
[phoneId, customerId]
|
|
1106
|
+
);
|
|
1107
|
+
if (deleted?.isPrimary) {
|
|
1108
|
+
await tx.query(
|
|
1109
|
+
`UPDATE fonderie_customer_phones
|
|
1110
|
+
SET is_primary = true
|
|
1111
|
+
WHERE id = (
|
|
1112
|
+
SELECT id FROM fonderie_customer_phones
|
|
1113
|
+
WHERE customer_id = $1
|
|
1114
|
+
ORDER BY created_at ASC
|
|
1115
|
+
LIMIT 1
|
|
1116
|
+
)`,
|
|
1117
|
+
[customerId]
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
// src/models/customer-tag.model.ts
|
|
1125
|
+
var CustomerTagModel = class {
|
|
1126
|
+
constructor(store) {
|
|
1127
|
+
this.store = store;
|
|
1128
|
+
}
|
|
1129
|
+
store;
|
|
1130
|
+
async list(customerId) {
|
|
1131
|
+
const rows = await this.store.query(
|
|
1132
|
+
`SELECT tag FROM fonderie_customer_tags
|
|
1133
|
+
WHERE customer_id = $1
|
|
1134
|
+
ORDER BY tag ASC`,
|
|
1135
|
+
[customerId]
|
|
1136
|
+
);
|
|
1137
|
+
return rows.map((r) => r.tag);
|
|
1138
|
+
}
|
|
1139
|
+
async add(customerId, tag) {
|
|
1140
|
+
await this.store.query(
|
|
1141
|
+
`INSERT INTO fonderie_customer_tags (customer_id, tag)
|
|
1142
|
+
VALUES ($1, $2)
|
|
1143
|
+
ON CONFLICT (customer_id, tag) DO NOTHING`,
|
|
1144
|
+
[customerId, tag]
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
async remove(customerId, tag) {
|
|
1148
|
+
await this.store.query(
|
|
1149
|
+
`DELETE FROM fonderie_customer_tags
|
|
1150
|
+
WHERE customer_id = $1 AND tag = $2`,
|
|
1151
|
+
[customerId, tag]
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
// src/routes.ts
|
|
1157
|
+
import { requireAuth } from "@fonderie/core/middlewares";
|
|
1158
|
+
import { withWorkspace } from "@fonderie/workspaces";
|
|
1159
|
+
|
|
1160
|
+
// src/controllers/customer.controller.ts
|
|
1161
|
+
import { HTTP, setApiResponse } from "@fonderie/core";
|
|
1162
|
+
|
|
1163
|
+
// src/utils.ts
|
|
1164
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1165
|
+
function isUuid(value) {
|
|
1166
|
+
return typeof value === "string" && UUID_RE.test(value);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// src/controllers/customer.controller.ts
|
|
1170
|
+
function customerController(store, config = {}, bus) {
|
|
1171
|
+
const customers = new CustomerModel(store);
|
|
1172
|
+
const prefix = (config.referenceCodePrefix ?? DEFAULT_REFERENCE_CODE_PREFIX).toUpperCase();
|
|
1173
|
+
return {
|
|
1174
|
+
async list(ctx) {
|
|
1175
|
+
const workspaceId = ctx.workspace?.id;
|
|
1176
|
+
if (!workspaceId) {
|
|
1177
|
+
return setApiResponse(
|
|
1178
|
+
HTTP.BAD_REQUEST,
|
|
1179
|
+
"MISSING_WORKSPACE",
|
|
1180
|
+
"Workspace context is required"
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
const query = ctx.request.url ? new URL(ctx.request.url).searchParams : null;
|
|
1184
|
+
const search = query?.get("search") ?? void 0;
|
|
1185
|
+
const archived = query?.get("blacklisted");
|
|
1186
|
+
const limit = Number(query?.get("limit") ?? 50);
|
|
1187
|
+
const offset = Number(query?.get("offset") ?? 0);
|
|
1188
|
+
const listOpts = {
|
|
1189
|
+
workspaceId,
|
|
1190
|
+
limit: Number.isFinite(limit) ? limit : 50,
|
|
1191
|
+
offset: Number.isFinite(offset) ? offset : 0
|
|
1192
|
+
};
|
|
1193
|
+
if (search !== void 0) {
|
|
1194
|
+
listOpts.search = search;
|
|
1195
|
+
}
|
|
1196
|
+
if (archived !== null && archived !== void 0) {
|
|
1197
|
+
listOpts.blacklisted = archived === "true" || archived === "1";
|
|
1198
|
+
}
|
|
1199
|
+
const list = await customers.list(listOpts);
|
|
1200
|
+
return setApiResponse(HTTP.OK, "CUSTOMERS_FETCHED", "Customers retrieved successfully.", {
|
|
1201
|
+
customers: list.map(toCustomerDTO)
|
|
1202
|
+
});
|
|
1203
|
+
},
|
|
1204
|
+
async get(ctx) {
|
|
1205
|
+
const workspaceId = ctx.workspace?.id;
|
|
1206
|
+
if (!workspaceId) {
|
|
1207
|
+
return setApiResponse(
|
|
1208
|
+
HTTP.BAD_REQUEST,
|
|
1209
|
+
"MISSING_WORKSPACE",
|
|
1210
|
+
"Workspace context is required"
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
const params = ctx.meta["params"];
|
|
1214
|
+
const id = params?.["customerId"];
|
|
1215
|
+
if (!isUuid(id)) {
|
|
1216
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "customerId must be a valid UUID");
|
|
1217
|
+
}
|
|
1218
|
+
const query = ctx.request.url ? new URL(ctx.request.url).searchParams : null;
|
|
1219
|
+
const depth = query?.get("depth") === "1" ? 1 : 2;
|
|
1220
|
+
if (depth === 2) {
|
|
1221
|
+
const customer2 = await customers.findDetail(id, workspaceId, 2);
|
|
1222
|
+
if (!customer2) return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Customer not found");
|
|
1223
|
+
return setApiResponse(HTTP.OK, "CUSTOMER_FETCHED", "Customer retrieved successfully.", toCustomerDetailD2DTO(customer2));
|
|
1224
|
+
}
|
|
1225
|
+
const customer = await customers.findDetail(id, workspaceId);
|
|
1226
|
+
if (!customer) {
|
|
1227
|
+
return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Customer not found");
|
|
1228
|
+
}
|
|
1229
|
+
return setApiResponse(HTTP.OK, "CUSTOMER_FETCHED", "Customer retrieved successfully.", toCustomerDetailDTO(customer));
|
|
1230
|
+
},
|
|
1231
|
+
async create(ctx) {
|
|
1232
|
+
const workspaceId = ctx.workspace?.id;
|
|
1233
|
+
if (!workspaceId) {
|
|
1234
|
+
return setApiResponse(
|
|
1235
|
+
HTTP.BAD_REQUEST,
|
|
1236
|
+
"MISSING_WORKSPACE",
|
|
1237
|
+
"Workspace context is required"
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
const body = ctx.meta["body"];
|
|
1241
|
+
const type = body?.["type"];
|
|
1242
|
+
const sex = body?.["sex"];
|
|
1243
|
+
const firstName = body?.["firstName"];
|
|
1244
|
+
const lastName = body?.["lastName"];
|
|
1245
|
+
const companyName = body?.["companyName"];
|
|
1246
|
+
const avatarUrl = body?.["avatarUrl"];
|
|
1247
|
+
const locale = body?.["locale"];
|
|
1248
|
+
const referenceCode = body?.["referenceCode"];
|
|
1249
|
+
if (type !== void 0 && type !== "individual" && type !== "business") {
|
|
1250
|
+
return setApiResponse(
|
|
1251
|
+
HTTP.UNPROCESSABLE,
|
|
1252
|
+
"INVALID_PARAMETER",
|
|
1253
|
+
"type must be individual or business"
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
if (sex !== void 0 && sex !== "UNKNOWN" && sex !== "MALE" && sex !== "FEMALE") {
|
|
1257
|
+
return setApiResponse(
|
|
1258
|
+
HTTP.UNPROCESSABLE,
|
|
1259
|
+
"INVALID_PARAMETER",
|
|
1260
|
+
"sex must be UNKNOWN, MALE, or FEMALE"
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
let customer;
|
|
1264
|
+
try {
|
|
1265
|
+
customer = await customers.create({
|
|
1266
|
+
workspaceId,
|
|
1267
|
+
type: typeof type === "string" ? type : "individual",
|
|
1268
|
+
sex: typeof sex === "string" ? sex : "UNKNOWN",
|
|
1269
|
+
firstName: typeof firstName === "string" ? firstName : null,
|
|
1270
|
+
lastName: typeof lastName === "string" ? lastName : null,
|
|
1271
|
+
companyName: typeof companyName === "string" ? companyName : null,
|
|
1272
|
+
avatarUrl: typeof avatarUrl === "string" ? avatarUrl : null,
|
|
1273
|
+
locale: typeof locale === "string" ? locale : "en-US",
|
|
1274
|
+
referenceCode: typeof referenceCode === "string" ? referenceCode.toUpperCase() : void 0,
|
|
1275
|
+
referenceCodePrefix: prefix,
|
|
1276
|
+
createdBy: ctx.user?.id ?? null
|
|
1277
|
+
});
|
|
1278
|
+
} catch (err) {
|
|
1279
|
+
if (err instanceof Error && err.message.includes("idx_fc_reference_code")) {
|
|
1280
|
+
return setApiResponse(HTTP.CONFLICT, "DUPLICATE_REFERENCE_CODE", "A customer with this reference code already exists");
|
|
1281
|
+
}
|
|
1282
|
+
throw err;
|
|
1283
|
+
}
|
|
1284
|
+
bus?.emit(EVENT_KEYS.customerCreated, {
|
|
1285
|
+
customerId: customer.id,
|
|
1286
|
+
workspaceId: customer.workspaceId
|
|
1287
|
+
}).catch(() => {
|
|
1288
|
+
});
|
|
1289
|
+
return setApiResponse(HTTP.CREATED, "CUSTOMER_CREATED", "Customer created successfully.", {
|
|
1290
|
+
customer: toCustomerDTO(customer)
|
|
1291
|
+
});
|
|
1292
|
+
},
|
|
1293
|
+
async update(ctx) {
|
|
1294
|
+
const workspaceId = ctx.workspace?.id;
|
|
1295
|
+
if (!workspaceId) {
|
|
1296
|
+
return setApiResponse(
|
|
1297
|
+
HTTP.BAD_REQUEST,
|
|
1298
|
+
"MISSING_WORKSPACE",
|
|
1299
|
+
"Workspace context is required"
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
const params = ctx.meta["params"];
|
|
1303
|
+
const id = params?.["customerId"];
|
|
1304
|
+
if (!isUuid(id)) {
|
|
1305
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "customerId must be a valid UUID");
|
|
1306
|
+
}
|
|
1307
|
+
const body = ctx.meta["body"];
|
|
1308
|
+
const opts = {};
|
|
1309
|
+
if (body?.["type"] !== void 0) {
|
|
1310
|
+
const t = body["type"];
|
|
1311
|
+
if (t !== "individual" && t !== "business") {
|
|
1312
|
+
return setApiResponse(
|
|
1313
|
+
HTTP.UNPROCESSABLE,
|
|
1314
|
+
"INVALID_PARAMETER",
|
|
1315
|
+
"type must be individual or business"
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
opts.type = t;
|
|
1319
|
+
}
|
|
1320
|
+
if (body?.["sex"] !== void 0) {
|
|
1321
|
+
const s = body["sex"];
|
|
1322
|
+
if (s !== "UNKNOWN" && s !== "MALE" && s !== "FEMALE") {
|
|
1323
|
+
return setApiResponse(
|
|
1324
|
+
HTTP.UNPROCESSABLE,
|
|
1325
|
+
"INVALID_PARAMETER",
|
|
1326
|
+
"sex must be UNKNOWN, MALE, or FEMALE"
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
opts.sex = s;
|
|
1330
|
+
}
|
|
1331
|
+
if (body?.["firstName"] !== void 0) {
|
|
1332
|
+
opts.firstName = typeof body["firstName"] === "string" ? body["firstName"] : null;
|
|
1333
|
+
}
|
|
1334
|
+
if (body?.["lastName"] !== void 0) {
|
|
1335
|
+
opts.lastName = typeof body["lastName"] === "string" ? body["lastName"] : null;
|
|
1336
|
+
}
|
|
1337
|
+
if (body?.["companyName"] !== void 0) {
|
|
1338
|
+
opts.companyName = typeof body["companyName"] === "string" ? body["companyName"] : null;
|
|
1339
|
+
}
|
|
1340
|
+
if (body?.["avatarUrl"] !== void 0) {
|
|
1341
|
+
opts.avatarUrl = typeof body["avatarUrl"] === "string" ? body["avatarUrl"] : null;
|
|
1342
|
+
}
|
|
1343
|
+
if (body?.["locale"] !== void 0 && typeof body["locale"] === "string") {
|
|
1344
|
+
opts.locale = body["locale"];
|
|
1345
|
+
}
|
|
1346
|
+
if (body?.["referenceCode"] !== void 0 && typeof body["referenceCode"] === "string") {
|
|
1347
|
+
opts.referenceCode = body["referenceCode"].toUpperCase();
|
|
1348
|
+
}
|
|
1349
|
+
const customer = await customers.update(id, workspaceId, opts, prefix);
|
|
1350
|
+
if (!customer) {
|
|
1351
|
+
return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Customer not found");
|
|
1352
|
+
}
|
|
1353
|
+
bus?.emit(EVENT_KEYS.customerUpdated, {
|
|
1354
|
+
customerId: customer.id,
|
|
1355
|
+
workspaceId: customer.workspaceId
|
|
1356
|
+
}).catch(() => {
|
|
1357
|
+
});
|
|
1358
|
+
return setApiResponse(HTTP.OK, "CUSTOMER_UPDATED", "Customer updated successfully.", {
|
|
1359
|
+
customer: toCustomerDTO(customer)
|
|
1360
|
+
});
|
|
1361
|
+
},
|
|
1362
|
+
async delete(ctx) {
|
|
1363
|
+
const workspaceId = ctx.workspace?.id;
|
|
1364
|
+
if (!workspaceId) {
|
|
1365
|
+
return setApiResponse(
|
|
1366
|
+
HTTP.BAD_REQUEST,
|
|
1367
|
+
"MISSING_WORKSPACE",
|
|
1368
|
+
"Workspace context is required"
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
const params = ctx.meta["params"];
|
|
1372
|
+
const id = params?.["customerId"];
|
|
1373
|
+
if (!isUuid(id)) {
|
|
1374
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "customerId must be a valid UUID");
|
|
1375
|
+
}
|
|
1376
|
+
const existing = await customers.findById(id, workspaceId);
|
|
1377
|
+
if (!existing) {
|
|
1378
|
+
return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Customer not found");
|
|
1379
|
+
}
|
|
1380
|
+
await customers.delete(id, workspaceId);
|
|
1381
|
+
bus?.emit(EVENT_KEYS.customerDeleted, {
|
|
1382
|
+
customerId: id,
|
|
1383
|
+
workspaceId
|
|
1384
|
+
}).catch(() => {
|
|
1385
|
+
});
|
|
1386
|
+
return setApiResponse(HTTP.OK, "CUSTOMER_DELETED", "Customer deleted successfully.");
|
|
1387
|
+
},
|
|
1388
|
+
async blacklist(ctx) {
|
|
1389
|
+
const workspaceId = ctx.workspace?.id;
|
|
1390
|
+
if (!workspaceId) {
|
|
1391
|
+
return setApiResponse(
|
|
1392
|
+
HTTP.BAD_REQUEST,
|
|
1393
|
+
"MISSING_WORKSPACE",
|
|
1394
|
+
"Workspace context is required"
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
const params = ctx.meta["params"];
|
|
1398
|
+
const id = params?.["customerId"];
|
|
1399
|
+
if (!isUuid(id)) {
|
|
1400
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "customerId must be a valid UUID");
|
|
1401
|
+
}
|
|
1402
|
+
const existing = await customers.findById(id, workspaceId);
|
|
1403
|
+
if (!existing) {
|
|
1404
|
+
return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Customer not found");
|
|
1405
|
+
}
|
|
1406
|
+
const body = ctx.meta["body"];
|
|
1407
|
+
const reason = typeof body?.["reason"] === "string" ? body["reason"].trim() || null : null;
|
|
1408
|
+
await customers.blacklist(id, workspaceId, reason);
|
|
1409
|
+
bus?.emit(EVENT_KEYS.customerBlacklisted, {
|
|
1410
|
+
customerId: id,
|
|
1411
|
+
workspaceId
|
|
1412
|
+
}).catch(() => {
|
|
1413
|
+
});
|
|
1414
|
+
return setApiResponse(HTTP.OK, "CUSTOMER_BLACKLISTED", "Customer blacklisted successfully.");
|
|
1415
|
+
},
|
|
1416
|
+
async unblacklist(ctx) {
|
|
1417
|
+
const workspaceId = ctx.workspace?.id;
|
|
1418
|
+
if (!workspaceId) {
|
|
1419
|
+
return setApiResponse(
|
|
1420
|
+
HTTP.BAD_REQUEST,
|
|
1421
|
+
"MISSING_WORKSPACE",
|
|
1422
|
+
"Workspace context is required"
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
const params = ctx.meta["params"];
|
|
1426
|
+
const id = params?.["customerId"];
|
|
1427
|
+
if (!isUuid(id)) {
|
|
1428
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "customerId must be a valid UUID");
|
|
1429
|
+
}
|
|
1430
|
+
const existing = await customers.findById(id, workspaceId);
|
|
1431
|
+
if (!existing) {
|
|
1432
|
+
return setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "Customer not found");
|
|
1433
|
+
}
|
|
1434
|
+
await customers.unblacklist(id, workspaceId);
|
|
1435
|
+
bus?.emit(EVENT_KEYS.customerUnblacklisted, {
|
|
1436
|
+
customerId: id,
|
|
1437
|
+
workspaceId
|
|
1438
|
+
}).catch(() => {
|
|
1439
|
+
});
|
|
1440
|
+
return setApiResponse(HTTP.OK, "CUSTOMER_UNBLACKLISTED", "Customer removed from blacklist successfully.");
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// src/controllers/customer-address.controller.ts
|
|
1446
|
+
import { HTTP as HTTP2, setApiResponse as setApiResponse2 } from "@fonderie/core";
|
|
1447
|
+
function customerAddressController(store) {
|
|
1448
|
+
const customers = new CustomerModel(store);
|
|
1449
|
+
const addresses = new CustomerAddressModel(store);
|
|
1450
|
+
const labels = new CustomerLabelModel(store);
|
|
1451
|
+
async function resolveCustomer(ctx) {
|
|
1452
|
+
const workspaceId = ctx.workspace?.id;
|
|
1453
|
+
if (!workspaceId) {
|
|
1454
|
+
return {
|
|
1455
|
+
error: setApiResponse2(
|
|
1456
|
+
HTTP2.BAD_REQUEST,
|
|
1457
|
+
"MISSING_WORKSPACE",
|
|
1458
|
+
"Workspace context is required"
|
|
1459
|
+
)
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
const params = ctx.meta["params"];
|
|
1463
|
+
const id = params?.["customerId"];
|
|
1464
|
+
if (!isUuid(id)) {
|
|
1465
|
+
return { error: setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "customerId must be a valid UUID") };
|
|
1466
|
+
}
|
|
1467
|
+
const customer = await customers.findById(id, workspaceId);
|
|
1468
|
+
if (!customer) {
|
|
1469
|
+
return { error: setApiResponse2(HTTP2.NOT_FOUND, "NOT_FOUND", "Customer not found") };
|
|
1470
|
+
}
|
|
1471
|
+
return { customer, workspaceId };
|
|
1472
|
+
}
|
|
1473
|
+
return {
|
|
1474
|
+
async list(ctx) {
|
|
1475
|
+
const r = await resolveCustomer(ctx);
|
|
1476
|
+
if ("error" in r) {
|
|
1477
|
+
return r.error;
|
|
1478
|
+
}
|
|
1479
|
+
const list = await addresses.list(r.customer.id);
|
|
1480
|
+
return setApiResponse2(HTTP2.OK, "ADDRESSES_FETCHED", "Addresses retrieved successfully.", {
|
|
1481
|
+
addresses: list.map(toCustomerAddressDTO)
|
|
1482
|
+
});
|
|
1483
|
+
},
|
|
1484
|
+
async add(ctx) {
|
|
1485
|
+
const r = await resolveCustomer(ctx);
|
|
1486
|
+
if ("error" in r) {
|
|
1487
|
+
return r.error;
|
|
1488
|
+
}
|
|
1489
|
+
const body = ctx.meta["body"];
|
|
1490
|
+
const countryIso = body?.["countryIso"];
|
|
1491
|
+
const zipPostalCode = body?.["zipPostalCode"];
|
|
1492
|
+
if (typeof countryIso !== "string" || countryIso.trim().length === 0) {
|
|
1493
|
+
return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "countryIso is required");
|
|
1494
|
+
}
|
|
1495
|
+
if (typeof zipPostalCode !== "string" || zipPostalCode.trim().length === 0) {
|
|
1496
|
+
return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "zipPostalCode is required");
|
|
1497
|
+
}
|
|
1498
|
+
const rawLabel = typeof body?.["label"] === "string" ? body["label"] : "service";
|
|
1499
|
+
const { id: labelId } = await labels.findOrCreate("address", rawLabel);
|
|
1500
|
+
let created;
|
|
1501
|
+
try {
|
|
1502
|
+
created = await addresses.add({
|
|
1503
|
+
customerId: r.customer.id,
|
|
1504
|
+
countryIso: countryIso.trim(),
|
|
1505
|
+
zipPostalCode: zipPostalCode.trim(),
|
|
1506
|
+
subdivision1Iso: typeof body?.["subdivision1Iso"] === "string" ? body["subdivision1Iso"] : null,
|
|
1507
|
+
subdivision2Iso: typeof body?.["subdivision2Iso"] === "string" ? body["subdivision2Iso"] : null,
|
|
1508
|
+
unit: typeof body?.["unit"] === "string" ? body["unit"] : null,
|
|
1509
|
+
line1: typeof body?.["line1"] === "string" ? body["line1"] : null,
|
|
1510
|
+
line2: typeof body?.["line2"] === "string" ? body["line2"] : null,
|
|
1511
|
+
labelId,
|
|
1512
|
+
isPrimary: body?.["isPrimary"] === true
|
|
1513
|
+
});
|
|
1514
|
+
} catch (err) {
|
|
1515
|
+
if (err instanceof Error && err.code === "DUPLICATE_ADDRESS") {
|
|
1516
|
+
return setApiResponse2(HTTP2.CONFLICT, "DUPLICATE_ADDRESS", "This address is already linked to this customer");
|
|
1517
|
+
}
|
|
1518
|
+
throw err;
|
|
1519
|
+
}
|
|
1520
|
+
return setApiResponse2(HTTP2.CREATED, "ADDRESS_ADDED", "Address added successfully.", {
|
|
1521
|
+
address: toCustomerAddressDTO(created)
|
|
1522
|
+
});
|
|
1523
|
+
},
|
|
1524
|
+
async update(ctx) {
|
|
1525
|
+
const r = await resolveCustomer(ctx);
|
|
1526
|
+
if ("error" in r) return r.error;
|
|
1527
|
+
const params = ctx.meta["params"];
|
|
1528
|
+
const addrId = params?.["addrId"];
|
|
1529
|
+
if (!isUuid(addrId)) {
|
|
1530
|
+
return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "addrId must be a valid UUID");
|
|
1531
|
+
}
|
|
1532
|
+
const body = ctx.meta["body"];
|
|
1533
|
+
if (typeof body?.["label"] !== "string" || body["label"].trim().length === 0) {
|
|
1534
|
+
return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "label is required");
|
|
1535
|
+
}
|
|
1536
|
+
try {
|
|
1537
|
+
const { id: labelId } = await labels.findOrCreate("address", body["label"].trim());
|
|
1538
|
+
const updated = await addresses.updateLabel(addrId, r.customer.id, labelId);
|
|
1539
|
+
return setApiResponse2(HTTP2.OK, "ADDRESS_UPDATED", "Address updated successfully.", {
|
|
1540
|
+
address: toCustomerAddressDTO(updated)
|
|
1541
|
+
});
|
|
1542
|
+
} catch (err) {
|
|
1543
|
+
if (err instanceof Error && err.code === "NOT_FOUND") {
|
|
1544
|
+
return setApiResponse2(HTTP2.NOT_FOUND, "NOT_FOUND", "Address not found");
|
|
1545
|
+
}
|
|
1546
|
+
throw err;
|
|
1547
|
+
}
|
|
1548
|
+
},
|
|
1549
|
+
async setPrimary(ctx) {
|
|
1550
|
+
const r = await resolveCustomer(ctx);
|
|
1551
|
+
if ("error" in r) {
|
|
1552
|
+
return r.error;
|
|
1553
|
+
}
|
|
1554
|
+
const params = ctx.meta["params"];
|
|
1555
|
+
const addrId = params?.["addrId"];
|
|
1556
|
+
if (!isUuid(addrId)) {
|
|
1557
|
+
return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "addrId must be a valid UUID");
|
|
1558
|
+
}
|
|
1559
|
+
await addresses.setPrimary(addrId, r.customer.id);
|
|
1560
|
+
return setApiResponse2(HTTP2.OK, "ADDRESS_PRIMARY_SET", "Primary address updated successfully.");
|
|
1561
|
+
},
|
|
1562
|
+
async remove(ctx) {
|
|
1563
|
+
const r = await resolveCustomer(ctx);
|
|
1564
|
+
if ("error" in r) {
|
|
1565
|
+
return r.error;
|
|
1566
|
+
}
|
|
1567
|
+
const params = ctx.meta["params"];
|
|
1568
|
+
const addrId = params?.["addrId"];
|
|
1569
|
+
if (!isUuid(addrId)) {
|
|
1570
|
+
return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "addrId must be a valid UUID");
|
|
1571
|
+
}
|
|
1572
|
+
await addresses.remove(addrId, r.customer.id);
|
|
1573
|
+
return setApiResponse2(HTTP2.OK, "ADDRESS_REMOVED", "Address removed successfully.");
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// src/controllers/customer-email.controller.ts
|
|
1579
|
+
import { HTTP as HTTP3, setApiResponse as setApiResponse3 } from "@fonderie/core";
|
|
1580
|
+
function customerEmailController(store) {
|
|
1581
|
+
const customers = new CustomerModel(store);
|
|
1582
|
+
const emails = new CustomerEmailModel(store);
|
|
1583
|
+
const labels = new CustomerLabelModel(store);
|
|
1584
|
+
async function resolveCustomer(ctx) {
|
|
1585
|
+
const workspaceId = ctx.workspace?.id;
|
|
1586
|
+
if (!workspaceId) {
|
|
1587
|
+
return {
|
|
1588
|
+
error: setApiResponse3(
|
|
1589
|
+
HTTP3.BAD_REQUEST,
|
|
1590
|
+
"MISSING_WORKSPACE",
|
|
1591
|
+
"Workspace context is required"
|
|
1592
|
+
)
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
const params = ctx.meta["params"];
|
|
1596
|
+
const id = params?.["customerId"];
|
|
1597
|
+
if (!isUuid(id)) {
|
|
1598
|
+
return { error: setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "customerId must be a valid UUID") };
|
|
1599
|
+
}
|
|
1600
|
+
const customer = await customers.findById(id, workspaceId);
|
|
1601
|
+
if (!customer) {
|
|
1602
|
+
return { error: setApiResponse3(HTTP3.NOT_FOUND, "NOT_FOUND", "Customer not found") };
|
|
1603
|
+
}
|
|
1604
|
+
return { customer, workspaceId };
|
|
1605
|
+
}
|
|
1606
|
+
return {
|
|
1607
|
+
async list(ctx) {
|
|
1608
|
+
const r = await resolveCustomer(ctx);
|
|
1609
|
+
if ("error" in r) {
|
|
1610
|
+
return r.error;
|
|
1611
|
+
}
|
|
1612
|
+
const list = await emails.list(r.customer.id);
|
|
1613
|
+
return setApiResponse3(HTTP3.OK, "EMAILS_FETCHED", "Emails retrieved successfully.", {
|
|
1614
|
+
emails: list.map(toCustomerEmailDTO)
|
|
1615
|
+
});
|
|
1616
|
+
},
|
|
1617
|
+
async add(ctx) {
|
|
1618
|
+
const r = await resolveCustomer(ctx);
|
|
1619
|
+
if ("error" in r) {
|
|
1620
|
+
return r.error;
|
|
1621
|
+
}
|
|
1622
|
+
const body = ctx.meta["body"];
|
|
1623
|
+
const email = body?.["email"];
|
|
1624
|
+
if (typeof email !== "string" || email.trim().length === 0) {
|
|
1625
|
+
return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "email is required");
|
|
1626
|
+
}
|
|
1627
|
+
const rawLabel = typeof body?.["label"] === "string" ? body["label"] : "work";
|
|
1628
|
+
const isPrimary = body?.["isPrimary"] === true;
|
|
1629
|
+
const { id: labelId } = await labels.findOrCreate("email", rawLabel);
|
|
1630
|
+
let created;
|
|
1631
|
+
try {
|
|
1632
|
+
created = await emails.add({
|
|
1633
|
+
customerId: r.customer.id,
|
|
1634
|
+
email: email.trim(),
|
|
1635
|
+
labelId,
|
|
1636
|
+
isPrimary
|
|
1637
|
+
});
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
if (err instanceof Error && err.message.includes("idx_fce_unique_email")) {
|
|
1640
|
+
return setApiResponse3(HTTP3.CONFLICT, "DUPLICATE_EMAIL", "This email is already linked to this customer");
|
|
1641
|
+
}
|
|
1642
|
+
throw err;
|
|
1643
|
+
}
|
|
1644
|
+
return setApiResponse3(HTTP3.CREATED, "EMAIL_ADDED", "Email added successfully.", {
|
|
1645
|
+
email: toCustomerEmailDTO(created)
|
|
1646
|
+
});
|
|
1647
|
+
},
|
|
1648
|
+
async update(ctx) {
|
|
1649
|
+
const r = await resolveCustomer(ctx);
|
|
1650
|
+
if ("error" in r) return r.error;
|
|
1651
|
+
const params = ctx.meta["params"];
|
|
1652
|
+
const emailId = params?.["emailId"];
|
|
1653
|
+
if (!isUuid(emailId)) {
|
|
1654
|
+
return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "emailId must be a valid UUID");
|
|
1655
|
+
}
|
|
1656
|
+
const body = ctx.meta["body"];
|
|
1657
|
+
if (typeof body?.["label"] !== "string" || body["label"].trim().length === 0) {
|
|
1658
|
+
return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "label is required");
|
|
1659
|
+
}
|
|
1660
|
+
try {
|
|
1661
|
+
const { id: labelId } = await labels.findOrCreate("email", body["label"].trim());
|
|
1662
|
+
const updated = await emails.updateLabel(emailId, r.customer.id, labelId);
|
|
1663
|
+
return setApiResponse3(HTTP3.OK, "EMAIL_UPDATED", "Email updated successfully.", {
|
|
1664
|
+
email: toCustomerEmailDTO(updated)
|
|
1665
|
+
});
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
if (err instanceof Error && err.code === "NOT_FOUND") {
|
|
1668
|
+
return setApiResponse3(HTTP3.NOT_FOUND, "NOT_FOUND", "Email not found");
|
|
1669
|
+
}
|
|
1670
|
+
throw err;
|
|
1671
|
+
}
|
|
1672
|
+
},
|
|
1673
|
+
async setPrimary(ctx) {
|
|
1674
|
+
const r = await resolveCustomer(ctx);
|
|
1675
|
+
if ("error" in r) {
|
|
1676
|
+
return r.error;
|
|
1677
|
+
}
|
|
1678
|
+
const params = ctx.meta["params"];
|
|
1679
|
+
const emailId = params?.["emailId"];
|
|
1680
|
+
if (!isUuid(emailId)) {
|
|
1681
|
+
return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "emailId must be a valid UUID");
|
|
1682
|
+
}
|
|
1683
|
+
await emails.setPrimary(emailId, r.customer.id);
|
|
1684
|
+
return setApiResponse3(HTTP3.OK, "EMAIL_PRIMARY_SET", "Primary email updated successfully.");
|
|
1685
|
+
},
|
|
1686
|
+
async remove(ctx) {
|
|
1687
|
+
const r = await resolveCustomer(ctx);
|
|
1688
|
+
if ("error" in r) {
|
|
1689
|
+
return r.error;
|
|
1690
|
+
}
|
|
1691
|
+
const params = ctx.meta["params"];
|
|
1692
|
+
const emailId = params?.["emailId"];
|
|
1693
|
+
if (!isUuid(emailId)) {
|
|
1694
|
+
return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "emailId must be a valid UUID");
|
|
1695
|
+
}
|
|
1696
|
+
await emails.remove(emailId, r.customer.id);
|
|
1697
|
+
return setApiResponse3(HTTP3.OK, "EMAIL_REMOVED", "Email removed successfully.");
|
|
1698
|
+
}
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// src/controllers/customer-note.controller.ts
|
|
1703
|
+
import { HTTP as HTTP4, setApiResponse as setApiResponse4 } from "@fonderie/core";
|
|
1704
|
+
function customerNoteController(store) {
|
|
1705
|
+
const customers = new CustomerModel(store);
|
|
1706
|
+
const notes = new CustomerNoteModel(store);
|
|
1707
|
+
async function resolveCustomer(ctx) {
|
|
1708
|
+
const workspaceId = ctx.workspace?.id;
|
|
1709
|
+
if (!workspaceId)
|
|
1710
|
+
return {
|
|
1711
|
+
error: setApiResponse4(
|
|
1712
|
+
HTTP4.BAD_REQUEST,
|
|
1713
|
+
"MISSING_WORKSPACE",
|
|
1714
|
+
"Workspace context is required"
|
|
1715
|
+
)
|
|
1716
|
+
};
|
|
1717
|
+
const params = ctx.meta["params"];
|
|
1718
|
+
const id = params?.["customerId"];
|
|
1719
|
+
if (!isUuid(id))
|
|
1720
|
+
return { error: setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "customerId must be a valid UUID") };
|
|
1721
|
+
const customer = await customers.findById(id, workspaceId);
|
|
1722
|
+
if (!customer)
|
|
1723
|
+
return { error: setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Customer not found") };
|
|
1724
|
+
return { customer, workspaceId };
|
|
1725
|
+
}
|
|
1726
|
+
return {
|
|
1727
|
+
async list(ctx) {
|
|
1728
|
+
const r = await resolveCustomer(ctx);
|
|
1729
|
+
if ("error" in r) return r.error;
|
|
1730
|
+
const list = await notes.list(r.customer.id);
|
|
1731
|
+
return setApiResponse4(HTTP4.OK, "NOTES_FETCHED", "Notes retrieved successfully.", {
|
|
1732
|
+
notes: list.map(toCustomerNoteDTO)
|
|
1733
|
+
});
|
|
1734
|
+
},
|
|
1735
|
+
async create(ctx) {
|
|
1736
|
+
const r = await resolveCustomer(ctx);
|
|
1737
|
+
if ("error" in r) return r.error;
|
|
1738
|
+
const body = ctx.meta["body"];
|
|
1739
|
+
const bodyText = body?.["body"];
|
|
1740
|
+
if (typeof bodyText !== "string" || bodyText.trim().length === 0) {
|
|
1741
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "body is required");
|
|
1742
|
+
}
|
|
1743
|
+
const note = await notes.create({
|
|
1744
|
+
customerId: r.customer.id,
|
|
1745
|
+
authorId: ctx.user?.id ?? null,
|
|
1746
|
+
body: bodyText.trim()
|
|
1747
|
+
});
|
|
1748
|
+
return setApiResponse4(HTTP4.CREATED, "NOTE_CREATED", "Note created successfully.", {
|
|
1749
|
+
note: toCustomerNoteDTO(note)
|
|
1750
|
+
});
|
|
1751
|
+
},
|
|
1752
|
+
async update(ctx) {
|
|
1753
|
+
const r = await resolveCustomer(ctx);
|
|
1754
|
+
if ("error" in r) return r.error;
|
|
1755
|
+
const params = ctx.meta["params"];
|
|
1756
|
+
const noteId = params?.["noteId"];
|
|
1757
|
+
if (!isUuid(noteId)) {
|
|
1758
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "noteId must be a valid UUID");
|
|
1759
|
+
}
|
|
1760
|
+
const body = ctx.meta["body"];
|
|
1761
|
+
const bodyText = body?.["body"];
|
|
1762
|
+
if (typeof bodyText !== "string" || bodyText.trim().length === 0) {
|
|
1763
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "body is required");
|
|
1764
|
+
}
|
|
1765
|
+
const note = await notes.update(noteId, r.customer.id, bodyText.trim());
|
|
1766
|
+
if (!note) {
|
|
1767
|
+
return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Note not found");
|
|
1768
|
+
}
|
|
1769
|
+
return setApiResponse4(HTTP4.OK, "NOTE_UPDATED", "Note updated successfully.", {
|
|
1770
|
+
note: toCustomerNoteDTO(note)
|
|
1771
|
+
});
|
|
1772
|
+
},
|
|
1773
|
+
async delete(ctx) {
|
|
1774
|
+
const r = await resolveCustomer(ctx);
|
|
1775
|
+
if ("error" in r) return r.error;
|
|
1776
|
+
const params = ctx.meta["params"];
|
|
1777
|
+
const noteId = params?.["noteId"];
|
|
1778
|
+
if (!isUuid(noteId)) {
|
|
1779
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "noteId must be a valid UUID");
|
|
1780
|
+
}
|
|
1781
|
+
await notes.delete(noteId, r.customer.id);
|
|
1782
|
+
return setApiResponse4(HTTP4.OK, "NOTE_DELETED", "Note deleted successfully.");
|
|
1783
|
+
}
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// src/controllers/customer-phone.controller.ts
|
|
1788
|
+
import { HTTP as HTTP5, setApiResponse as setApiResponse5 } from "@fonderie/core";
|
|
1789
|
+
function customerPhoneController(store) {
|
|
1790
|
+
const customers = new CustomerModel(store);
|
|
1791
|
+
const phones = new CustomerPhoneModel(store);
|
|
1792
|
+
const labels = new CustomerLabelModel(store);
|
|
1793
|
+
async function resolveCustomer(ctx) {
|
|
1794
|
+
const workspaceId = ctx.workspace?.id;
|
|
1795
|
+
if (!workspaceId) {
|
|
1796
|
+
return {
|
|
1797
|
+
error: setApiResponse5(
|
|
1798
|
+
HTTP5.BAD_REQUEST,
|
|
1799
|
+
"MISSING_WORKSPACE",
|
|
1800
|
+
"Workspace context is required"
|
|
1801
|
+
)
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
const params = ctx.meta["params"];
|
|
1805
|
+
const id = params?.["customerId"];
|
|
1806
|
+
if (!isUuid(id)) {
|
|
1807
|
+
return { error: setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "customerId must be a valid UUID") };
|
|
1808
|
+
}
|
|
1809
|
+
const customer = await customers.findById(id, workspaceId);
|
|
1810
|
+
if (!customer) {
|
|
1811
|
+
return { error: setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "Customer not found") };
|
|
1812
|
+
}
|
|
1813
|
+
return { customer, workspaceId };
|
|
1814
|
+
}
|
|
1815
|
+
return {
|
|
1816
|
+
async list(ctx) {
|
|
1817
|
+
const r = await resolveCustomer(ctx);
|
|
1818
|
+
if ("error" in r) {
|
|
1819
|
+
return r.error;
|
|
1820
|
+
}
|
|
1821
|
+
const list = await phones.list(r.customer.id);
|
|
1822
|
+
return setApiResponse5(HTTP5.OK, "PHONES_FETCHED", "Phones retrieved successfully.", {
|
|
1823
|
+
phones: list.map(toCustomerPhoneDTO)
|
|
1824
|
+
});
|
|
1825
|
+
},
|
|
1826
|
+
async add(ctx) {
|
|
1827
|
+
const r = await resolveCustomer(ctx);
|
|
1828
|
+
if ("error" in r) {
|
|
1829
|
+
return r.error;
|
|
1830
|
+
}
|
|
1831
|
+
const body = ctx.meta["body"];
|
|
1832
|
+
const phone = body?.["phone"];
|
|
1833
|
+
if (typeof phone !== "string" || phone.trim().length === 0) {
|
|
1834
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "phone is required");
|
|
1835
|
+
}
|
|
1836
|
+
const rawLabel = typeof body?.["label"] === "string" ? body["label"] : "mobile";
|
|
1837
|
+
const isPrimary = body?.["isPrimary"] === true;
|
|
1838
|
+
const { id: labelId } = await labels.findOrCreate("phone", rawLabel);
|
|
1839
|
+
let created;
|
|
1840
|
+
try {
|
|
1841
|
+
created = await phones.add({
|
|
1842
|
+
customerId: r.customer.id,
|
|
1843
|
+
phone: phone.trim(),
|
|
1844
|
+
labelId,
|
|
1845
|
+
isPrimary
|
|
1846
|
+
});
|
|
1847
|
+
} catch (err) {
|
|
1848
|
+
if (err instanceof Error && err.message.includes("idx_fcp_unique_phone")) {
|
|
1849
|
+
return setApiResponse5(HTTP5.CONFLICT, "DUPLICATE_PHONE", "This phone is already linked to this customer");
|
|
1850
|
+
}
|
|
1851
|
+
throw err;
|
|
1852
|
+
}
|
|
1853
|
+
return setApiResponse5(HTTP5.CREATED, "PHONE_ADDED", "Phone added successfully.", {
|
|
1854
|
+
phone: toCustomerPhoneDTO(created)
|
|
1855
|
+
});
|
|
1856
|
+
},
|
|
1857
|
+
async update(ctx) {
|
|
1858
|
+
const r = await resolveCustomer(ctx);
|
|
1859
|
+
if ("error" in r) return r.error;
|
|
1860
|
+
const params = ctx.meta["params"];
|
|
1861
|
+
const phoneId = params?.["phoneId"];
|
|
1862
|
+
if (!isUuid(phoneId)) {
|
|
1863
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "phoneId must be a valid UUID");
|
|
1864
|
+
}
|
|
1865
|
+
const body = ctx.meta["body"];
|
|
1866
|
+
if (typeof body?.["label"] !== "string" || body["label"].trim().length === 0) {
|
|
1867
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "label is required");
|
|
1868
|
+
}
|
|
1869
|
+
try {
|
|
1870
|
+
const { id: labelId } = await labels.findOrCreate("phone", body["label"].trim());
|
|
1871
|
+
const updated = await phones.updateLabel(phoneId, r.customer.id, labelId);
|
|
1872
|
+
return setApiResponse5(HTTP5.OK, "PHONE_UPDATED", "Phone updated successfully.", {
|
|
1873
|
+
phone: toCustomerPhoneDTO(updated)
|
|
1874
|
+
});
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
if (err instanceof Error && err.code === "NOT_FOUND") {
|
|
1877
|
+
return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "Phone not found");
|
|
1878
|
+
}
|
|
1879
|
+
throw err;
|
|
1880
|
+
}
|
|
1881
|
+
},
|
|
1882
|
+
async setPrimary(ctx) {
|
|
1883
|
+
const r = await resolveCustomer(ctx);
|
|
1884
|
+
if ("error" in r) {
|
|
1885
|
+
return r.error;
|
|
1886
|
+
}
|
|
1887
|
+
const params = ctx.meta["params"];
|
|
1888
|
+
const phoneId = params?.["phoneId"];
|
|
1889
|
+
if (!isUuid(phoneId)) {
|
|
1890
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "phoneId must be a valid UUID");
|
|
1891
|
+
}
|
|
1892
|
+
await phones.setPrimary(phoneId, r.customer.id);
|
|
1893
|
+
return setApiResponse5(HTTP5.OK, "PHONE_PRIMARY_SET", "Primary phone updated successfully.");
|
|
1894
|
+
},
|
|
1895
|
+
async remove(ctx) {
|
|
1896
|
+
const r = await resolveCustomer(ctx);
|
|
1897
|
+
if ("error" in r) {
|
|
1898
|
+
return r.error;
|
|
1899
|
+
}
|
|
1900
|
+
const params = ctx.meta["params"];
|
|
1901
|
+
const phoneId = params?.["phoneId"];
|
|
1902
|
+
if (!isUuid(phoneId)) {
|
|
1903
|
+
return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "phoneId must be a valid UUID");
|
|
1904
|
+
}
|
|
1905
|
+
await phones.remove(phoneId, r.customer.id);
|
|
1906
|
+
return setApiResponse5(HTTP5.OK, "PHONE_REMOVED", "Phone removed successfully.");
|
|
1907
|
+
}
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// src/controllers/customer-tag.controller.ts
|
|
1912
|
+
import { HTTP as HTTP6, setApiResponse as setApiResponse6 } from "@fonderie/core";
|
|
1913
|
+
function customerTagController(store) {
|
|
1914
|
+
const customers = new CustomerModel(store);
|
|
1915
|
+
const tags = new CustomerTagModel(store);
|
|
1916
|
+
async function resolveCustomer(ctx) {
|
|
1917
|
+
const workspaceId = ctx.workspace?.id;
|
|
1918
|
+
if (!workspaceId)
|
|
1919
|
+
return {
|
|
1920
|
+
error: setApiResponse6(
|
|
1921
|
+
HTTP6.BAD_REQUEST,
|
|
1922
|
+
"MISSING_WORKSPACE",
|
|
1923
|
+
"Workspace context is required"
|
|
1924
|
+
)
|
|
1925
|
+
};
|
|
1926
|
+
const params = ctx.meta["params"];
|
|
1927
|
+
const id = params?.["customerId"];
|
|
1928
|
+
if (!isUuid(id))
|
|
1929
|
+
return { error: setApiResponse6(HTTP6.UNPROCESSABLE, "INVALID_PARAMETER", "customerId must be a valid UUID") };
|
|
1930
|
+
const customer = await customers.findById(id, workspaceId);
|
|
1931
|
+
if (!customer)
|
|
1932
|
+
return { error: setApiResponse6(HTTP6.NOT_FOUND, "NOT_FOUND", "Customer not found") };
|
|
1933
|
+
return { customer, workspaceId };
|
|
1934
|
+
}
|
|
1935
|
+
return {
|
|
1936
|
+
async list(ctx) {
|
|
1937
|
+
const r = await resolveCustomer(ctx);
|
|
1938
|
+
if ("error" in r) return r.error;
|
|
1939
|
+
const list = await tags.list(r.customer.id);
|
|
1940
|
+
return setApiResponse6(HTTP6.OK, "TAGS_FETCHED", "Tags retrieved successfully.", {
|
|
1941
|
+
tags: list
|
|
1942
|
+
});
|
|
1943
|
+
},
|
|
1944
|
+
async add(ctx) {
|
|
1945
|
+
const r = await resolveCustomer(ctx);
|
|
1946
|
+
if ("error" in r) return r.error;
|
|
1947
|
+
const body = ctx.meta["body"];
|
|
1948
|
+
const tag = body?.["tag"];
|
|
1949
|
+
if (typeof tag !== "string" || tag.trim().length === 0) {
|
|
1950
|
+
return setApiResponse6(HTTP6.UNPROCESSABLE, "INVALID_PARAMETER", "tag is required");
|
|
1951
|
+
}
|
|
1952
|
+
await tags.add(r.customer.id, tag.trim());
|
|
1953
|
+
return setApiResponse6(HTTP6.CREATED, "TAG_ADDED", "Tag added successfully.");
|
|
1954
|
+
},
|
|
1955
|
+
async remove(ctx) {
|
|
1956
|
+
const r = await resolveCustomer(ctx);
|
|
1957
|
+
if ("error" in r) return r.error;
|
|
1958
|
+
const params = ctx.meta["params"];
|
|
1959
|
+
const tag = params?.["tag"];
|
|
1960
|
+
if (!tag) {
|
|
1961
|
+
return setApiResponse6(HTTP6.UNPROCESSABLE, "INVALID_PARAMETER", "tag is required");
|
|
1962
|
+
}
|
|
1963
|
+
await tags.remove(r.customer.id, tag);
|
|
1964
|
+
return setApiResponse6(HTTP6.OK, "TAG_REMOVED", "Tag removed successfully.");
|
|
1965
|
+
}
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// src/controllers/customer-label.controller.ts
|
|
1970
|
+
import { HTTP as HTTP7, setApiResponse as setApiResponse7 } from "@fonderie/core";
|
|
1971
|
+
var VALID_TYPES = ["phone", "email", "address"];
|
|
1972
|
+
function customerLabelController(store) {
|
|
1973
|
+
const labels = new CustomerLabelModel(store);
|
|
1974
|
+
return {
|
|
1975
|
+
async list(ctx) {
|
|
1976
|
+
const query = ctx.request.url ? new URL(ctx.request.url).searchParams : null;
|
|
1977
|
+
const type = query?.get("type");
|
|
1978
|
+
if (!type || !VALID_TYPES.includes(type)) {
|
|
1979
|
+
return setApiResponse7(HTTP7.UNPROCESSABLE, "INVALID_PARAMETER", "type must be phone, email, or address");
|
|
1980
|
+
}
|
|
1981
|
+
const rows = await labels.list(type);
|
|
1982
|
+
return setApiResponse7(HTTP7.OK, "LABELS_FETCHED", "Labels retrieved successfully.", { labels: rows });
|
|
1983
|
+
},
|
|
1984
|
+
async remove(ctx) {
|
|
1985
|
+
const params = ctx.meta["params"];
|
|
1986
|
+
const labelId = params?.["labelId"];
|
|
1987
|
+
if (!isUuid(labelId)) {
|
|
1988
|
+
return setApiResponse7(HTTP7.UNPROCESSABLE, "INVALID_PARAMETER", "labelId must be a valid UUID");
|
|
1989
|
+
}
|
|
1990
|
+
await labels.remove(labelId);
|
|
1991
|
+
return setApiResponse7(HTTP7.OK, "LABEL_DELETED", "Label deleted successfully.");
|
|
1992
|
+
}
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// src/controllers/customer-relationship.controller.ts
|
|
1997
|
+
import { HTTP as HTTP8, setApiResponse as setApiResponse8 } from "@fonderie/core";
|
|
1998
|
+
function customerRelationshipController(store) {
|
|
1999
|
+
const customers = new CustomerModel(store);
|
|
2000
|
+
const relationships = new CustomerRelationshipModel(store);
|
|
2001
|
+
async function resolveCustomer(ctx) {
|
|
2002
|
+
const workspaceId = ctx.workspace?.id;
|
|
2003
|
+
if (!workspaceId) {
|
|
2004
|
+
return {
|
|
2005
|
+
error: setApiResponse8(HTTP8.BAD_REQUEST, "MISSING_WORKSPACE", "Workspace context is required")
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
const params = ctx.meta["params"];
|
|
2009
|
+
const id = params?.["customerId"];
|
|
2010
|
+
if (!isUuid(id)) {
|
|
2011
|
+
return { error: setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "customerId must be a valid UUID") };
|
|
2012
|
+
}
|
|
2013
|
+
const customer = await customers.findById(id, workspaceId);
|
|
2014
|
+
if (!customer) {
|
|
2015
|
+
return { error: setApiResponse8(HTTP8.NOT_FOUND, "NOT_FOUND", "Customer not found") };
|
|
2016
|
+
}
|
|
2017
|
+
return { customer, workspaceId };
|
|
2018
|
+
}
|
|
2019
|
+
return {
|
|
2020
|
+
async list(ctx) {
|
|
2021
|
+
const r = await resolveCustomer(ctx);
|
|
2022
|
+
if ("error" in r) return r.error;
|
|
2023
|
+
const list = await relationships.list(r.customer.id);
|
|
2024
|
+
return setApiResponse8(HTTP8.OK, "RELATIONSHIPS_FETCHED", "Relationships retrieved successfully.", {
|
|
2025
|
+
relationships: list.map(toCustomerRelationshipDTO)
|
|
2026
|
+
});
|
|
2027
|
+
},
|
|
2028
|
+
async add(ctx) {
|
|
2029
|
+
const r = await resolveCustomer(ctx);
|
|
2030
|
+
if ("error" in r) return r.error;
|
|
2031
|
+
const body = ctx.meta["body"];
|
|
2032
|
+
const relatedId = body?.["relatedId"];
|
|
2033
|
+
const relationship = body?.["relationship"];
|
|
2034
|
+
if (!isUuid(relatedId)) {
|
|
2035
|
+
return setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "relatedId must be a valid UUID");
|
|
2036
|
+
}
|
|
2037
|
+
if (typeof relationship !== "string" || relationship.trim().length === 0) {
|
|
2038
|
+
return setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "relationship is required");
|
|
2039
|
+
}
|
|
2040
|
+
if (relatedId === r.customer.id) {
|
|
2041
|
+
return setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "A customer cannot be related to itself");
|
|
2042
|
+
}
|
|
2043
|
+
const related = await customers.findById(relatedId, r.workspaceId);
|
|
2044
|
+
if (!related) {
|
|
2045
|
+
return setApiResponse8(HTTP8.NOT_FOUND, "NOT_FOUND", "Related customer not found");
|
|
2046
|
+
}
|
|
2047
|
+
const created = await relationships.add({
|
|
2048
|
+
workspaceId: r.workspaceId,
|
|
2049
|
+
customerId: r.customer.id,
|
|
2050
|
+
relatedId,
|
|
2051
|
+
relationship: relationship.trim(),
|
|
2052
|
+
isPrimary: body?.["isPrimary"] === true
|
|
2053
|
+
});
|
|
2054
|
+
return setApiResponse8(HTTP8.CREATED, "RELATIONSHIP_ADDED", "Relationship added successfully.", {
|
|
2055
|
+
relationship: toCustomerRelationshipDTO(created)
|
|
2056
|
+
});
|
|
2057
|
+
},
|
|
2058
|
+
async setPrimary(ctx) {
|
|
2059
|
+
const r = await resolveCustomer(ctx);
|
|
2060
|
+
if ("error" in r) return r.error;
|
|
2061
|
+
const params = ctx.meta["params"];
|
|
2062
|
+
const relatedId = params?.["relatedId"];
|
|
2063
|
+
if (!isUuid(relatedId)) {
|
|
2064
|
+
return setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "relatedId must be a valid UUID");
|
|
2065
|
+
}
|
|
2066
|
+
await relationships.setPrimary(r.customer.id, relatedId);
|
|
2067
|
+
return setApiResponse8(HTTP8.OK, "RELATIONSHIP_PRIMARY_SET", "Primary relationship updated successfully.");
|
|
2068
|
+
},
|
|
2069
|
+
async remove(ctx) {
|
|
2070
|
+
const r = await resolveCustomer(ctx);
|
|
2071
|
+
if ("error" in r) return r.error;
|
|
2072
|
+
const params = ctx.meta["params"];
|
|
2073
|
+
const relatedId = params?.["relatedId"];
|
|
2074
|
+
if (!isUuid(relatedId)) {
|
|
2075
|
+
return setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "relatedId must be a valid UUID");
|
|
2076
|
+
}
|
|
2077
|
+
await relationships.remove(r.customer.id, relatedId);
|
|
2078
|
+
return setApiResponse8(HTTP8.OK, "RELATIONSHIP_REMOVED", "Relationship removed successfully.");
|
|
2079
|
+
}
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
// src/routes.ts
|
|
2084
|
+
function buildCustomerRoutes(store, config, bus) {
|
|
2085
|
+
const wsCtx = withWorkspace(store);
|
|
2086
|
+
const customer = customerController(store, config, bus);
|
|
2087
|
+
const email = customerEmailController(store);
|
|
2088
|
+
const phone = customerPhoneController(store);
|
|
2089
|
+
const address = customerAddressController(store);
|
|
2090
|
+
const note = customerNoteController(store);
|
|
2091
|
+
const tag = customerTagController(store);
|
|
2092
|
+
const relationship = customerRelationshipController(store);
|
|
2093
|
+
const label = customerLabelController(store);
|
|
2094
|
+
return [
|
|
2095
|
+
// ── Labels ───────────────────────────────────────────────────────
|
|
2096
|
+
["GET", "/customers/labels", requireAuth, wsCtx, label.list],
|
|
2097
|
+
["DELETE", "/customers/labels/:labelId", requireAuth, wsCtx, label.remove],
|
|
2098
|
+
// ── Core customer CRUD ───────────────────────────────────────────
|
|
2099
|
+
["GET", "/customers", requireAuth, wsCtx, customer.list],
|
|
2100
|
+
["POST", "/customers", requireAuth, wsCtx, customer.create],
|
|
2101
|
+
["GET", "/customers/:customerId", requireAuth, wsCtx, customer.get],
|
|
2102
|
+
["PUT", "/customers/:customerId", requireAuth, wsCtx, customer.update],
|
|
2103
|
+
["DELETE", "/customers/:customerId", requireAuth, wsCtx, customer.delete],
|
|
2104
|
+
["POST", "/customers/:customerId/blacklist", requireAuth, wsCtx, customer.blacklist],
|
|
2105
|
+
["POST", "/customers/:customerId/unblacklist", requireAuth, wsCtx, customer.unblacklist],
|
|
2106
|
+
// ── Emails ───────────────────────────────────────────────────────
|
|
2107
|
+
["GET", "/customers/:customerId/emails", requireAuth, wsCtx, email.list],
|
|
2108
|
+
["POST", "/customers/:customerId/emails", requireAuth, wsCtx, email.add],
|
|
2109
|
+
["PATCH", "/customers/:customerId/emails/:emailId", requireAuth, wsCtx, email.update],
|
|
2110
|
+
["PUT", "/customers/:customerId/emails/:emailId/primary", requireAuth, wsCtx, email.setPrimary],
|
|
2111
|
+
["DELETE", "/customers/:customerId/emails/:emailId", requireAuth, wsCtx, email.remove],
|
|
2112
|
+
// ── Phones ───────────────────────────────────────────────────────
|
|
2113
|
+
["GET", "/customers/:customerId/phones", requireAuth, wsCtx, phone.list],
|
|
2114
|
+
["POST", "/customers/:customerId/phones", requireAuth, wsCtx, phone.add],
|
|
2115
|
+
["PATCH", "/customers/:customerId/phones/:phoneId", requireAuth, wsCtx, phone.update],
|
|
2116
|
+
["PUT", "/customers/:customerId/phones/:phoneId/primary", requireAuth, wsCtx, phone.setPrimary],
|
|
2117
|
+
["DELETE", "/customers/:customerId/phones/:phoneId", requireAuth, wsCtx, phone.remove],
|
|
2118
|
+
// ── Addresses ────────────────────────────────────────────────────
|
|
2119
|
+
["GET", "/customers/:customerId/addresses", requireAuth, wsCtx, address.list],
|
|
2120
|
+
["POST", "/customers/:customerId/addresses", requireAuth, wsCtx, address.add],
|
|
2121
|
+
["PATCH", "/customers/:customerId/addresses/:addrId", requireAuth, wsCtx, address.update],
|
|
2122
|
+
["PUT", "/customers/:customerId/addresses/:addrId/primary", requireAuth, wsCtx, address.setPrimary],
|
|
2123
|
+
["DELETE", "/customers/:customerId/addresses/:addrId", requireAuth, wsCtx, address.remove],
|
|
2124
|
+
// ── Notes ────────────────────────────────────────────────────────
|
|
2125
|
+
["GET", "/customers/:customerId/notes", requireAuth, wsCtx, note.list],
|
|
2126
|
+
["POST", "/customers/:customerId/notes", requireAuth, wsCtx, note.create],
|
|
2127
|
+
["PUT", "/customers/:customerId/notes/:noteId", requireAuth, wsCtx, note.update],
|
|
2128
|
+
["DELETE", "/customers/:customerId/notes/:noteId", requireAuth, wsCtx, note.delete],
|
|
2129
|
+
// ── Tags ─────────────────────────────────────────────────────────
|
|
2130
|
+
["GET", "/customers/:customerId/tags", requireAuth, wsCtx, tag.list],
|
|
2131
|
+
["POST", "/customers/:customerId/tags", requireAuth, wsCtx, tag.add],
|
|
2132
|
+
["DELETE", "/customers/:customerId/tags/:tag", requireAuth, wsCtx, tag.remove],
|
|
2133
|
+
// ── Relationships ────────────────────────────────────────────────────
|
|
2134
|
+
["GET", "/customers/:customerId/relationships", requireAuth, wsCtx, relationship.list],
|
|
2135
|
+
["POST", "/customers/:customerId/relationships", requireAuth, wsCtx, relationship.add],
|
|
2136
|
+
["PUT", "/customers/:customerId/relationships/:relatedId/primary", requireAuth, wsCtx, relationship.setPrimary],
|
|
2137
|
+
["DELETE", "/customers/:customerId/relationships/:relatedId", requireAuth, wsCtx, relationship.remove]
|
|
2138
|
+
];
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// src/module.ts
|
|
2142
|
+
var CustomersModule = class {
|
|
2143
|
+
constructor(store, config = {}, bus) {
|
|
2144
|
+
this.store = store;
|
|
2145
|
+
this.config = config;
|
|
2146
|
+
this.bus = bus;
|
|
2147
|
+
}
|
|
2148
|
+
store;
|
|
2149
|
+
config;
|
|
2150
|
+
bus;
|
|
2151
|
+
name = "@fonderie/customers";
|
|
2152
|
+
deps = ["@fonderie/workspaces"];
|
|
2153
|
+
install(app) {
|
|
2154
|
+
const routes = buildCustomerRoutes(this.store, this.config, this.bus);
|
|
2155
|
+
for (const [method, path, ...handlers] of routes) {
|
|
2156
|
+
app.addRoute(method, path, ...handlers);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
};
|
|
2160
|
+
export {
|
|
2161
|
+
CustomerAddressModel,
|
|
2162
|
+
CustomerEmailModel,
|
|
2163
|
+
CustomerModel,
|
|
2164
|
+
CustomerNoteModel,
|
|
2165
|
+
CustomerPhoneModel,
|
|
2166
|
+
CustomerTagModel,
|
|
2167
|
+
CustomersModule,
|
|
2168
|
+
EVENT_KEYS,
|
|
2169
|
+
toAddressDTO,
|
|
2170
|
+
toCustomerAddressDTO,
|
|
2171
|
+
toCustomerDTO,
|
|
2172
|
+
toCustomerDetailDTO,
|
|
2173
|
+
toCustomerEmailDTO,
|
|
2174
|
+
toCustomerNoteDTO,
|
|
2175
|
+
toCustomerPhoneDTO,
|
|
2176
|
+
toCustomerTagDTO
|
|
2177
|
+
};
|
|
2178
|
+
//# sourceMappingURL=index.js.map
|