@fonderie/customers 2.0.0 → 2.0.1
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/brain/outcomes.md +2 -0
- package/brain/signatures.md +5 -0
- package/dist/index.cjs +54 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +54 -3
- package/dist/index.js.map +1 -1
- 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/migrations/sql/012_referral_code.sql +18 -0
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +2 -0
- package/dist/types.d.ts +2 -0
- package/package.json +6 -6
package/dist/index.d.cts
CHANGED
|
@@ -28,6 +28,8 @@ interface ICustomerDTO {
|
|
|
28
28
|
avatarUrl: string;
|
|
29
29
|
locale: string;
|
|
30
30
|
referenceCode: string;
|
|
31
|
+
referralCode: string;
|
|
32
|
+
referredBy: string | null;
|
|
31
33
|
blacklisted: {
|
|
32
34
|
status: boolean;
|
|
33
35
|
reason: string | null;
|
|
@@ -125,6 +127,10 @@ interface CreateCustomerOpts {
|
|
|
125
127
|
referenceCode?: string;
|
|
126
128
|
/** Prefix used when auto-generating. Defaults to 'CLT'. */
|
|
127
129
|
referenceCodePrefix?: string;
|
|
130
|
+
/** Explicit referral code. Omit to auto-generate a random, workspace-unique one. */
|
|
131
|
+
referralCode?: string;
|
|
132
|
+
/** The referrer's referral code (from signup). Resolved to `referredBy` within the same workspace; ignored if it doesn't match a customer. */
|
|
133
|
+
referredByCode?: string;
|
|
128
134
|
createdBy?: string | null;
|
|
129
135
|
}
|
|
130
136
|
interface UpdateCustomerOpts {
|
|
@@ -142,6 +148,17 @@ declare class CustomerModel {
|
|
|
142
148
|
private readonly store;
|
|
143
149
|
constructor(store: IStoreAdapter);
|
|
144
150
|
private allocateCode;
|
|
151
|
+
/** A random referral code (crypto-random over the unambiguous alphabet). */
|
|
152
|
+
private randomReferralCode;
|
|
153
|
+
/**
|
|
154
|
+
* A referral code unique within the workspace. Random codes collide only
|
|
155
|
+
* astronomically rarely; we still pre-check and retry a few times, and the
|
|
156
|
+
* unique index is the final guard. Throws only if the space is somehow
|
|
157
|
+
* exhausted (not reachable in practice).
|
|
158
|
+
*/
|
|
159
|
+
private allocateReferralCode;
|
|
160
|
+
/** Resolve a referral code to the referring customer's id, within a workspace. */
|
|
161
|
+
resolveReferralCode(workspaceId: string, code: string): Promise<string | null>;
|
|
145
162
|
list(opts: ListCustomersOpts): Promise<ICustomer[]>;
|
|
146
163
|
findById(id: string, workspaceId: string): Promise<ICustomer | null>;
|
|
147
164
|
findDetail(id: string, workspaceId: string, depth: 2): Promise<ICustomerDetailD2 | null>;
|
|
@@ -251,6 +268,8 @@ declare const createCustomerSchema: z.ZodObject<{
|
|
|
251
268
|
avatarUrl: z.ZodOptional<z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodURL>>>;
|
|
252
269
|
locale: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
253
270
|
referenceCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
271
|
+
referralCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
272
|
+
referredByCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
254
273
|
}, z.core.$strip>;
|
|
255
274
|
declare const updateCustomerSchema: z.ZodObject<{
|
|
256
275
|
type: z.ZodOptional<z.ZodEnum<{
|
|
@@ -268,6 +287,8 @@ declare const updateCustomerSchema: z.ZodObject<{
|
|
|
268
287
|
avatarUrl: z.ZodOptional<z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodURL>>>;
|
|
269
288
|
locale: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
270
289
|
referenceCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
290
|
+
referralCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
291
|
+
referredByCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
271
292
|
}, z.core.$strip>;
|
|
272
293
|
declare const blacklistSchema: z.ZodObject<{
|
|
273
294
|
reason: z.ZodOptional<z.ZodString>;
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ interface ICustomerDTO {
|
|
|
28
28
|
avatarUrl: string;
|
|
29
29
|
locale: string;
|
|
30
30
|
referenceCode: string;
|
|
31
|
+
referralCode: string;
|
|
32
|
+
referredBy: string | null;
|
|
31
33
|
blacklisted: {
|
|
32
34
|
status: boolean;
|
|
33
35
|
reason: string | null;
|
|
@@ -125,6 +127,10 @@ interface CreateCustomerOpts {
|
|
|
125
127
|
referenceCode?: string;
|
|
126
128
|
/** Prefix used when auto-generating. Defaults to 'CLT'. */
|
|
127
129
|
referenceCodePrefix?: string;
|
|
130
|
+
/** Explicit referral code. Omit to auto-generate a random, workspace-unique one. */
|
|
131
|
+
referralCode?: string;
|
|
132
|
+
/** The referrer's referral code (from signup). Resolved to `referredBy` within the same workspace; ignored if it doesn't match a customer. */
|
|
133
|
+
referredByCode?: string;
|
|
128
134
|
createdBy?: string | null;
|
|
129
135
|
}
|
|
130
136
|
interface UpdateCustomerOpts {
|
|
@@ -142,6 +148,17 @@ declare class CustomerModel {
|
|
|
142
148
|
private readonly store;
|
|
143
149
|
constructor(store: IStoreAdapter);
|
|
144
150
|
private allocateCode;
|
|
151
|
+
/** A random referral code (crypto-random over the unambiguous alphabet). */
|
|
152
|
+
private randomReferralCode;
|
|
153
|
+
/**
|
|
154
|
+
* A referral code unique within the workspace. Random codes collide only
|
|
155
|
+
* astronomically rarely; we still pre-check and retry a few times, and the
|
|
156
|
+
* unique index is the final guard. Throws only if the space is somehow
|
|
157
|
+
* exhausted (not reachable in practice).
|
|
158
|
+
*/
|
|
159
|
+
private allocateReferralCode;
|
|
160
|
+
/** Resolve a referral code to the referring customer's id, within a workspace. */
|
|
161
|
+
resolveReferralCode(workspaceId: string, code: string): Promise<string | null>;
|
|
145
162
|
list(opts: ListCustomersOpts): Promise<ICustomer[]>;
|
|
146
163
|
findById(id: string, workspaceId: string): Promise<ICustomer | null>;
|
|
147
164
|
findDetail(id: string, workspaceId: string, depth: 2): Promise<ICustomerDetailD2 | null>;
|
|
@@ -251,6 +268,8 @@ declare const createCustomerSchema: z.ZodObject<{
|
|
|
251
268
|
avatarUrl: z.ZodOptional<z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodURL>>>;
|
|
252
269
|
locale: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
253
270
|
referenceCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
271
|
+
referralCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
272
|
+
referredByCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
254
273
|
}, z.core.$strip>;
|
|
255
274
|
declare const updateCustomerSchema: z.ZodObject<{
|
|
256
275
|
type: z.ZodOptional<z.ZodEnum<{
|
|
@@ -268,6 +287,8 @@ declare const updateCustomerSchema: z.ZodObject<{
|
|
|
268
287
|
avatarUrl: z.ZodOptional<z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodURL>>>;
|
|
269
288
|
locale: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
270
289
|
referenceCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
290
|
+
referralCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
291
|
+
referredByCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
271
292
|
}, z.core.$strip>;
|
|
272
293
|
declare const blacklistSchema: z.ZodObject<{
|
|
273
294
|
reason: z.ZodOptional<z.ZodString>;
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,8 @@ var EVENT_KEYS = {
|
|
|
13
13
|
customerUnblacklisted: "fonderie.customer.unblacklisted"
|
|
14
14
|
};
|
|
15
15
|
var DEFAULT_REFERENCE_CODE_PREFIX = "CLT";
|
|
16
|
+
var REFERRAL_CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
|
|
17
|
+
var REFERRAL_CODE_LENGTH = 8;
|
|
16
18
|
|
|
17
19
|
// src/dtos/customer.ts
|
|
18
20
|
import { arrayOrEmpty, booleanOrFalse, dateOrEmpty, stringOrEmpty } from "@fonderie/core";
|
|
@@ -28,6 +30,8 @@ function toCustomerDTO(c) {
|
|
|
28
30
|
avatarUrl: stringOrEmpty(c.avatarUrl),
|
|
29
31
|
locale: stringOrEmpty(c.locale),
|
|
30
32
|
referenceCode: stringOrEmpty(c.referenceCode),
|
|
33
|
+
referralCode: stringOrEmpty(c.referralCode),
|
|
34
|
+
referredBy: c.referredBy ?? null,
|
|
31
35
|
blacklisted: { status: booleanOrFalse(c.isBlacklisted), reason: c.blacklistReason ?? null },
|
|
32
36
|
createdBy: stringOrEmpty(c.createdBy),
|
|
33
37
|
createdAt: dateOrEmpty(c.createdAt),
|
|
@@ -149,6 +153,7 @@ function toCustomerTagDTO(t) {
|
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
// src/models/customer.model.ts
|
|
156
|
+
import { randomInt } from "crypto";
|
|
152
157
|
function groupByCustomer(rows) {
|
|
153
158
|
return rows.reduce((m, r) => {
|
|
154
159
|
if (!m.has(r.customerId)) m.set(r.customerId, []);
|
|
@@ -167,6 +172,8 @@ var SELECT_CUSTOMER = `
|
|
|
167
172
|
avatar_url AS "avatarUrl",
|
|
168
173
|
locale,
|
|
169
174
|
reference_code AS "referenceCode",
|
|
175
|
+
referral_code AS "referralCode",
|
|
176
|
+
referred_by AS "referredBy",
|
|
170
177
|
is_blacklisted AS "isBlacklisted",
|
|
171
178
|
blacklist_reason AS "blacklistReason",
|
|
172
179
|
created_by AS "createdBy",
|
|
@@ -189,6 +196,39 @@ var CustomerModel = class {
|
|
|
189
196
|
);
|
|
190
197
|
return `${prefix}-${String(row.nextVal).padStart(4, "0")}`;
|
|
191
198
|
}
|
|
199
|
+
/** A random referral code (crypto-random over the unambiguous alphabet). */
|
|
200
|
+
randomReferralCode() {
|
|
201
|
+
let out = "";
|
|
202
|
+
for (let i = 0; i < REFERRAL_CODE_LENGTH; i++) {
|
|
203
|
+
out += REFERRAL_CODE_ALPHABET[randomInt(REFERRAL_CODE_ALPHABET.length)];
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* A referral code unique within the workspace. Random codes collide only
|
|
209
|
+
* astronomically rarely; we still pre-check and retry a few times, and the
|
|
210
|
+
* unique index is the final guard. Throws only if the space is somehow
|
|
211
|
+
* exhausted (not reachable in practice).
|
|
212
|
+
*/
|
|
213
|
+
async allocateReferralCode(workspaceId) {
|
|
214
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
215
|
+
const code = this.randomReferralCode();
|
|
216
|
+
const [hit] = await this.store.query(
|
|
217
|
+
`SELECT 1 AS one FROM fonderie_customers WHERE workspace_id = $1 AND referral_code = $2 LIMIT 1`,
|
|
218
|
+
[workspaceId, code]
|
|
219
|
+
);
|
|
220
|
+
if (!hit) return code;
|
|
221
|
+
}
|
|
222
|
+
throw new Error("could not allocate a unique referral code");
|
|
223
|
+
}
|
|
224
|
+
/** Resolve a referral code to the referring customer's id, within a workspace. */
|
|
225
|
+
async resolveReferralCode(workspaceId, code) {
|
|
226
|
+
const [row] = await this.store.query(
|
|
227
|
+
`SELECT id FROM fonderie_customers WHERE workspace_id = $1 AND referral_code = $2 LIMIT 1`,
|
|
228
|
+
[workspaceId, code]
|
|
229
|
+
);
|
|
230
|
+
return row?.id ?? null;
|
|
231
|
+
}
|
|
192
232
|
async list(opts) {
|
|
193
233
|
const conditions = ["workspace_id = $1"];
|
|
194
234
|
const params = [opts.workspaceId];
|
|
@@ -504,10 +544,12 @@ var CustomerModel = class {
|
|
|
504
544
|
}
|
|
505
545
|
async create(opts) {
|
|
506
546
|
const referenceCode = opts.referenceCode ?? await this.allocateCode(opts.workspaceId, opts.referenceCodePrefix ?? DEFAULT_REFERENCE_CODE_PREFIX);
|
|
547
|
+
const referralCode = opts.referralCode ?? await this.allocateReferralCode(opts.workspaceId);
|
|
548
|
+
const referredBy = opts.referredByCode ? await this.resolveReferralCode(opts.workspaceId, opts.referredByCode) : null;
|
|
507
549
|
const [row] = await this.store.query(
|
|
508
550
|
`INSERT INTO fonderie_customers
|
|
509
|
-
(workspace_id, type, sex, first_name, last_name, company_name, avatar_url, locale, reference_code, created_by)
|
|
510
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
551
|
+
(workspace_id, type, sex, first_name, last_name, company_name, avatar_url, locale, reference_code, referral_code, referred_by, created_by)
|
|
552
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
511
553
|
RETURNING ${SELECT_CUSTOMER}`,
|
|
512
554
|
[
|
|
513
555
|
opts.workspaceId,
|
|
@@ -519,6 +561,8 @@ var CustomerModel = class {
|
|
|
519
561
|
opts.avatarUrl ?? null,
|
|
520
562
|
opts.locale ?? "en-US",
|
|
521
563
|
referenceCode,
|
|
564
|
+
referralCode,
|
|
565
|
+
referredBy,
|
|
522
566
|
opts.createdBy ?? null
|
|
523
567
|
]
|
|
524
568
|
);
|
|
@@ -1191,7 +1235,9 @@ var customerFields = {
|
|
|
1191
1235
|
companyName: z.string().max(200).nullable().optional(),
|
|
1192
1236
|
avatarUrl: z.string().trim().pipe(z.url()).nullable().optional(),
|
|
1193
1237
|
locale: z.string().max(35).nullable().optional(),
|
|
1194
|
-
referenceCode: z.string().max(100).nullable().optional()
|
|
1238
|
+
referenceCode: z.string().max(100).nullable().optional(),
|
|
1239
|
+
referralCode: z.string().max(100).nullable().optional(),
|
|
1240
|
+
referredByCode: z.string().max(100).nullable().optional()
|
|
1195
1241
|
};
|
|
1196
1242
|
var createCustomerSchema = z.object(customerFields);
|
|
1197
1243
|
var updateCustomerSchema = z.object(customerFields).refine((o) => Object.values(o).some((v) => v !== void 0), "Provide at least one field");
|
|
@@ -1313,6 +1359,8 @@ function customerController(store, config = {}, bus) {
|
|
|
1313
1359
|
const avatarUrl = body?.["avatarUrl"];
|
|
1314
1360
|
const locale = body?.["locale"];
|
|
1315
1361
|
const referenceCode = body?.["referenceCode"];
|
|
1362
|
+
const referralCode = body?.["referralCode"];
|
|
1363
|
+
const referredByCode = body?.["referredByCode"];
|
|
1316
1364
|
if (type !== void 0 && type !== "individual" && type !== "business") {
|
|
1317
1365
|
return setApiResponse(
|
|
1318
1366
|
HTTP.UNPROCESSABLE,
|
|
@@ -1341,6 +1389,9 @@ function customerController(store, config = {}, bus) {
|
|
|
1341
1389
|
// exactOptionalPropertyTypes: omit the key entirely when absent
|
|
1342
1390
|
...typeof referenceCode === "string" ? { referenceCode: referenceCode.toUpperCase() } : {},
|
|
1343
1391
|
referenceCodePrefix: prefix,
|
|
1392
|
+
// referral: explicit code override is rare; referredByCode is the signup input
|
|
1393
|
+
...typeof referralCode === "string" ? { referralCode: referralCode.toUpperCase() } : {},
|
|
1394
|
+
...typeof referredByCode === "string" ? { referredByCode: referredByCode.toUpperCase() } : {},
|
|
1344
1395
|
createdBy: ctx.user?.id ?? null
|
|
1345
1396
|
});
|
|
1346
1397
|
} catch (err) {
|