@checodo/better-auth-referral 0.0.0-stage → 0.1.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/CHANGELOG.md +7 -0
- package/LICENSE.md +21 -0
- package/README.md +53 -2
- package/dist/client.d.ts +41 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +39 -0
- package/dist/client.js.map +1 -0
- package/dist/error-codes-Bw554PRu.js +12 -0
- package/dist/error-codes-Bw554PRu.js.map +1 -0
- package/dist/index-HDtManYT.d.ts +777 -0
- package/dist/index-HDtManYT.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +957 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
import { t as REFERRAL_ERROR_CODES } from "./error-codes-Bw554PRu.js";
|
|
2
|
+
import { mergeSchema } from "better-auth/db";
|
|
3
|
+
import { getCurrentAdapter, queueAfterTransactionHook, tryGetCurrentAuthEndpointContext } from "@better-auth/core/context";
|
|
4
|
+
import { APIError, createAuthEndpoint, getOAuthState, sessionMiddleware } from "better-auth/api";
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
// package.json
|
|
7
|
+
var version = "0.1.0";
|
|
8
|
+
// ../plugin-utils/src/admin.ts
|
|
9
|
+
/** Applies the defaults of `AdminOptions`. */
|
|
10
|
+
function resolveAdminOptions(options) {
|
|
11
|
+
return {
|
|
12
|
+
adminRoles: options.adminRoles ?? ["admin"],
|
|
13
|
+
adminUserIds: options.adminUserIds ?? []
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Whether `user` is listed in `adminUserIds`, or has one of `adminRoles`
|
|
18
|
+
* in the admin plugin's `role` field, which holds several roles
|
|
19
|
+
* comma-separated (`"user, admin"`).
|
|
20
|
+
*/
|
|
21
|
+
function isAdmin(user, options) {
|
|
22
|
+
if (options.adminUserIds.includes(user.id)) return true;
|
|
23
|
+
if (typeof user.role !== "string") return false;
|
|
24
|
+
return user.role.split(",").some((role) => options.adminRoles.includes(role.trim()));
|
|
25
|
+
}
|
|
26
|
+
// ../plugin-utils/src/options.ts
|
|
27
|
+
/**
|
|
28
|
+
* Returns a function that throws an option error prefixed with the plugin
|
|
29
|
+
* id, `[referral] "codeLength" must be ...`, for validating options at
|
|
30
|
+
* plugin creation.
|
|
31
|
+
*/
|
|
32
|
+
function createFail(pluginId) {
|
|
33
|
+
return (message) => {
|
|
34
|
+
throw new Error(`[${pluginId}] ${message}`);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// src/options.ts
|
|
38
|
+
/** The step every referral starts with. Not configurable, and not stored. */
|
|
39
|
+
const SIGN_UP_STEP = "sign-up";
|
|
40
|
+
/** A step the plugin records itself, when the referred user's email is verified. */
|
|
41
|
+
const EMAIL_VERIFIED_STEP = "email-verified";
|
|
42
|
+
const fail = createFail("referral");
|
|
43
|
+
/** Resolves defaults and validates the options once, at plugin creation. */
|
|
44
|
+
function resolveOptions(options) {
|
|
45
|
+
const codeLength = options.codeLength ?? 8;
|
|
46
|
+
if (!Number.isInteger(codeLength) || codeLength < 6 || codeLength > 32) fail(`"codeLength" must be an integer between 6 and 32, got ${codeLength}.`);
|
|
47
|
+
const max = options.maxReferralsPerUser;
|
|
48
|
+
if (max !== void 0 && (!Number.isInteger(max) || max < 1)) fail(`"maxReferralsPerUser" must be a positive integer, got ${max}.`);
|
|
49
|
+
const steps = [...options.steps ?? []];
|
|
50
|
+
for (const step of steps) {
|
|
51
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(step) || step.length > 64) fail(`Invalid step ${JSON.stringify(step)}: use letters, digits, "-", and "_", up to 64 characters.`);
|
|
52
|
+
if (step === "sign-up") fail(`"${SIGN_UP_STEP}" is always the first step; don't list it in "steps".`);
|
|
53
|
+
}
|
|
54
|
+
if (new Set(steps).size !== steps.length) fail(`"steps" lists a step twice.`);
|
|
55
|
+
return {
|
|
56
|
+
codeLength,
|
|
57
|
+
generateCode: options.generateCode,
|
|
58
|
+
createCodeOnSignUp: options.createCodeOnSignUp ?? false,
|
|
59
|
+
steps,
|
|
60
|
+
stepsConfigured: options.steps !== void 0,
|
|
61
|
+
maxReferralsPerUser: max,
|
|
62
|
+
invalidCode: options.invalidCode ?? "ignore",
|
|
63
|
+
allowReferral: options.allowReferral,
|
|
64
|
+
onReferral: options.onReferral,
|
|
65
|
+
onStepCompleted: options.onStepCompleted,
|
|
66
|
+
onReferralCompleted: options.onReferralCompleted,
|
|
67
|
+
maskEmails: options.maskEmails ?? true,
|
|
68
|
+
hideNames: options.hideNames ?? false,
|
|
69
|
+
...resolveAdminOptions(options)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Defaults that depend on the app's auth config, applied in the plugin's
|
|
74
|
+
* `init`: without explicit `steps`, apps that send verification emails
|
|
75
|
+
* require `email-verified`, so fake sign-ups don't complete referrals.
|
|
76
|
+
* Warns when referrals earn rewards but complete at sign-up.
|
|
77
|
+
*/
|
|
78
|
+
function applyAuthDefaults(options, ctx) {
|
|
79
|
+
if (!options.stepsConfigured && ctx.options.emailVerification?.sendVerificationEmail) options.steps = [EMAIL_VERIFIED_STEP];
|
|
80
|
+
if (!options.stepsConfigured && options.steps.length === 0 && options.onReferralCompleted) ctx.logger.warn("[referral] Referrals complete at sign-up (no \"steps\"), so fake sign-ups earn rewards. Require at least \"email-verified\" (see \"Steps and rewards\" in the referral plugin docs).");
|
|
81
|
+
}
|
|
82
|
+
// ../plugin-utils/src/audit.ts
|
|
83
|
+
/**
|
|
84
|
+
* Records an event in the audit log plugin, if the app uses it. The audit
|
|
85
|
+
* log is found through `getPlugin`, like official plugins find each other,
|
|
86
|
+
* so it isn't a dependency. Does nothing outside a request. Never throws:
|
|
87
|
+
* the audit log's `recordEvent` doesn't.
|
|
88
|
+
*/
|
|
89
|
+
async function recordAuditEvent(event) {
|
|
90
|
+
await (tryGetCurrentAuthEndpointContext()?.context.getPlugin("audit-log"))?.recordEvent?.(event);
|
|
91
|
+
}
|
|
92
|
+
// ../plugin-utils/src/callbacks.ts
|
|
93
|
+
/**
|
|
94
|
+
* Runs a user callback (`onJoin`, `onReferralCompleted`, ...) in the
|
|
95
|
+
* background when better-auth's `advanced.backgroundTasks` is configured,
|
|
96
|
+
* so response times don't depend on it, and awaits it otherwise. Errors
|
|
97
|
+
* are logged as `[<pluginId>] <name> threw an error` and never reach the
|
|
98
|
+
* client.
|
|
99
|
+
*/
|
|
100
|
+
async function runCallback(context, pluginId, name, callback) {
|
|
101
|
+
const task = (async () => {
|
|
102
|
+
try {
|
|
103
|
+
await callback();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
context.logger.error(`[${pluginId}] ${name} threw an error`, error);
|
|
106
|
+
}
|
|
107
|
+
})();
|
|
108
|
+
await context.runInBackgroundOrAwait(task);
|
|
109
|
+
}
|
|
110
|
+
// src/store.ts
|
|
111
|
+
async function findCodeByUserId(adapter, userId) {
|
|
112
|
+
return adapter.findOne({
|
|
113
|
+
model: "referralCode",
|
|
114
|
+
where: [{
|
|
115
|
+
field: "userId",
|
|
116
|
+
value: userId
|
|
117
|
+
}]
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async function findCodeByCode(adapter, code) {
|
|
121
|
+
return adapter.findOne({
|
|
122
|
+
model: "referralCode",
|
|
123
|
+
where: [{
|
|
124
|
+
field: "code",
|
|
125
|
+
value: code
|
|
126
|
+
}]
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async function createCode(adapter, input) {
|
|
130
|
+
return adapter.create({
|
|
131
|
+
model: "referralCode",
|
|
132
|
+
data: {
|
|
133
|
+
userId: input.userId,
|
|
134
|
+
code: input.code,
|
|
135
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async function replaceCode(adapter, id, code) {
|
|
140
|
+
return adapter.update({
|
|
141
|
+
model: "referralCode",
|
|
142
|
+
where: [{
|
|
143
|
+
field: "id",
|
|
144
|
+
value: id
|
|
145
|
+
}],
|
|
146
|
+
update: {
|
|
147
|
+
code,
|
|
148
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async function findReferralByReferredUserId(adapter, referredUserId) {
|
|
153
|
+
return adapter.findOne({
|
|
154
|
+
model: "referral",
|
|
155
|
+
where: [{
|
|
156
|
+
field: "referredUserId",
|
|
157
|
+
value: referredUserId
|
|
158
|
+
}]
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async function createReferral(adapter, input) {
|
|
162
|
+
const now = /* @__PURE__ */ new Date();
|
|
163
|
+
return adapter.create({
|
|
164
|
+
model: "referral",
|
|
165
|
+
data: {
|
|
166
|
+
...input,
|
|
167
|
+
completedAt: input.status === "completed" ? now : null,
|
|
168
|
+
createdAt: now
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
async function deleteReferral(adapter, id) {
|
|
173
|
+
await adapter.delete({
|
|
174
|
+
model: "referral",
|
|
175
|
+
where: [{
|
|
176
|
+
field: "id",
|
|
177
|
+
value: id
|
|
178
|
+
}]
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Number of the referrer's referrals made before `referral`: earlier ones,
|
|
183
|
+
* and those made in the same millisecond with a smaller id. Gives every
|
|
184
|
+
* referral of a referrer a fixed place, so concurrent sign-ups agree on
|
|
185
|
+
* which of them are over `maxReferralsPerUser`.
|
|
186
|
+
*/
|
|
187
|
+
async function countReferralsBefore(adapter, referral) {
|
|
188
|
+
const createdAt = new Date(referral.createdAt);
|
|
189
|
+
const referrer = {
|
|
190
|
+
field: "referrerUserId",
|
|
191
|
+
value: referral.referrerUserId
|
|
192
|
+
};
|
|
193
|
+
const [earlier, sameTime] = await Promise.all([adapter.count({
|
|
194
|
+
model: "referral",
|
|
195
|
+
where: [referrer, {
|
|
196
|
+
field: "createdAt",
|
|
197
|
+
value: createdAt,
|
|
198
|
+
operator: "lt"
|
|
199
|
+
}]
|
|
200
|
+
}), adapter.count({
|
|
201
|
+
model: "referral",
|
|
202
|
+
where: [
|
|
203
|
+
referrer,
|
|
204
|
+
{
|
|
205
|
+
field: "createdAt",
|
|
206
|
+
value: createdAt
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
field: "id",
|
|
210
|
+
value: referral.id,
|
|
211
|
+
operator: "lt"
|
|
212
|
+
}
|
|
213
|
+
]
|
|
214
|
+
})]);
|
|
215
|
+
return earlier + sameTime;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Marks a pending referral completed. Returns `null` if it wasn't pending
|
|
219
|
+
* (anymore), so only one caller gets to run `onReferralCompleted`.
|
|
220
|
+
*/
|
|
221
|
+
async function completeReferral(adapter, id) {
|
|
222
|
+
return adapter.update({
|
|
223
|
+
model: "referral",
|
|
224
|
+
where: [{
|
|
225
|
+
field: "id",
|
|
226
|
+
value: id
|
|
227
|
+
}, {
|
|
228
|
+
field: "status",
|
|
229
|
+
value: "pending"
|
|
230
|
+
}],
|
|
231
|
+
update: {
|
|
232
|
+
status: "completed",
|
|
233
|
+
completedAt: /* @__PURE__ */ new Date()
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
function referralFilters(input) {
|
|
238
|
+
const where = [];
|
|
239
|
+
if (input.referrerUserId) where.push({
|
|
240
|
+
field: "referrerUserId",
|
|
241
|
+
value: input.referrerUserId
|
|
242
|
+
});
|
|
243
|
+
if (input.status) where.push({
|
|
244
|
+
field: "status",
|
|
245
|
+
value: input.status
|
|
246
|
+
});
|
|
247
|
+
return where;
|
|
248
|
+
}
|
|
249
|
+
/** Newest first. */
|
|
250
|
+
async function listReferrals(adapter, input) {
|
|
251
|
+
return adapter.findMany({
|
|
252
|
+
model: "referral",
|
|
253
|
+
where: referralFilters(input),
|
|
254
|
+
limit: input.limit,
|
|
255
|
+
offset: input.offset,
|
|
256
|
+
sortBy: {
|
|
257
|
+
field: "createdAt",
|
|
258
|
+
direction: "desc"
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async function countReferrals(adapter, input) {
|
|
263
|
+
return adapter.count({
|
|
264
|
+
model: "referral",
|
|
265
|
+
where: referralFilters(input)
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
async function createStep(adapter, input) {
|
|
269
|
+
return adapter.create({
|
|
270
|
+
model: "referralStep",
|
|
271
|
+
data: {
|
|
272
|
+
referralId: input.referralId,
|
|
273
|
+
step: input.step,
|
|
274
|
+
metadata: input.metadata ?? null,
|
|
275
|
+
completedAt: /* @__PURE__ */ new Date()
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
async function findStep(adapter, referralId, step) {
|
|
280
|
+
return adapter.findOne({
|
|
281
|
+
model: "referralStep",
|
|
282
|
+
where: [{
|
|
283
|
+
field: "referralId",
|
|
284
|
+
value: referralId
|
|
285
|
+
}, {
|
|
286
|
+
field: "step",
|
|
287
|
+
value: step
|
|
288
|
+
}]
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/** The completed steps of these referrals, by referral id. */
|
|
292
|
+
async function stepsByReferral(adapter, referralIds) {
|
|
293
|
+
const result = new Map(referralIds.map((id) => [id, []]));
|
|
294
|
+
if (referralIds.length === 0) return result;
|
|
295
|
+
const steps = await adapter.findMany({
|
|
296
|
+
model: "referralStep",
|
|
297
|
+
where: [{
|
|
298
|
+
field: "referralId",
|
|
299
|
+
value: referralIds,
|
|
300
|
+
operator: "in"
|
|
301
|
+
}],
|
|
302
|
+
sortBy: {
|
|
303
|
+
field: "completedAt",
|
|
304
|
+
direction: "asc"
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
for (const { referralId, step } of steps) result.get(referralId)?.push(step);
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Whether a user can't refer anyone: deleted, or banned with better-auth's
|
|
312
|
+
* `admin` plugin (until `banExpires`, if set). Without the admin plugin,
|
|
313
|
+
* nobody is banned.
|
|
314
|
+
*/
|
|
315
|
+
async function isReferrerBlocked(adapter, userId) {
|
|
316
|
+
const user = await adapter.findOne({
|
|
317
|
+
model: "user",
|
|
318
|
+
where: [{
|
|
319
|
+
field: "id",
|
|
320
|
+
value: userId
|
|
321
|
+
}]
|
|
322
|
+
});
|
|
323
|
+
if (!user) return true;
|
|
324
|
+
if (user.banned !== true) return false;
|
|
325
|
+
return !user.banExpires || new Date(user.banExpires).getTime() > Date.now();
|
|
326
|
+
}
|
|
327
|
+
async function findUsers(adapter, ids) {
|
|
328
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
329
|
+
const users = await adapter.findMany({
|
|
330
|
+
model: "user",
|
|
331
|
+
where: [{
|
|
332
|
+
field: "id",
|
|
333
|
+
value: ids,
|
|
334
|
+
operator: "in"
|
|
335
|
+
}]
|
|
336
|
+
});
|
|
337
|
+
return new Map(users.map((user) => [user.id, user]));
|
|
338
|
+
}
|
|
339
|
+
// src/utils/code.ts
|
|
340
|
+
/**
|
|
341
|
+
* Consonants and digits. No look-alikes (0/O, 1/I/L), and no vowels
|
|
342
|
+
* (A, E, I, O, U, Y), so random codes can't spell words, offensive ones
|
|
343
|
+
* included.
|
|
344
|
+
*/
|
|
345
|
+
const CODE_ALPHABET = "BCDFGHJKMNPQRSTVWXZ23456789";
|
|
346
|
+
/** What a code may look like once normalized: letters, digits, `-`, `_`. */
|
|
347
|
+
const CODE_PATTERN = /^[A-Z0-9_-]{4,64}$/;
|
|
348
|
+
/** A random code of `length` characters from `CODE_ALPHABET`, without modulo bias. */
|
|
349
|
+
function generateCode(length) {
|
|
350
|
+
const limit = 243;
|
|
351
|
+
let code = "";
|
|
352
|
+
while (code.length < length) for (const byte of crypto.getRandomValues(new Uint8Array(length * 2))) {
|
|
353
|
+
if (byte >= limit) continue;
|
|
354
|
+
code += CODE_ALPHABET[byte % 27];
|
|
355
|
+
if (code.length === length) break;
|
|
356
|
+
}
|
|
357
|
+
return code;
|
|
358
|
+
}
|
|
359
|
+
/** Trims and uppercases a code, the way codes are stored and compared. */
|
|
360
|
+
function normalizeCode(code) {
|
|
361
|
+
return code.trim().toUpperCase();
|
|
362
|
+
}
|
|
363
|
+
/** Whether `code` (normalized) is a well-formed code. */
|
|
364
|
+
function isWellFormedCode(code) {
|
|
365
|
+
return CODE_PATTERN.test(code);
|
|
366
|
+
}
|
|
367
|
+
// src/referrals.ts
|
|
368
|
+
/** How often a colliding generated code is replaced before giving up. */
|
|
369
|
+
const MAX_CODE_ATTEMPTS = 5;
|
|
370
|
+
/** A new, unused, well-formed code from `generateCode` or the built-in generator. */
|
|
371
|
+
async function newCode(adapter, options) {
|
|
372
|
+
const code = normalizeCode(options.generateCode ? await options.generateCode() : generateCode(options.codeLength));
|
|
373
|
+
if (!isWellFormedCode(code)) throw new Error(`[referral] generateCode returned ${JSON.stringify(code)}: use 4 to 64 letters, digits, "-", or "_".`);
|
|
374
|
+
return await findCodeByCode(adapter, code) ? null : code;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* The code `userId` refers with, created on first use. Safe to call
|
|
378
|
+
* concurrently: a second creation hits the unique index and returns the
|
|
379
|
+
* code the first one stored.
|
|
380
|
+
*/
|
|
381
|
+
async function getOrCreateCode(adapter, options, userId) {
|
|
382
|
+
const existing = await findCodeByUserId(adapter, userId);
|
|
383
|
+
if (existing) return existing;
|
|
384
|
+
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
|
385
|
+
const code = await newCode(adapter, options);
|
|
386
|
+
if (!code) continue;
|
|
387
|
+
try {
|
|
388
|
+
return await createCode(adapter, {
|
|
389
|
+
userId,
|
|
390
|
+
code
|
|
391
|
+
});
|
|
392
|
+
} catch (error) {
|
|
393
|
+
const raced = await findCodeByUserId(adapter, userId);
|
|
394
|
+
if (raced) return raced;
|
|
395
|
+
if (!await findCodeByCode(adapter, code)) throw error;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
throw APIError.from("INTERNAL_SERVER_ERROR", REFERRAL_ERROR_CODES.CODE_GENERATION_FAILED);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Gives `userId` a new code; the old one stops working. Referrals made
|
|
402
|
+
* with the old code keep counting (they store the code they used).
|
|
403
|
+
*/
|
|
404
|
+
async function regenerateCode(adapter, options, userId) {
|
|
405
|
+
const existing = await findCodeByUserId(adapter, userId);
|
|
406
|
+
if (!existing) return getOrCreateCode(adapter, options, userId);
|
|
407
|
+
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
|
408
|
+
const code = await newCode(adapter, options);
|
|
409
|
+
if (!code) continue;
|
|
410
|
+
try {
|
|
411
|
+
return await replaceCode(adapter, existing.id, code) ?? {
|
|
412
|
+
...existing,
|
|
413
|
+
code
|
|
414
|
+
};
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (!await findCodeByCode(adapter, code)) throw error;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
throw APIError.from("INTERNAL_SERVER_ERROR", REFERRAL_ERROR_CODES.CODE_GENERATION_FAILED);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The stored code for `rawCode` if it can still refer someone: it exists,
|
|
423
|
+
* its owner isn't banned, and hasn't reached `maxReferralsPerUser`. `null`
|
|
424
|
+
* otherwise.
|
|
425
|
+
*/
|
|
426
|
+
async function findUsableCode(adapter, options, rawCode) {
|
|
427
|
+
const code = normalizeCode(rawCode);
|
|
428
|
+
if (!isWellFormedCode(code)) return null;
|
|
429
|
+
const stored = await findCodeByCode(adapter, code);
|
|
430
|
+
if (!stored) return null;
|
|
431
|
+
if (await isReferrerBlocked(adapter, stored.userId)) return null;
|
|
432
|
+
if (options.maxReferralsPerUser !== void 0 && await countReferrals(adapter, { referrerUserId: stored.userId }) >= options.maxReferralsPerUser) return null;
|
|
433
|
+
return stored;
|
|
434
|
+
}
|
|
435
|
+
/** Records a completed referral in the audit log, for the referrer it rewards. */
|
|
436
|
+
function recordCompleted(referral) {
|
|
437
|
+
return recordAuditEvent({
|
|
438
|
+
type: "referral.completed",
|
|
439
|
+
severity: "medium",
|
|
440
|
+
userId: referral.referrerUserId,
|
|
441
|
+
metadata: {
|
|
442
|
+
referralId: referral.id,
|
|
443
|
+
referredUserId: referral.referredUserId
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Marks `referral` completed once all required steps are done, and runs
|
|
449
|
+
* `onReferralCompleted` exactly once.
|
|
450
|
+
*/
|
|
451
|
+
async function completeIfDone(adapter, context, options, referral) {
|
|
452
|
+
if (referral.status === "completed") return referral;
|
|
453
|
+
const done = new Set((await stepsByReferral(adapter, [referral.id])).get(referral.id));
|
|
454
|
+
if (!options.steps.every((step) => done.has(step))) return referral;
|
|
455
|
+
const completed = await completeReferral(adapter, referral.id);
|
|
456
|
+
if (!completed) return {
|
|
457
|
+
...referral,
|
|
458
|
+
status: "completed"
|
|
459
|
+
};
|
|
460
|
+
await recordCompleted(completed);
|
|
461
|
+
const { onReferralCompleted } = options;
|
|
462
|
+
if (onReferralCompleted) await runCallback(context, "referral", "onReferralCompleted", () => onReferralCompleted(completed));
|
|
463
|
+
return completed;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Records `step` for `referral` (once; repeating it changes nothing) and
|
|
467
|
+
* completes the referral when it was the last required step.
|
|
468
|
+
*/
|
|
469
|
+
async function recordStep(adapter, context, options, referral, step, metadata) {
|
|
470
|
+
if (!options.steps.includes(step)) throw APIError.from("BAD_REQUEST", REFERRAL_ERROR_CODES.UNKNOWN_REFERRAL_STEP);
|
|
471
|
+
let isNew = false;
|
|
472
|
+
if (!await findStep(adapter, referral.id, step)) try {
|
|
473
|
+
await createStep(adapter, {
|
|
474
|
+
referralId: referral.id,
|
|
475
|
+
step,
|
|
476
|
+
metadata
|
|
477
|
+
});
|
|
478
|
+
isNew = true;
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (!await findStep(adapter, referral.id, step)) throw error;
|
|
481
|
+
}
|
|
482
|
+
const { onStepCompleted } = options;
|
|
483
|
+
if (isNew && onStepCompleted) await runCallback(context, "referral", "onStepCompleted", () => onStepCompleted({
|
|
484
|
+
referral,
|
|
485
|
+
step
|
|
486
|
+
}));
|
|
487
|
+
return completeIfDone(adapter, context, options, referral);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Runs once a referral is created: `onReferral`, then `onReferralCompleted`
|
|
491
|
+
* if it was created completed (no steps required).
|
|
492
|
+
*/
|
|
493
|
+
async function referralCreated(context, options, referral) {
|
|
494
|
+
await recordAuditEvent({
|
|
495
|
+
type: "referral.created",
|
|
496
|
+
userId: referral.referredUserId,
|
|
497
|
+
actorUserId: referral.referredUserId,
|
|
498
|
+
metadata: {
|
|
499
|
+
referralId: referral.id,
|
|
500
|
+
referrerUserId: referral.referrerUserId,
|
|
501
|
+
code: referral.code
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
if (referral.status === "completed") await recordCompleted(referral);
|
|
505
|
+
const { onReferral, onReferralCompleted } = options;
|
|
506
|
+
if (onReferral) await runCallback(context, "referral", "onReferral", () => onReferral(referral));
|
|
507
|
+
if (referral.status === "completed" && onReferralCompleted) await runCallback(context, "referral", "onReferralCompleted", () => onReferralCompleted(referral));
|
|
508
|
+
}
|
|
509
|
+
// src/hooks/database.ts
|
|
510
|
+
const REFERRAL_CODE_HEADER = "x-referral-code";
|
|
511
|
+
/** Longest code read from a request; longer values are ignored. */
|
|
512
|
+
const MAX_CODE_INPUT_LENGTH = 128;
|
|
513
|
+
/**
|
|
514
|
+
* The referral code sent with the request that creates the user: the
|
|
515
|
+
* `referralCode` field of the sign-up body, the `x-referral-code` header,
|
|
516
|
+
* or `additionalData.referralCode` of a social sign-in (read on the
|
|
517
|
+
* callback).
|
|
518
|
+
*/
|
|
519
|
+
async function readReferralCode(context) {
|
|
520
|
+
const valid = (value) => typeof value === "string" && value.trim() !== "" && value.length <= MAX_CODE_INPUT_LENGTH;
|
|
521
|
+
const body = context?.body;
|
|
522
|
+
if (valid(body?.referralCode)) return body.referralCode;
|
|
523
|
+
const header = context?.headers?.get(REFERRAL_CODE_HEADER);
|
|
524
|
+
if (valid(header)) return header;
|
|
525
|
+
try {
|
|
526
|
+
const state = await getOAuthState();
|
|
527
|
+
if (valid(state?.referralCode)) return state.referralCode;
|
|
528
|
+
} catch {}
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Attributes new users to the code they signed up with, whichever way
|
|
532
|
+
* they sign up, and records the `email-verified` step. Failures after the
|
|
533
|
+
* user exists are logged, never returned: a referral problem must not
|
|
534
|
+
* fail a sign-up that already happened.
|
|
535
|
+
*/
|
|
536
|
+
function createDatabaseHooks(authContext, options) {
|
|
537
|
+
const contextFor = (context) => context?.context ?? authContext;
|
|
538
|
+
const tracksEmailVerified = options.steps.includes(EMAIL_VERIFIED_STEP);
|
|
539
|
+
/** Requests whose user update sets `emailVerified: true` (see `update.before`). */
|
|
540
|
+
const verifying = /* @__PURE__ */ new WeakSet();
|
|
541
|
+
const attribute = async (user, context) => {
|
|
542
|
+
const ctx = contextFor(context);
|
|
543
|
+
const adapter = await getCurrentAdapter(ctx.adapter);
|
|
544
|
+
if (options.createCodeOnSignUp) await getOrCreateCode(adapter, options, user.id);
|
|
545
|
+
const rawCode = await readReferralCode(context);
|
|
546
|
+
if (!rawCode) return;
|
|
547
|
+
const code = await findUsableCode(adapter, options, rawCode);
|
|
548
|
+
if (!code || code.userId === user.id) return;
|
|
549
|
+
if (options.allowReferral) {
|
|
550
|
+
let allowed = false;
|
|
551
|
+
try {
|
|
552
|
+
allowed = await options.allowReferral({
|
|
553
|
+
referrerUserId: code.userId,
|
|
554
|
+
referredUser: {
|
|
555
|
+
id: user.id,
|
|
556
|
+
email: user.email
|
|
557
|
+
},
|
|
558
|
+
code: code.code,
|
|
559
|
+
context: context ?? null
|
|
560
|
+
});
|
|
561
|
+
} catch (error) {
|
|
562
|
+
ctx.logger.error("[referral] allowReferral threw an error", error);
|
|
563
|
+
}
|
|
564
|
+
if (!allowed) return;
|
|
565
|
+
}
|
|
566
|
+
let referral;
|
|
567
|
+
try {
|
|
568
|
+
referral = await createReferral(adapter, {
|
|
569
|
+
referrerUserId: code.userId,
|
|
570
|
+
referredUserId: user.id,
|
|
571
|
+
code: code.code,
|
|
572
|
+
status: options.steps.length === 0 ? "completed" : "pending"
|
|
573
|
+
});
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (await findReferralByReferredUserId(adapter, user.id)) return;
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
const settle = async () => {
|
|
579
|
+
try {
|
|
580
|
+
const max = options.maxReferralsPerUser;
|
|
581
|
+
if (max !== void 0 && await countReferralsBefore(ctx.adapter, referral) >= max) {
|
|
582
|
+
await deleteReferral(ctx.adapter, referral.id);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
await referralCreated(ctx, options, referral);
|
|
586
|
+
if (tracksEmailVerified && user.emailVerified) await recordStep(ctx.adapter, ctx, options, referral, EMAIL_VERIFIED_STEP);
|
|
587
|
+
} catch (error) {
|
|
588
|
+
ctx.logger.error("[referral] failed to record the referral", error);
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
if (adapter === ctx.adapter) await settle();
|
|
592
|
+
else await queueAfterTransactionHook(settle);
|
|
593
|
+
};
|
|
594
|
+
return { user: {
|
|
595
|
+
create: {
|
|
596
|
+
before: async (_user, context) => {
|
|
597
|
+
if (options.invalidCode !== "reject") return;
|
|
598
|
+
const rawCode = await readReferralCode(context);
|
|
599
|
+
if (rawCode === void 0) return;
|
|
600
|
+
if (!await findUsableCode(await getCurrentAdapter(contextFor(context).adapter), options, rawCode)) throw APIError.from("BAD_REQUEST", REFERRAL_ERROR_CODES.INVALID_REFERRAL_CODE);
|
|
601
|
+
},
|
|
602
|
+
after: async (user, context) => {
|
|
603
|
+
try {
|
|
604
|
+
await attribute(user, context);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
contextFor(context).logger.error("[referral] failed to record the referral", error);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
update: {
|
|
611
|
+
before: async (data, context) => {
|
|
612
|
+
if (tracksEmailVerified && context && data.emailVerified === true) verifying.add(context);
|
|
613
|
+
},
|
|
614
|
+
after: async (user, context) => {
|
|
615
|
+
if (!tracksEmailVerified || user.emailVerified !== true) return;
|
|
616
|
+
if (context && !verifying.delete(context)) return;
|
|
617
|
+
const ctx = contextFor(context);
|
|
618
|
+
try {
|
|
619
|
+
const adapter = await getCurrentAdapter(ctx.adapter);
|
|
620
|
+
const referral = await findReferralByReferredUserId(adapter, user.id);
|
|
621
|
+
if (referral) await recordStep(adapter, ctx, options, referral, EMAIL_VERIFIED_STEP);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
ctx.logger.error("[referral] failed to record the email-verified step", error);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
} };
|
|
628
|
+
}
|
|
629
|
+
// src/routes/codes.ts
|
|
630
|
+
function createCodeEndpoints(options) {
|
|
631
|
+
return {
|
|
632
|
+
getReferralCode: createAuthEndpoint("/referral/get-code", {
|
|
633
|
+
method: "GET",
|
|
634
|
+
use: [sessionMiddleware],
|
|
635
|
+
metadata: { openapi: { description: "Get the signed-in user's referral code, or null if they have none" } }
|
|
636
|
+
}, async (ctx) => {
|
|
637
|
+
const code = await findCodeByUserId(ctx.context.adapter, ctx.context.session.user.id);
|
|
638
|
+
return ctx.json({ code: code?.code ?? null });
|
|
639
|
+
}),
|
|
640
|
+
createReferralCode: createAuthEndpoint("/referral/create-code", {
|
|
641
|
+
method: "POST",
|
|
642
|
+
use: [sessionMiddleware],
|
|
643
|
+
metadata: { openapi: { description: "Create the signed-in user's referral code. Returns the existing code if they already have one" } }
|
|
644
|
+
}, async (ctx) => {
|
|
645
|
+
const code = await getOrCreateCode(ctx.context.adapter, options, ctx.context.session.user.id);
|
|
646
|
+
return ctx.json({ code: code.code });
|
|
647
|
+
}),
|
|
648
|
+
regenerateReferralCode: createAuthEndpoint("/referral/regenerate-code", {
|
|
649
|
+
method: "POST",
|
|
650
|
+
use: [sessionMiddleware],
|
|
651
|
+
metadata: { openapi: { description: "Replace the signed-in user's referral code with a new one. The old code and its share links stop working; referrals made with it keep counting" } }
|
|
652
|
+
}, async (ctx) => {
|
|
653
|
+
const code = await regenerateCode(ctx.context.adapter, options, ctx.context.session.user.id);
|
|
654
|
+
return ctx.json({ code: code.code });
|
|
655
|
+
}),
|
|
656
|
+
checkReferralCode: createAuthEndpoint("/referral/check-code", {
|
|
657
|
+
method: "GET",
|
|
658
|
+
query: z.object({ code: z.string().min(1).max(128).meta({ description: "The referral code" }) }),
|
|
659
|
+
metadata: { openapi: { description: "Check whether a referral code can be used at sign-up. Doesn't reveal whose code it is" } }
|
|
660
|
+
}, async (ctx) => {
|
|
661
|
+
const code = await findUsableCode(ctx.context.adapter, options, ctx.query.code);
|
|
662
|
+
return ctx.json({ valid: code !== null });
|
|
663
|
+
})
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
// ../plugin-utils/src/pagination.ts
|
|
667
|
+
/**
|
|
668
|
+
* The `limit` and `offset` fields of a list endpoint's query, coerced from
|
|
669
|
+
* query strings. Spread into the endpoint's `z.object({ ... })`.
|
|
670
|
+
*/
|
|
671
|
+
function paginationQuery({ defaultLimit, maxLimit }) {
|
|
672
|
+
return {
|
|
673
|
+
limit: z.coerce.number().int().min(1).max(maxLimit).default(defaultLimit).meta({ description: `How many to return, at most ${maxLimit}. Defaults to ${defaultLimit}` }),
|
|
674
|
+
offset: z.coerce.number().int().min(0).default(0).meta({ description: "How many to skip. Defaults to 0" })
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
// src/utils/mask.ts
|
|
678
|
+
/** `ada@example.com` → `a***@example.com`. Emails without `@` become `***`. */
|
|
679
|
+
function maskEmail(email) {
|
|
680
|
+
const at = email.lastIndexOf("@");
|
|
681
|
+
if (at < 1) return "***";
|
|
682
|
+
return `${email[0]}***${email.slice(at)}`;
|
|
683
|
+
}
|
|
684
|
+
const pageSchema = paginationQuery({
|
|
685
|
+
defaultLimit: 20,
|
|
686
|
+
maxLimit: 100
|
|
687
|
+
});
|
|
688
|
+
const statusSchema = z.enum(["pending", "completed"]).meta({ description: "Only referrals with this status" });
|
|
689
|
+
async function withSteps(adapter, referrals) {
|
|
690
|
+
const steps = await stepsByReferral(adapter, referrals.map((referral) => referral.id));
|
|
691
|
+
return referrals.map((referral) => ({
|
|
692
|
+
...referral,
|
|
693
|
+
steps: steps.get(referral.id) ?? []
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
696
|
+
function createReferralEndpoints(options) {
|
|
697
|
+
return {
|
|
698
|
+
listReferrals: createAuthEndpoint("/referral/list-referrals", {
|
|
699
|
+
method: "GET",
|
|
700
|
+
use: [sessionMiddleware],
|
|
701
|
+
query: z.optional(z.object({
|
|
702
|
+
status: statusSchema.optional(),
|
|
703
|
+
...pageSchema
|
|
704
|
+
})),
|
|
705
|
+
metadata: { openapi: { description: "List the users the signed-in user referred, newest first, with their steps" } }
|
|
706
|
+
}, async (ctx) => {
|
|
707
|
+
const { adapter } = ctx.context;
|
|
708
|
+
const referrerUserId = ctx.context.session.user.id;
|
|
709
|
+
const status = ctx.query?.status;
|
|
710
|
+
const [referrals, total, completed] = await Promise.all([
|
|
711
|
+
listReferrals(adapter, {
|
|
712
|
+
referrerUserId,
|
|
713
|
+
status,
|
|
714
|
+
limit: ctx.query?.limit ?? 20,
|
|
715
|
+
offset: ctx.query?.offset ?? 0
|
|
716
|
+
}),
|
|
717
|
+
countReferrals(adapter, {
|
|
718
|
+
referrerUserId,
|
|
719
|
+
status
|
|
720
|
+
}),
|
|
721
|
+
countReferrals(adapter, {
|
|
722
|
+
referrerUserId,
|
|
723
|
+
status: "completed"
|
|
724
|
+
})
|
|
725
|
+
]);
|
|
726
|
+
const [detailed, users] = await Promise.all([withSteps(adapter, referrals), findUsers(adapter, referrals.map((referral) => referral.referredUserId))]);
|
|
727
|
+
return ctx.json({
|
|
728
|
+
referrals: detailed.map((referral) => {
|
|
729
|
+
const user = users.get(referral.referredUserId);
|
|
730
|
+
return {
|
|
731
|
+
id: referral.id,
|
|
732
|
+
status: referral.status,
|
|
733
|
+
steps: referral.steps,
|
|
734
|
+
createdAt: referral.createdAt,
|
|
735
|
+
completedAt: referral.completedAt ?? null,
|
|
736
|
+
referredUser: {
|
|
737
|
+
name: options.hideNames ? null : user?.name ?? null,
|
|
738
|
+
email: user ? options.maskEmails ? maskEmail(user.email) : user.email : null
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
}),
|
|
742
|
+
total,
|
|
743
|
+
completed
|
|
744
|
+
});
|
|
745
|
+
}),
|
|
746
|
+
listAllReferrals: createAuthEndpoint("/referral/list-all-referrals", {
|
|
747
|
+
method: "GET",
|
|
748
|
+
use: [sessionMiddleware],
|
|
749
|
+
query: z.optional(z.object({
|
|
750
|
+
referrerUserId: z.string().min(1).optional().meta({ description: "Only referrals by this user" }),
|
|
751
|
+
status: statusSchema.optional(),
|
|
752
|
+
...pageSchema
|
|
753
|
+
})),
|
|
754
|
+
metadata: { openapi: { description: "List all referrals, newest first. Admins only" } }
|
|
755
|
+
}, async (ctx) => {
|
|
756
|
+
if (!isAdmin(ctx.context.session.user, options)) throw APIError.from("FORBIDDEN", REFERRAL_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_REFERRALS);
|
|
757
|
+
const { adapter } = ctx.context;
|
|
758
|
+
const filters = {
|
|
759
|
+
referrerUserId: ctx.query?.referrerUserId,
|
|
760
|
+
status: ctx.query?.status
|
|
761
|
+
};
|
|
762
|
+
const [referrals, total] = await Promise.all([listReferrals(adapter, {
|
|
763
|
+
...filters,
|
|
764
|
+
limit: ctx.query?.limit ?? 20,
|
|
765
|
+
offset: ctx.query?.offset ?? 0
|
|
766
|
+
}), countReferrals(adapter, filters)]);
|
|
767
|
+
return ctx.json({
|
|
768
|
+
referrals: await withSteps(adapter, referrals),
|
|
769
|
+
total
|
|
770
|
+
});
|
|
771
|
+
}),
|
|
772
|
+
/**
|
|
773
|
+
* Server only (no path, so not reachable over HTTP): your backend calls
|
|
774
|
+
* `auth.api.completeReferralStep` when a referred user does something
|
|
775
|
+
* that counts, e.g. their first purchase.
|
|
776
|
+
*/
|
|
777
|
+
completeReferralStep: createAuthEndpoint({
|
|
778
|
+
method: "POST",
|
|
779
|
+
body: z.object({
|
|
780
|
+
userId: z.string().min(1).meta({ description: "The referred user" }),
|
|
781
|
+
step: z.string().min(1).max(64).meta({ description: "A step from the plugin's `steps` option" }),
|
|
782
|
+
metadata: z.record(z.string(), z.unknown()).optional().meta({ description: "Extra data to keep with the step, e.g. an order id" })
|
|
783
|
+
}),
|
|
784
|
+
metadata: { openapi: { description: "Record a step of the user's referral. Does nothing if the user wasn't referred or the step is already done" } }
|
|
785
|
+
}, async (ctx) => {
|
|
786
|
+
const { userId, step } = ctx.body;
|
|
787
|
+
const metadata = ctx.body.metadata ? JSON.stringify(ctx.body.metadata) : void 0;
|
|
788
|
+
if (metadata && metadata.length > 2e3) throw APIError.from("BAD_REQUEST", REFERRAL_ERROR_CODES.METADATA_TOO_LARGE);
|
|
789
|
+
if (!options.steps.includes(step)) throw APIError.from("BAD_REQUEST", REFERRAL_ERROR_CODES.UNKNOWN_REFERRAL_STEP);
|
|
790
|
+
const { adapter } = ctx.context;
|
|
791
|
+
const referral = await findReferralByReferredUserId(adapter, userId);
|
|
792
|
+
if (!referral) return ctx.json({ referral: null });
|
|
793
|
+
const [detailed] = await withSteps(adapter, [await recordStep(adapter, ctx.context, options, referral, step, metadata)]);
|
|
794
|
+
return ctx.json({ referral: detailed ?? null });
|
|
795
|
+
})
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
// src/schema.ts
|
|
799
|
+
const referralSchema = {
|
|
800
|
+
/** One code per user. */
|
|
801
|
+
referralCode: {
|
|
802
|
+
fields: {
|
|
803
|
+
userId: {
|
|
804
|
+
type: "string",
|
|
805
|
+
required: true,
|
|
806
|
+
references: {
|
|
807
|
+
model: "user",
|
|
808
|
+
field: "id",
|
|
809
|
+
onDelete: "cascade"
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
/** Uppercase. */
|
|
813
|
+
code: {
|
|
814
|
+
type: "string",
|
|
815
|
+
required: true
|
|
816
|
+
},
|
|
817
|
+
createdAt: {
|
|
818
|
+
type: "date",
|
|
819
|
+
required: true
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
indexes: [{
|
|
823
|
+
fields: ["userId"],
|
|
824
|
+
unique: true
|
|
825
|
+
}, {
|
|
826
|
+
fields: ["code"],
|
|
827
|
+
unique: true
|
|
828
|
+
}]
|
|
829
|
+
},
|
|
830
|
+
/** Who referred whom. A user is referred at most once. */
|
|
831
|
+
referral: {
|
|
832
|
+
fields: {
|
|
833
|
+
referrerUserId: {
|
|
834
|
+
type: "string",
|
|
835
|
+
required: true,
|
|
836
|
+
index: true,
|
|
837
|
+
references: {
|
|
838
|
+
model: "user",
|
|
839
|
+
field: "id",
|
|
840
|
+
onDelete: "cascade"
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
referredUserId: {
|
|
844
|
+
type: "string",
|
|
845
|
+
required: true,
|
|
846
|
+
references: {
|
|
847
|
+
model: "user",
|
|
848
|
+
field: "id",
|
|
849
|
+
onDelete: "cascade"
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
/** The code used at sign-up, kept even if the code changes. */
|
|
853
|
+
code: {
|
|
854
|
+
type: "string",
|
|
855
|
+
required: true
|
|
856
|
+
},
|
|
857
|
+
/** "pending" | "completed" */
|
|
858
|
+
status: {
|
|
859
|
+
type: "string",
|
|
860
|
+
required: true,
|
|
861
|
+
index: true
|
|
862
|
+
},
|
|
863
|
+
completedAt: {
|
|
864
|
+
type: "date",
|
|
865
|
+
required: false
|
|
866
|
+
},
|
|
867
|
+
createdAt: {
|
|
868
|
+
type: "date",
|
|
869
|
+
required: true
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
indexes: [{
|
|
873
|
+
fields: ["referredUserId"],
|
|
874
|
+
unique: true
|
|
875
|
+
}]
|
|
876
|
+
},
|
|
877
|
+
/** Steps a referred user has completed. Each step counts once. */
|
|
878
|
+
referralStep: {
|
|
879
|
+
fields: {
|
|
880
|
+
referralId: {
|
|
881
|
+
type: "string",
|
|
882
|
+
required: true,
|
|
883
|
+
references: {
|
|
884
|
+
model: "referral",
|
|
885
|
+
field: "id",
|
|
886
|
+
onDelete: "cascade"
|
|
887
|
+
}
|
|
888
|
+
},
|
|
889
|
+
step: {
|
|
890
|
+
type: "string",
|
|
891
|
+
required: true
|
|
892
|
+
},
|
|
893
|
+
/** JSON-encoded. */
|
|
894
|
+
metadata: {
|
|
895
|
+
type: "string",
|
|
896
|
+
required: false
|
|
897
|
+
},
|
|
898
|
+
completedAt: {
|
|
899
|
+
type: "date",
|
|
900
|
+
required: true
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
indexes: [{
|
|
904
|
+
fields: ["referralId", "step"],
|
|
905
|
+
unique: true
|
|
906
|
+
}]
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
// src/index.ts
|
|
910
|
+
/**
|
|
911
|
+
* Gives users referral codes, attributes new users to the code they signed
|
|
912
|
+
* up with (any sign-up method), and tracks steps a referral must complete
|
|
913
|
+
* before it counts, e.g. a verified email or a first purchase.
|
|
914
|
+
*
|
|
915
|
+
* @example
|
|
916
|
+
* ```ts
|
|
917
|
+
* import { betterAuth } from "better-auth";
|
|
918
|
+
* import { referral } from "@checodo/better-auth-referral";
|
|
919
|
+
*
|
|
920
|
+
* export const auth = betterAuth({
|
|
921
|
+
* plugins: [
|
|
922
|
+
* referral({
|
|
923
|
+
* steps: ["email-verified", "first-purchase"],
|
|
924
|
+
* onReferralCompleted: async ({ referrerUserId }) => {
|
|
925
|
+
* await grantCredit(referrerUserId);
|
|
926
|
+
* },
|
|
927
|
+
* }),
|
|
928
|
+
* ],
|
|
929
|
+
* });
|
|
930
|
+
* ```
|
|
931
|
+
*/
|
|
932
|
+
const referral = (options = {}) => {
|
|
933
|
+
const resolved = resolveOptions(options);
|
|
934
|
+
return {
|
|
935
|
+
id: "referral",
|
|
936
|
+
version,
|
|
937
|
+
init(ctx) {
|
|
938
|
+
applyAuthDefaults(resolved, ctx);
|
|
939
|
+
return { options: { databaseHooks: createDatabaseHooks(ctx, resolved) } };
|
|
940
|
+
},
|
|
941
|
+
schema: mergeSchema(structuredClone(referralSchema), options.schema),
|
|
942
|
+
endpoints: {
|
|
943
|
+
...createCodeEndpoints(resolved),
|
|
944
|
+
...createReferralEndpoints(resolved)
|
|
945
|
+
},
|
|
946
|
+
rateLimit: [{
|
|
947
|
+
pathMatcher: (path) => path === "/referral/check-code",
|
|
948
|
+
window: 60,
|
|
949
|
+
max: 5
|
|
950
|
+
}],
|
|
951
|
+
$ERROR_CODES: REFERRAL_ERROR_CODES,
|
|
952
|
+
options
|
|
953
|
+
};
|
|
954
|
+
};
|
|
955
|
+
export { EMAIL_VERIFIED_STEP, REFERRAL_ERROR_CODES, referral };
|
|
956
|
+
|
|
957
|
+
//# sourceMappingURL=index.js.map
|