@profullstack/affiliates 0.0.0-stage → 0.2.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 +248 -2
- package/dist/affiliates.d.ts +101 -0
- package/dist/affiliates.js +180 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +169 -0
- package/dist/codes.d.ts +19 -0
- package/dist/codes.js +48 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +8 -0
- package/dist/memory.d.ts +3 -0
- package/dist/memory.js +105 -0
- package/dist/postgres.d.ts +10 -0
- package/dist/postgres.js +8 -0
- package/dist/ref.d.ts +22 -0
- package/dist/ref.js +49 -0
- package/dist/sql.d.ts +12 -0
- package/dist/sql.js +172 -0
- package/dist/sqlite.d.ts +26 -0
- package/dist/sqlite.js +30 -0
- package/dist/store.d.ts +61 -0
- package/dist/store.js +1 -0
- package/dist/supabase.d.ts +31 -0
- package/dist/supabase.js +226 -0
- package/migrations/supabase.sql +319 -0
- package/package.json +65 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
3
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
4
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
5
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
return path;
|
|
9
|
+
};
|
|
10
|
+
import { parseArgs } from 'node:util';
|
|
11
|
+
import { Affiliates } from "./affiliates.js";
|
|
12
|
+
import { sqliteStore } from "./sqlite.js";
|
|
13
|
+
const HELP = `affiliates - scarce invite codes and affiliate links on a SQLite file
|
|
14
|
+
|
|
15
|
+
Usage: affiliates [--db FILE] [--json] <command>
|
|
16
|
+
|
|
17
|
+
invite create <inviterId> [--note TEXT] mint one invite (prints the code once)
|
|
18
|
+
invite list <inviterId> list a user's invites (no codes, only hashes are stored)
|
|
19
|
+
invite revoke <inviteId> revoke an unused invite (refunds the quota slot)
|
|
20
|
+
invite redeem <code> <newUserId> spend an invite
|
|
21
|
+
quota get <userId> show total / used / left
|
|
22
|
+
quota set <userId> <total> give someone more (or fewer) invites
|
|
23
|
+
invitedby <userId> who invited this user
|
|
24
|
+
invitees <inviterId> who joined on this user's invites
|
|
25
|
+
code <userId> the user's affiliate code (created on first call)
|
|
26
|
+
attribute <code> <newUserId> first-touch affiliate attribution
|
|
27
|
+
affiliateof <userId> the affiliate a user is attributed to
|
|
28
|
+
commission <amountCents> discount + commission for an amount
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--db FILE SQLite file (default $AFFILIATES_DB or ./affiliates.db)
|
|
32
|
+
--prefix P table prefix (default aff_)
|
|
33
|
+
--default-quota N invites per user without a custom quota (default 3)
|
|
34
|
+
--ttl-days N invite lifetime in days, 0 for none (default 30)
|
|
35
|
+
--json machine-readable output
|
|
36
|
+
-h, --help
|
|
37
|
+
`;
|
|
38
|
+
async function openDb(file) {
|
|
39
|
+
if (globalThis.Bun) {
|
|
40
|
+
const mod = await import(__rewriteRelativeImportExtension('bun:sqlite'));
|
|
41
|
+
return new mod.Database(file, { create: true });
|
|
42
|
+
}
|
|
43
|
+
const mod = await import(__rewriteRelativeImportExtension('node:sqlite'));
|
|
44
|
+
return new mod.DatabaseSync(file);
|
|
45
|
+
}
|
|
46
|
+
async function main(argv) {
|
|
47
|
+
const { values, positionals } = parseArgs({
|
|
48
|
+
args: argv,
|
|
49
|
+
allowPositionals: true,
|
|
50
|
+
options: {
|
|
51
|
+
db: { type: 'string' },
|
|
52
|
+
prefix: { type: 'string' },
|
|
53
|
+
'default-quota': { type: 'string' },
|
|
54
|
+
'ttl-days': { type: 'string' },
|
|
55
|
+
note: { type: 'string' },
|
|
56
|
+
json: { type: 'boolean', default: false },
|
|
57
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const json = values.json === true;
|
|
61
|
+
const [cmd, sub, ...rest] = positionals;
|
|
62
|
+
if (values.help || !cmd) {
|
|
63
|
+
process.stdout.write(HELP);
|
|
64
|
+
return values.help ? 0 : 1;
|
|
65
|
+
}
|
|
66
|
+
const file = values.db ?? process.env.AFFILIATES_DB ?? './affiliates.db';
|
|
67
|
+
const ttlDays = values['ttl-days'] === undefined ? 30 : Number(values['ttl-days']);
|
|
68
|
+
const db = await openDb(file);
|
|
69
|
+
const aff = new Affiliates(sqliteStore(db, values.prefix ? { prefix: values.prefix } : {}), {
|
|
70
|
+
defaultQuota: values['default-quota'] === undefined ? 3 : Number(values['default-quota']),
|
|
71
|
+
inviteTtlMs: ttlDays > 0 ? ttlDays * 86_400_000 : null,
|
|
72
|
+
});
|
|
73
|
+
const need = (v, name) => {
|
|
74
|
+
if (!v)
|
|
75
|
+
throw new Error(`missing <${name}>`);
|
|
76
|
+
return v;
|
|
77
|
+
};
|
|
78
|
+
const out = (data, text) => {
|
|
79
|
+
process.stdout.write(json ? JSON.stringify(data, null, 2) + '\n' : text + '\n');
|
|
80
|
+
};
|
|
81
|
+
const when = (ms) => (ms === null ? '-' : new Date(ms).toISOString());
|
|
82
|
+
try {
|
|
83
|
+
if (cmd === 'invite') {
|
|
84
|
+
if (sub === 'create') {
|
|
85
|
+
const inv = await aff.createInvite(need(rest[0], 'inviterId'), values.note ?? '');
|
|
86
|
+
out(inv, `${inv.code}\n(id ${inv.id}, expires ${when(inv.expiresAt)}; the code is shown only once)`);
|
|
87
|
+
}
|
|
88
|
+
else if (sub === 'list') {
|
|
89
|
+
const list = await aff.invites(need(rest[0], 'inviterId'));
|
|
90
|
+
out(list, list.length === 0
|
|
91
|
+
? 'no invites'
|
|
92
|
+
: list
|
|
93
|
+
.map((i) => {
|
|
94
|
+
const state = i.revokedAt !== null && !i.usedBy ? 'revoked' : i.usedBy ? `used by ${i.usedBy}` : 'open';
|
|
95
|
+
return `${i.id} ${when(i.createdAt)} ${state}${i.note ? ` ${i.note}` : ''}`;
|
|
96
|
+
})
|
|
97
|
+
.join('\n'));
|
|
98
|
+
}
|
|
99
|
+
else if (sub === 'revoke') {
|
|
100
|
+
const id = need(rest[0], 'inviteId');
|
|
101
|
+
await aff.revokeInvite(id);
|
|
102
|
+
out({ ok: true, id }, `revoked ${id}`);
|
|
103
|
+
}
|
|
104
|
+
else if (sub === 'redeem') {
|
|
105
|
+
const r = await aff.redeemInvite(need(rest[0], 'code'), need(rest[1], 'newUserId'));
|
|
106
|
+
out(r, r.ok ? `ok: invited by ${r.inviterId}` : `refused: ${r.reason}`);
|
|
107
|
+
return r.ok ? 0 : 2;
|
|
108
|
+
}
|
|
109
|
+
else
|
|
110
|
+
throw new Error('usage: invite create|list|revoke|redeem');
|
|
111
|
+
}
|
|
112
|
+
else if (cmd === 'quota') {
|
|
113
|
+
if (sub === 'set') {
|
|
114
|
+
const user = need(rest[0], 'userId');
|
|
115
|
+
await aff.setQuota(user, Number(need(rest[1], 'total')));
|
|
116
|
+
const q = await aff.quota(user);
|
|
117
|
+
out({ userId: user, ...q }, `${user}: ${q.left} of ${q.total} left`);
|
|
118
|
+
}
|
|
119
|
+
else if (sub === 'get') {
|
|
120
|
+
const user = need(rest[0], 'userId');
|
|
121
|
+
const q = await aff.quota(user);
|
|
122
|
+
out({ userId: user, ...q }, `${user}: ${q.left} of ${q.total} left (${q.used} used)`);
|
|
123
|
+
}
|
|
124
|
+
else
|
|
125
|
+
throw new Error('usage: quota get|set');
|
|
126
|
+
}
|
|
127
|
+
else if (cmd === 'invitedby') {
|
|
128
|
+
const who = await aff.invitedBy(need(sub, 'userId'));
|
|
129
|
+
out({ userId: sub, invitedBy: who }, who ?? 'nobody');
|
|
130
|
+
}
|
|
131
|
+
else if (cmd === 'invitees') {
|
|
132
|
+
const list = await aff.invitees(need(sub, 'inviterId'));
|
|
133
|
+
out(list, list.length ? list.map((i) => `${i.userId} ${when(i.at)}`).join('\n') : 'nobody yet');
|
|
134
|
+
}
|
|
135
|
+
else if (cmd === 'code') {
|
|
136
|
+
const code = await aff.affiliateCode(need(sub, 'userId'));
|
|
137
|
+
out({ userId: sub, code }, code);
|
|
138
|
+
}
|
|
139
|
+
else if (cmd === 'attribute') {
|
|
140
|
+
const r = await aff.attribute(need(sub, 'code'), need(rest[0], 'newUserId'));
|
|
141
|
+
out(r, r.ok ? `attributed to ${r.affiliateId}` : r.affiliateId ? `already attributed to ${r.affiliateId}` : 'refused');
|
|
142
|
+
return r.ok ? 0 : 2;
|
|
143
|
+
}
|
|
144
|
+
else if (cmd === 'affiliateof') {
|
|
145
|
+
const a = await aff.affiliateOf(need(sub, 'userId'));
|
|
146
|
+
out({ userId: sub, affiliateId: a }, a ?? 'none');
|
|
147
|
+
}
|
|
148
|
+
else if (cmd === 'commission') {
|
|
149
|
+
const amount = Number(need(sub, 'amountCents'));
|
|
150
|
+
const c = aff.commission(amount);
|
|
151
|
+
out({ amountCents: amount, ...c }, `discount ${c.discountCents}c, commission ${c.commissionCents}c`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
throw new Error(`unknown command: ${cmd}`);
|
|
155
|
+
}
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
db.close?.();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
|
163
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
164
|
+
if (process.argv.includes('--json'))
|
|
165
|
+
process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n');
|
|
166
|
+
else
|
|
167
|
+
process.stderr.write(`affiliates: ${msg}\n`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
});
|
package/dist/codes.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Crockford base32 alphabet: no I, L, O, U. */
|
|
2
|
+
export declare const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
3
|
+
/** `n` random Crockford base32 characters from the CSPRNG (5 bits each). */
|
|
4
|
+
export declare function randomCrockford(n: number): string;
|
|
5
|
+
/** A new invite code, e.g. INV-7K3M-QX9D-2F8H (60 random bits). */
|
|
6
|
+
export declare function generateInviteCode(): string;
|
|
7
|
+
/** A new public affiliate code, 8 Crockford characters (40 random bits). */
|
|
8
|
+
export declare function generateAffiliateCode(): string;
|
|
9
|
+
/**
|
|
10
|
+
* Canonical form of a user-typed code: case-insensitive, ignores spaces,
|
|
11
|
+
* dashes and other punctuation, maps the Crockford look-alikes O->0 and I/L->1.
|
|
12
|
+
* Invite codes come back as INV-XXXX-XXXX-XXXX (the INV prefix is optional on
|
|
13
|
+
* input); anything else comes back as uppercase Crockford characters.
|
|
14
|
+
*/
|
|
15
|
+
export declare function normalizeCode(input: string): string;
|
|
16
|
+
/** sha256 hex of the normalized code. Only this is stored. */
|
|
17
|
+
export declare function hashCode(code: string): string;
|
|
18
|
+
/** Random opaque id for rows. */
|
|
19
|
+
export declare function newId(): string;
|
package/dist/codes.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
/** Crockford base32 alphabet: no I, L, O, U. */
|
|
3
|
+
export const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
4
|
+
/** `n` random Crockford base32 characters from the CSPRNG (5 bits each). */
|
|
5
|
+
export function randomCrockford(n) {
|
|
6
|
+
const bytes = randomBytes(n);
|
|
7
|
+
let out = '';
|
|
8
|
+
for (let i = 0; i < n; i++)
|
|
9
|
+
out += CROCKFORD[bytes[i] & 31];
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
/** A new invite code, e.g. INV-7K3M-QX9D-2F8H (60 random bits). */
|
|
13
|
+
export function generateInviteCode() {
|
|
14
|
+
const body = randomCrockford(12);
|
|
15
|
+
return `INV-${body.slice(0, 4)}-${body.slice(4, 8)}-${body.slice(8, 12)}`;
|
|
16
|
+
}
|
|
17
|
+
/** A new public affiliate code, 8 Crockford characters (40 random bits). */
|
|
18
|
+
export function generateAffiliateCode() {
|
|
19
|
+
return randomCrockford(8);
|
|
20
|
+
}
|
|
21
|
+
function crockfordFix(s) {
|
|
22
|
+
return s.replace(/O/g, '0').replace(/[IL]/g, '1');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Canonical form of a user-typed code: case-insensitive, ignores spaces,
|
|
26
|
+
* dashes and other punctuation, maps the Crockford look-alikes O->0 and I/L->1.
|
|
27
|
+
* Invite codes come back as INV-XXXX-XXXX-XXXX (the INV prefix is optional on
|
|
28
|
+
* input); anything else comes back as uppercase Crockford characters.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeCode(input) {
|
|
31
|
+
const s = String(input ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
32
|
+
if (s.length === 15 && s.startsWith('INV'))
|
|
33
|
+
return formatInvite(crockfordFix(s.slice(3)));
|
|
34
|
+
if (s.length === 12)
|
|
35
|
+
return formatInvite(crockfordFix(s)); // invite typed without INV-
|
|
36
|
+
return crockfordFix(s);
|
|
37
|
+
}
|
|
38
|
+
function formatInvite(body) {
|
|
39
|
+
return `INV-${body.slice(0, 4)}-${body.slice(4, 8)}-${body.slice(8, 12)}`;
|
|
40
|
+
}
|
|
41
|
+
/** sha256 hex of the normalized code. Only this is stored. */
|
|
42
|
+
export function hashCode(code) {
|
|
43
|
+
return createHash('sha256').update(normalizeCode(code)).digest('hex');
|
|
44
|
+
}
|
|
45
|
+
/** Random opaque id for rows. */
|
|
46
|
+
export function newId() {
|
|
47
|
+
return randomBytes(12).toString('base64url');
|
|
48
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { Affiliates } from './affiliates.js';
|
|
2
|
+
export type { Invite, RedeemInviteResult, AffiliatesOptions, AttributeResult, ExternalCodes } from './affiliates.js';
|
|
3
|
+
export type { AffiliateStore, InviteRow, AttributionRow } from './store.js';
|
|
4
|
+
export { memoryStore } from './memory.js';
|
|
5
|
+
export { sqliteStore } from './sqlite.js';
|
|
6
|
+
export type { SqliteLike } from './sqlite.js';
|
|
7
|
+
export { postgresStore } from './postgres.js';
|
|
8
|
+
export type { PgLike } from './postgres.js';
|
|
9
|
+
export type { SqlStoreOptions } from './sql.js';
|
|
10
|
+
export { supabaseStore, supabaseMigrationSql } from './supabase.js';
|
|
11
|
+
export type { SupabaseLike, SupabaseStoreOptions } from './supabase.js';
|
|
12
|
+
export { REF_COOKIE, REF_PARAM, refFromRequest } from './ref.js';
|
|
13
|
+
export type { RequestLike } from './ref.js';
|
|
14
|
+
export { normalizeCode, generateInviteCode, hashCode } from './codes.js';
|
|
15
|
+
export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
|
|
16
|
+
export type { ReferralSplit } from '@profullstack/referrals';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Affiliates } from "./affiliates.js";
|
|
2
|
+
export { memoryStore } from "./memory.js";
|
|
3
|
+
export { sqliteStore } from "./sqlite.js";
|
|
4
|
+
export { postgresStore } from "./postgres.js";
|
|
5
|
+
export { supabaseStore, supabaseMigrationSql } from "./supabase.js";
|
|
6
|
+
export { REF_COOKIE, REF_PARAM, refFromRequest } from "./ref.js";
|
|
7
|
+
export { normalizeCode, generateInviteCode, hashCode } from "./codes.js";
|
|
8
|
+
export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
|
package/dist/memory.d.ts
ADDED
package/dist/memory.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const counted = (r) => r.revokedAt === null || r.usedBy !== null;
|
|
2
|
+
/** In-process store for tests and prototypes. Every method body is synchronous, so it is atomic. */
|
|
3
|
+
export function memoryStore() {
|
|
4
|
+
const quotas = new Map();
|
|
5
|
+
const invites = new Map();
|
|
6
|
+
const byHash = new Map();
|
|
7
|
+
const codes = new Map(); // userId -> code
|
|
8
|
+
const codeOwners = new Map(); // code -> userId
|
|
9
|
+
const attributions = new Map();
|
|
10
|
+
const copy = (r) => (r ? { ...r } : null);
|
|
11
|
+
return {
|
|
12
|
+
async getQuota(userId) {
|
|
13
|
+
return quotas.has(userId) ? quotas.get(userId) : null;
|
|
14
|
+
},
|
|
15
|
+
async setQuota(userId, total) {
|
|
16
|
+
quotas.set(userId, total);
|
|
17
|
+
},
|
|
18
|
+
async countInvites(inviterId) {
|
|
19
|
+
let n = 0;
|
|
20
|
+
for (const r of invites.values())
|
|
21
|
+
if (r.inviterId === inviterId && counted(r))
|
|
22
|
+
n++;
|
|
23
|
+
return n;
|
|
24
|
+
},
|
|
25
|
+
async insertInvite(row, limit) {
|
|
26
|
+
let n = 0;
|
|
27
|
+
for (const r of invites.values())
|
|
28
|
+
if (r.inviterId === row.inviterId && counted(r))
|
|
29
|
+
n++;
|
|
30
|
+
if (n >= limit)
|
|
31
|
+
return false;
|
|
32
|
+
if (byHash.has(row.codeHash))
|
|
33
|
+
throw new Error('invite code collision');
|
|
34
|
+
invites.set(row.id, { ...row });
|
|
35
|
+
byHash.set(row.codeHash, row.id);
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
async getInviteByHash(codeHash) {
|
|
39
|
+
const id = byHash.get(codeHash);
|
|
40
|
+
return id ? copy(invites.get(id)) : null;
|
|
41
|
+
},
|
|
42
|
+
async getInvite(id) {
|
|
43
|
+
return copy(invites.get(id));
|
|
44
|
+
},
|
|
45
|
+
async claimInvite(id, userId, at) {
|
|
46
|
+
const r = invites.get(id);
|
|
47
|
+
if (!r || r.usedBy !== null || r.revokedAt !== null)
|
|
48
|
+
return false;
|
|
49
|
+
r.usedBy = userId;
|
|
50
|
+
r.usedAt = at;
|
|
51
|
+
return true;
|
|
52
|
+
},
|
|
53
|
+
async revokeInvite(id, at) {
|
|
54
|
+
const r = invites.get(id);
|
|
55
|
+
if (r && r.revokedAt === null)
|
|
56
|
+
r.revokedAt = at;
|
|
57
|
+
},
|
|
58
|
+
async listInvites(inviterId) {
|
|
59
|
+
return [...invites.values()]
|
|
60
|
+
.filter((r) => r.inviterId === inviterId)
|
|
61
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
62
|
+
.map((r) => ({ ...r }));
|
|
63
|
+
},
|
|
64
|
+
async invitedBy(userId) {
|
|
65
|
+
let best = null;
|
|
66
|
+
for (const r of invites.values()) {
|
|
67
|
+
if (r.usedBy === userId && (best === null || (r.usedAt ?? 0) < (best.usedAt ?? 0)))
|
|
68
|
+
best = r;
|
|
69
|
+
}
|
|
70
|
+
return best ? best.inviterId : null;
|
|
71
|
+
},
|
|
72
|
+
async invitees(inviterId) {
|
|
73
|
+
return [...invites.values()]
|
|
74
|
+
.filter((r) => r.inviterId === inviterId && r.usedBy !== null)
|
|
75
|
+
.sort((a, b) => (a.usedAt ?? 0) - (b.usedAt ?? 0))
|
|
76
|
+
.map((r) => ({ userId: r.usedBy, at: r.usedAt }));
|
|
77
|
+
},
|
|
78
|
+
async getAffiliateCode(userId) {
|
|
79
|
+
return codes.get(userId) ?? null;
|
|
80
|
+
},
|
|
81
|
+
async putAffiliateCode(userId, code) {
|
|
82
|
+
const existing = codes.get(userId);
|
|
83
|
+
if (existing)
|
|
84
|
+
return existing;
|
|
85
|
+
if (codeOwners.has(code))
|
|
86
|
+
return null;
|
|
87
|
+
codes.set(userId, code);
|
|
88
|
+
codeOwners.set(code, userId);
|
|
89
|
+
return code;
|
|
90
|
+
},
|
|
91
|
+
async affiliateByCode(code) {
|
|
92
|
+
return codeOwners.get(code) ?? null;
|
|
93
|
+
},
|
|
94
|
+
async attribute(row) {
|
|
95
|
+
if (attributions.has(row.userId))
|
|
96
|
+
return false;
|
|
97
|
+
attributions.set(row.userId, { ...row });
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
100
|
+
async getAttribution(userId) {
|
|
101
|
+
const r = attributions.get(userId);
|
|
102
|
+
return r ? { ...r } : null;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type SqlStoreOptions } from './sql.js';
|
|
2
|
+
import type { AffiliateStore } from './store.js';
|
|
3
|
+
/** Anything with a node-postgres style `query` (pg.Pool, pg.Client, @profullstack/libsql-pg, ...). */
|
|
4
|
+
export interface PgLike {
|
|
5
|
+
query(sql: string, params?: unknown[]): Promise<{
|
|
6
|
+
rows: any[];
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
/** Store on Postgres. Creates its tables (prefix `aff_`) on first use. */
|
|
10
|
+
export declare function postgresStore(pool: PgLike, opts?: SqlStoreOptions): AffiliateStore;
|
package/dist/postgres.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { sqlStore } from "./sql.js";
|
|
2
|
+
/** Store on Postgres. Creates its tables (prefix `aff_`) on first use. */
|
|
3
|
+
export function postgresStore(pool, opts = {}) {
|
|
4
|
+
return sqlStore(async (sql, params) => {
|
|
5
|
+
const res = await pool.query(sql, params.map((v) => (v === undefined ? null : v)));
|
|
6
|
+
return res?.rows ?? [];
|
|
7
|
+
}, opts, 'postgres');
|
|
8
|
+
}
|
package/dist/ref.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie name used by @profullstack/referrals (`trackReferralCode` in its `next` adapter defaults
|
|
3
|
+
* `cookieName` to this; its React `ReferralProvider` uses the same string as its sessionStorage key).
|
|
4
|
+
* Sites already on @profullstack/referrals keep setting this one cookie; affiliates reads it and never
|
|
5
|
+
* sets a second one, so one sign-up is counted once.
|
|
6
|
+
*/
|
|
7
|
+
export declare const REF_COOKIE = "referral_code";
|
|
8
|
+
/** Query parameter for affiliate links, the same default as @profullstack/referrals (`?ref=`). */
|
|
9
|
+
export declare const REF_PARAM = "ref";
|
|
10
|
+
export type RequestLike = Request | {
|
|
11
|
+
headers: Headers;
|
|
12
|
+
url: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The referral/affiliate code a request carries: `?ref=` on the URL first (the click being handled),
|
|
16
|
+
* otherwise the `referral_code` cookie that @profullstack/referrals' middleware set on an earlier
|
|
17
|
+
* visit. Returns null when neither is present. Pass the result to `aff.attribute(code, newUserId)`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function refFromRequest(req: RequestLike, opts?: {
|
|
20
|
+
param?: string;
|
|
21
|
+
cookieName?: string;
|
|
22
|
+
}): string | null;
|
package/dist/ref.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie name used by @profullstack/referrals (`trackReferralCode` in its `next` adapter defaults
|
|
3
|
+
* `cookieName` to this; its React `ReferralProvider` uses the same string as its sessionStorage key).
|
|
4
|
+
* Sites already on @profullstack/referrals keep setting this one cookie; affiliates reads it and never
|
|
5
|
+
* sets a second one, so one sign-up is counted once.
|
|
6
|
+
*/
|
|
7
|
+
export const REF_COOKIE = 'referral_code';
|
|
8
|
+
/** Query parameter for affiliate links, the same default as @profullstack/referrals (`?ref=`). */
|
|
9
|
+
export const REF_PARAM = 'ref';
|
|
10
|
+
function readCookie(header, name) {
|
|
11
|
+
if (!header)
|
|
12
|
+
return null;
|
|
13
|
+
for (const part of header.split(';')) {
|
|
14
|
+
const eq = part.indexOf('=');
|
|
15
|
+
if (eq < 0)
|
|
16
|
+
continue;
|
|
17
|
+
if (part.slice(0, eq).trim() !== name)
|
|
18
|
+
continue;
|
|
19
|
+
let v = part.slice(eq + 1).trim();
|
|
20
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2)
|
|
21
|
+
v = v.slice(1, -1);
|
|
22
|
+
try {
|
|
23
|
+
v = decodeURIComponent(v);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* keep raw */
|
|
27
|
+
}
|
|
28
|
+
return v || null;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The referral/affiliate code a request carries: `?ref=` on the URL first (the click being handled),
|
|
34
|
+
* otherwise the `referral_code` cookie that @profullstack/referrals' middleware set on an earlier
|
|
35
|
+
* visit. Returns null when neither is present. Pass the result to `aff.attribute(code, newUserId)`.
|
|
36
|
+
*/
|
|
37
|
+
export function refFromRequest(req, opts = {}) {
|
|
38
|
+
const param = opts.param ?? REF_PARAM;
|
|
39
|
+
const cookieName = opts.cookieName ?? REF_COOKIE;
|
|
40
|
+
try {
|
|
41
|
+
const q = new URL(req.url, 'http://localhost').searchParams.get(param)?.trim();
|
|
42
|
+
if (q)
|
|
43
|
+
return q;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* unparseable url: fall through to the cookie */
|
|
47
|
+
}
|
|
48
|
+
return readCookie(req.headers.get('cookie'), cookieName)?.trim() || null;
|
|
49
|
+
}
|
package/dist/sql.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AffiliateStore } from './store.js';
|
|
2
|
+
/** Runs one statement written with $1..$n placeholders and returns its rows. */
|
|
3
|
+
export type Exec = (sql: string, params: unknown[]) => Promise<any[]>;
|
|
4
|
+
export interface SqlStoreOptions {
|
|
5
|
+
/** Table name prefix. Default `aff_`. */
|
|
6
|
+
prefix?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Internal: which engine the SQL runs on. */
|
|
9
|
+
export type Dialect = 'sqlite' | 'postgres';
|
|
10
|
+
export declare function ddl(prefix: string, dialect?: Dialect): string[];
|
|
11
|
+
/** Store over any SQL engine that speaks the common subset of SQLite and Postgres (RETURNING, ON CONFLICT). */
|
|
12
|
+
export declare function sqlStore(exec: Exec, opts?: SqlStoreOptions, dialect?: Dialect): AffiliateStore;
|
package/dist/sql.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
function checkPrefix(prefix) {
|
|
2
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix))
|
|
3
|
+
throw new Error(`invalid table prefix: ${prefix}`);
|
|
4
|
+
return prefix;
|
|
5
|
+
}
|
|
6
|
+
const num = (v) => (v === null || v === undefined ? null : Number(v));
|
|
7
|
+
const str = (v) => (v === null || v === undefined ? null : String(v));
|
|
8
|
+
function toInvite(r) {
|
|
9
|
+
return {
|
|
10
|
+
id: String(r.id),
|
|
11
|
+
codeHash: String(r.code_hash),
|
|
12
|
+
inviterId: String(r.inviter_id),
|
|
13
|
+
createdAt: Number(r.created_at),
|
|
14
|
+
expiresAt: num(r.expires_at),
|
|
15
|
+
usedBy: str(r.used_by),
|
|
16
|
+
usedAt: num(r.used_at),
|
|
17
|
+
note: r.note == null ? '' : String(r.note),
|
|
18
|
+
revokedAt: num(r.revoked_at),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function ddl(prefix, dialect = 'sqlite') {
|
|
22
|
+
const p = checkPrefix(prefix);
|
|
23
|
+
const pgOnly = dialect === 'postgres'
|
|
24
|
+
? [
|
|
25
|
+
// Quota check + insert under a per-inviter advisory lock. A plpgsql function takes a fresh
|
|
26
|
+
// snapshot per statement, so the count after the lock sees every committed invite; a single
|
|
27
|
+
// INSERT ... SELECT WHERE count < limit would race under READ COMMITTED.
|
|
28
|
+
`CREATE OR REPLACE FUNCTION ${p}insert_invite(
|
|
29
|
+
p_id TEXT, p_hash TEXT, p_inviter TEXT, p_created BIGINT, p_expires BIGINT, p_note TEXT, p_limit INTEGER
|
|
30
|
+
) RETURNS BOOLEAN LANGUAGE plpgsql AS $fn$
|
|
31
|
+
BEGIN
|
|
32
|
+
PERFORM pg_advisory_xact_lock(hashtext('${p}invites'), hashtext(p_inviter));
|
|
33
|
+
IF (SELECT count(*) FROM ${p}invites
|
|
34
|
+
WHERE inviter_id = p_inviter AND (revoked_at IS NULL OR used_by IS NOT NULL)) >= p_limit THEN
|
|
35
|
+
RETURN FALSE;
|
|
36
|
+
END IF;
|
|
37
|
+
INSERT INTO ${p}invites (id, code_hash, inviter_id, created_at, expires_at, note)
|
|
38
|
+
VALUES (p_id, p_hash, p_inviter, p_created, p_expires, p_note);
|
|
39
|
+
RETURN TRUE;
|
|
40
|
+
END
|
|
41
|
+
$fn$`,
|
|
42
|
+
]
|
|
43
|
+
: [];
|
|
44
|
+
return [
|
|
45
|
+
`CREATE TABLE IF NOT EXISTS ${p}invites (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
code_hash TEXT NOT NULL UNIQUE,
|
|
48
|
+
inviter_id TEXT NOT NULL,
|
|
49
|
+
created_at BIGINT NOT NULL,
|
|
50
|
+
expires_at BIGINT,
|
|
51
|
+
used_by TEXT,
|
|
52
|
+
used_at BIGINT,
|
|
53
|
+
note TEXT NOT NULL DEFAULT '',
|
|
54
|
+
revoked_at BIGINT
|
|
55
|
+
)`,
|
|
56
|
+
`CREATE INDEX IF NOT EXISTS ${p}invites_inviter_idx ON ${p}invites (inviter_id)`,
|
|
57
|
+
`CREATE INDEX IF NOT EXISTS ${p}invites_used_by_idx ON ${p}invites (used_by)`,
|
|
58
|
+
`CREATE TABLE IF NOT EXISTS ${p}quotas (
|
|
59
|
+
user_id TEXT PRIMARY KEY,
|
|
60
|
+
total INTEGER NOT NULL
|
|
61
|
+
)`,
|
|
62
|
+
`CREATE TABLE IF NOT EXISTS ${p}codes (
|
|
63
|
+
user_id TEXT PRIMARY KEY,
|
|
64
|
+
code TEXT NOT NULL UNIQUE,
|
|
65
|
+
created_at BIGINT NOT NULL
|
|
66
|
+
)`,
|
|
67
|
+
`CREATE TABLE IF NOT EXISTS ${p}attributions (
|
|
68
|
+
user_id TEXT PRIMARY KEY,
|
|
69
|
+
affiliate_id TEXT NOT NULL,
|
|
70
|
+
code TEXT NOT NULL,
|
|
71
|
+
at BIGINT NOT NULL
|
|
72
|
+
)`,
|
|
73
|
+
`CREATE INDEX IF NOT EXISTS ${p}attributions_affiliate_idx ON ${p}attributions (affiliate_id)`,
|
|
74
|
+
...pgOnly,
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
/** Store over any SQL engine that speaks the common subset of SQLite and Postgres (RETURNING, ON CONFLICT). */
|
|
78
|
+
export function sqlStore(exec, opts = {}, dialect = 'sqlite') {
|
|
79
|
+
const p = checkPrefix(opts.prefix ?? 'aff_');
|
|
80
|
+
let ready = null;
|
|
81
|
+
const init = () => (ready ??= (async () => {
|
|
82
|
+
for (const stmt of ddl(p, dialect))
|
|
83
|
+
await exec(stmt, []);
|
|
84
|
+
})().catch((err) => {
|
|
85
|
+
ready = null;
|
|
86
|
+
throw err;
|
|
87
|
+
}));
|
|
88
|
+
const q = async (sql, params = []) => {
|
|
89
|
+
await init();
|
|
90
|
+
return exec(sql, params);
|
|
91
|
+
};
|
|
92
|
+
const COUNTED = `(revoked_at IS NULL OR used_by IS NOT NULL)`;
|
|
93
|
+
return {
|
|
94
|
+
async getQuota(userId) {
|
|
95
|
+
const rows = await q(`SELECT total FROM ${p}quotas WHERE user_id = $1`, [userId]);
|
|
96
|
+
return rows[0] ? Number(rows[0].total) : null;
|
|
97
|
+
},
|
|
98
|
+
async setQuota(userId, total) {
|
|
99
|
+
await q(`INSERT INTO ${p}quotas (user_id, total) VALUES ($1, $2)
|
|
100
|
+
ON CONFLICT (user_id) DO UPDATE SET total = excluded.total`, [userId, total]);
|
|
101
|
+
},
|
|
102
|
+
async countInvites(inviterId) {
|
|
103
|
+
const rows = await q(`SELECT count(*) AS n FROM ${p}invites WHERE inviter_id = $1 AND ${COUNTED}`, [inviterId]);
|
|
104
|
+
return Number(rows[0]?.n ?? 0);
|
|
105
|
+
},
|
|
106
|
+
async insertInvite(row, limit) {
|
|
107
|
+
if (dialect === 'postgres') {
|
|
108
|
+
const res = await q(`SELECT ${p}insert_invite($1, $2, $3, $4, $5, $6, $7) AS ok`, [
|
|
109
|
+
row.id, row.codeHash, row.inviterId, row.createdAt, row.expiresAt, row.note, limit,
|
|
110
|
+
]);
|
|
111
|
+
return res[0]?.ok === true || res[0]?.ok === 't' || res[0]?.ok === 1;
|
|
112
|
+
}
|
|
113
|
+
// SQLite: writers are serialized, so the check and the insert cannot interleave.
|
|
114
|
+
const rows = await q(`INSERT INTO ${p}invites (id, code_hash, inviter_id, created_at, expires_at, used_by, used_at, note, revoked_at)
|
|
115
|
+
SELECT CAST($1 AS TEXT), CAST($2 AS TEXT), CAST($3 AS TEXT), CAST($4 AS BIGINT), CAST($5 AS BIGINT), NULL, NULL, CAST($6 AS TEXT), NULL
|
|
116
|
+
WHERE (SELECT count(*) FROM ${p}invites WHERE inviter_id = CAST($3 AS TEXT) AND ${COUNTED}) < CAST($7 AS INTEGER)
|
|
117
|
+
RETURNING id`, [row.id, row.codeHash, row.inviterId, row.createdAt, row.expiresAt, row.note, limit]);
|
|
118
|
+
return rows.length > 0;
|
|
119
|
+
},
|
|
120
|
+
async getInviteByHash(codeHash) {
|
|
121
|
+
const rows = await q(`SELECT * FROM ${p}invites WHERE code_hash = $1`, [codeHash]);
|
|
122
|
+
return rows[0] ? toInvite(rows[0]) : null;
|
|
123
|
+
},
|
|
124
|
+
async getInvite(id) {
|
|
125
|
+
const rows = await q(`SELECT * FROM ${p}invites WHERE id = $1`, [id]);
|
|
126
|
+
return rows[0] ? toInvite(rows[0]) : null;
|
|
127
|
+
},
|
|
128
|
+
async claimInvite(id, userId, at) {
|
|
129
|
+
const rows = await q(`UPDATE ${p}invites SET used_by = $2, used_at = $3
|
|
130
|
+
WHERE id = $1 AND used_by IS NULL AND revoked_at IS NULL RETURNING id`, [id, userId, at]);
|
|
131
|
+
return rows.length > 0;
|
|
132
|
+
},
|
|
133
|
+
async revokeInvite(id, at) {
|
|
134
|
+
await q(`UPDATE ${p}invites SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL`, [id, at]);
|
|
135
|
+
},
|
|
136
|
+
async listInvites(inviterId) {
|
|
137
|
+
const rows = await q(`SELECT * FROM ${p}invites WHERE inviter_id = $1 ORDER BY created_at DESC, id`, [inviterId]);
|
|
138
|
+
return rows.map(toInvite);
|
|
139
|
+
},
|
|
140
|
+
async invitedBy(userId) {
|
|
141
|
+
const rows = await q(`SELECT inviter_id FROM ${p}invites WHERE used_by = $1 ORDER BY used_at ASC LIMIT 1`, [userId]);
|
|
142
|
+
return rows[0] ? String(rows[0].inviter_id) : null;
|
|
143
|
+
},
|
|
144
|
+
async invitees(inviterId) {
|
|
145
|
+
const rows = await q(`SELECT used_by, used_at FROM ${p}invites WHERE inviter_id = $1 AND used_by IS NOT NULL ORDER BY used_at ASC`, [inviterId]);
|
|
146
|
+
return rows.map((r) => ({ userId: String(r.used_by), at: Number(r.used_at) }));
|
|
147
|
+
},
|
|
148
|
+
async getAffiliateCode(userId) {
|
|
149
|
+
const rows = await q(`SELECT code FROM ${p}codes WHERE user_id = $1`, [userId]);
|
|
150
|
+
return rows[0] ? String(rows[0].code) : null;
|
|
151
|
+
},
|
|
152
|
+
async putAffiliateCode(userId, code, at) {
|
|
153
|
+
await q(`INSERT INTO ${p}codes (user_id, code, created_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [userId, code, at]);
|
|
154
|
+
const rows = await q(`SELECT code FROM ${p}codes WHERE user_id = $1`, [userId]);
|
|
155
|
+
return rows[0] ? String(rows[0].code) : null;
|
|
156
|
+
},
|
|
157
|
+
async affiliateByCode(code) {
|
|
158
|
+
const rows = await q(`SELECT user_id FROM ${p}codes WHERE code = $1`, [code]);
|
|
159
|
+
return rows[0] ? String(rows[0].user_id) : null;
|
|
160
|
+
},
|
|
161
|
+
async attribute(row) {
|
|
162
|
+
const rows = await q(`INSERT INTO ${p}attributions (user_id, affiliate_id, code, at) VALUES ($1, $2, $3, $4)
|
|
163
|
+
ON CONFLICT (user_id) DO NOTHING RETURNING user_id`, [row.userId, row.affiliateId, row.code, row.at]);
|
|
164
|
+
return rows.length > 0;
|
|
165
|
+
},
|
|
166
|
+
async getAttribution(userId) {
|
|
167
|
+
const rows = await q(`SELECT * FROM ${p}attributions WHERE user_id = $1`, [userId]);
|
|
168
|
+
const r = rows[0];
|
|
169
|
+
return r ? { userId: String(r.user_id), affiliateId: String(r.affiliate_id), code: String(r.code), at: Number(r.at) } : null;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|