@profullstack/affiliates 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/LICENSE +21 -0
- package/README.md +163 -2
- package/dist/affiliates.d.ts +80 -0
- package/dist/affiliates.js +157 -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 +12 -0
- package/dist/index.js +6 -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/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 +56 -0
- package/dist/store.js +1 -0
- package/package.json +60 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Profullstack, Inc. (moshcoder)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,164 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @profullstack/affiliates
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Scarce invite codes plus open affiliate links, for every Profullstack property.
|
|
4
|
+
|
|
5
|
+
- **Invite codes** are scarce on purpose. Each user gets a small quota (default 3). Each code
|
|
6
|
+
works once, expires (default 30 days), cannot be redeemed by the person who made it, and records
|
|
7
|
+
who invited whom. Codes look like `INV-7K3M-QX9D-2F8H` (60 random bits, Crockford base32) and are
|
|
8
|
+
stored only as sha256 hashes, so a database leak does not leak working codes.
|
|
9
|
+
- **Affiliate codes** are open: one stable public code per user, unlimited use, first-touch
|
|
10
|
+
attribution that is never overwritten.
|
|
11
|
+
- **Commission math** is not reimplemented here. It comes from
|
|
12
|
+
[`@profullstack/referrals`](https://www.npmjs.com/package/@profullstack/referrals)
|
|
13
|
+
(`calculateReferral`, `DEFAULT_SPLIT` = 60% affiliate, 20% customer discount), and its
|
|
14
|
+
`buildReferralUrl` / `extractCode` are re-exported.
|
|
15
|
+
|
|
16
|
+
Stores: in-memory, SQLite (`bun:sqlite`, `node:sqlite`, `better-sqlite3`) and Postgres (anything
|
|
17
|
+
with a node-postgres style `query`). SQL stores create their own tables (prefix `aff_`).
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm i @profullstack/affiliates
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { Database } from 'bun:sqlite'; // or: import { DatabaseSync } from 'node:sqlite'
|
|
27
|
+
import { Affiliates, sqliteStore, buildReferralUrl, extractCode } from '@profullstack/affiliates';
|
|
28
|
+
|
|
29
|
+
const aff = new Affiliates(sqliteStore(new Database('app.db')));
|
|
30
|
+
|
|
31
|
+
// invites: scarce, single use
|
|
32
|
+
const invite = await aff.createInvite('user_alice', 'for Carol');
|
|
33
|
+
invite.code; // 'INV-7K3M-QX9D-2F8H' (shown once, only the hash is stored)
|
|
34
|
+
await aff.quota('user_alice'); // { total: 3, used: 1, left: 2 }
|
|
35
|
+
await aff.redeemInvite(invite.code, 'user_carol'); // { ok: true, inviterId: 'user_alice', inviteId: '...' }
|
|
36
|
+
await aff.redeemInvite(invite.code, 'user_dave'); // { ok: false, reason: 'used' }
|
|
37
|
+
await aff.invitedBy('user_carol'); // 'user_alice'
|
|
38
|
+
await aff.setQuota('user_alice', 10); // admin: hand out more
|
|
39
|
+
|
|
40
|
+
// affiliate links: open, unlimited, first touch wins
|
|
41
|
+
const code = await aff.affiliateCode('user_alice'); // 'E6KETYBK' (stable)
|
|
42
|
+
const link = buildReferralUrl('https://example.com/signup', code); // ...?ref=E6KETYBK
|
|
43
|
+
await aff.attribute(extractCode(link)!, 'user_erin'); // { ok: true, affiliateId: 'user_alice' }
|
|
44
|
+
aff.commission(10_000); // { discountCents: 2000, commissionCents: 6000 }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Postgres:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import pg from 'pg';
|
|
51
|
+
import { Affiliates, postgresStore } from '@profullstack/affiliates';
|
|
52
|
+
const aff = new Affiliates(postgresStore(new pg.Pool({ connectionString: process.env.DATABASE_URL })));
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Invite-only signup in 5 lines
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const aff = new Affiliates(sqliteStore(db));
|
|
59
|
+
const r = await aff.redeemInvite(body.inviteCode, newUserId); // in your signup handler, before creating the account
|
|
60
|
+
if (!r.ok) return new Response(`Invite ${r.reason}`, { status: 403 });
|
|
61
|
+
await createUser(newUserId, { invitedBy: r.inviterId });
|
|
62
|
+
// later, on the user's settings page: const { code } = await aff.createInvite(userId);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Allocate the user id before redeeming (or redeem inside the same transaction you create the user in)
|
|
66
|
+
so a failed account creation does not silently burn the invite. If it does, `setQuota` the inviter
|
|
67
|
+
one more.
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
export type Invite = {
|
|
73
|
+
code: string; // plaintext, only on create
|
|
74
|
+
id: string;
|
|
75
|
+
inviterId: string;
|
|
76
|
+
createdAt: number; // ms
|
|
77
|
+
expiresAt: number | null;
|
|
78
|
+
usedBy: string | null;
|
|
79
|
+
usedAt: number | null;
|
|
80
|
+
note: string;
|
|
81
|
+
};
|
|
82
|
+
export type RedeemInviteResult =
|
|
83
|
+
| { ok: true; inviterId: string; inviteId: string }
|
|
84
|
+
| { ok: false; reason: 'invalid' | 'expired' | 'used' | 'self' | 'revoked' };
|
|
85
|
+
|
|
86
|
+
export interface AffiliateStore { /* see src/store.ts: quota, invite, claim, attribution primitives */ }
|
|
87
|
+
export function memoryStore(): AffiliateStore;
|
|
88
|
+
export function sqliteStore(db: SqliteLike, opts?: { prefix?: string }): AffiliateStore;
|
|
89
|
+
export function postgresStore(
|
|
90
|
+
pool: { query(sql: string, params?: unknown[]): Promise<{ rows: any[] }> },
|
|
91
|
+
opts?: { prefix?: string },
|
|
92
|
+
): AffiliateStore;
|
|
93
|
+
|
|
94
|
+
export class Affiliates {
|
|
95
|
+
constructor(store: AffiliateStore, opts?: {
|
|
96
|
+
now?: () => number; // clock, default Date.now
|
|
97
|
+
defaultQuota?: number; // default 3
|
|
98
|
+
inviteTtlMs?: number | null; // default 30 days, null = never expires
|
|
99
|
+
split?: ReferralSplit; // default DEFAULT_SPLIT from @profullstack/referrals
|
|
100
|
+
});
|
|
101
|
+
quota(userId: string): Promise<{ total: number; used: number; left: number }>;
|
|
102
|
+
setQuota(userId: string, total: number): Promise<void>;
|
|
103
|
+
createInvite(inviterId: string, note?: string): Promise<Invite>; // throws Error('no invites left')
|
|
104
|
+
redeemInvite(code: string, newUserId: string): Promise<RedeemInviteResult>;
|
|
105
|
+
invitedBy(userId: string): Promise<string | null>;
|
|
106
|
+
invitees(inviterId: string): Promise<{ userId: string; at: number }[]>;
|
|
107
|
+
invites(inviterId: string): Promise<Omit<Invite, 'code'>[]>; // newest first; also carries revokedAt
|
|
108
|
+
revokeInvite(inviteId: string): Promise<void>;
|
|
109
|
+
affiliateCode(userId: string): Promise<string>;
|
|
110
|
+
attribute(code: string, newUserId: string): Promise<{ ok: boolean; affiliateId?: string }>;
|
|
111
|
+
affiliateOf(userId: string): Promise<string | null>;
|
|
112
|
+
commission(amountCents: number): { discountCents: number; commissionCents: number };
|
|
113
|
+
}
|
|
114
|
+
export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
|
|
115
|
+
export function normalizeCode(input: string): string;
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Semantics worth knowing:
|
|
119
|
+
|
|
120
|
+
- **Quota** counts every invite a user created except ones revoked before use, so revoking an unused
|
|
121
|
+
invite refunds the slot. `setQuota` sets the total, not an increment.
|
|
122
|
+
- **Single use** is enforced in the store with `UPDATE ... WHERE used_by IS NULL AND revoked_at IS NULL`;
|
|
123
|
+
under any number of concurrent redeems exactly one wins and the rest get `used`.
|
|
124
|
+
- **Quota under concurrency**: SQLite serializes writers, and on Postgres the check and insert run in
|
|
125
|
+
a small `aff_insert_invite()` function under a per-inviter advisory lock, so parallel
|
|
126
|
+
`createInvite` calls cannot overshoot.
|
|
127
|
+
- **Redeem order**: `invalid`, `revoked`, `used`, `self`, `expired`. A self-redeem does not burn the
|
|
128
|
+
invite. If a user redeems two different invites, `invitedBy` returns the first inviter.
|
|
129
|
+
- **normalizeCode** uppercases, drops spaces and punctuation, and maps the Crockford look-alikes
|
|
130
|
+
`O` to `0` and `I`/`L` to `1`. Invite codes come back as `INV-XXXX-XXXX-XXXX` (the `INV` prefix is
|
|
131
|
+
optional on input), so `inv 7k3m qx9d 2f8h` redeems.
|
|
132
|
+
- **Affiliate attribution** is first touch: a second `attribute` for the same user returns
|
|
133
|
+
`{ ok: false, affiliateId: <the original> }`. Users cannot attribute themselves.
|
|
134
|
+
|
|
135
|
+
Tables (prefix configurable): `aff_invites`, `aff_quotas`, `aff_codes`, `aff_attributions`.
|
|
136
|
+
|
|
137
|
+
## CLI
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
npx @profullstack/affiliates --db app.db invite create user_alice --note "for Carol"
|
|
141
|
+
affiliates --db app.db invite list user_alice
|
|
142
|
+
affiliates --db app.db invite revoke <inviteId>
|
|
143
|
+
affiliates --db app.db invite redeem INV-7K3M-QX9D-2F8H user_carol
|
|
144
|
+
affiliates --db app.db quota set user_alice 10
|
|
145
|
+
affiliates --db app.db quota get user_alice
|
|
146
|
+
affiliates --db app.db invitedby user_carol
|
|
147
|
+
affiliates --db app.db invitees user_alice
|
|
148
|
+
affiliates --db app.db code user_alice
|
|
149
|
+
affiliates --db app.db --json commission 10000
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Works on a SQLite file through `node:sqlite` under Node (22.13+) and `bun:sqlite` under Bun. Every
|
|
153
|
+
command takes `--json`. Other options: `--prefix`, `--default-quota`, `--ttl-days` (0 = no expiry).
|
|
154
|
+
`--db` defaults to `$AFFILIATES_DB`, then `./affiliates.db`.
|
|
155
|
+
|
|
156
|
+
## Development
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
npm test # node --experimental-strip-types --test (memory + node:sqlite)
|
|
160
|
+
AFFILIATES_TEST_PG_URL=postgres://postgres:x@127.0.0.1:55432/postgres npm test # adds real Postgres
|
|
161
|
+
npm run build # tsc to dist/
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
MIT, Profullstack, Inc.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type ReferralSplit } from '@profullstack/referrals';
|
|
2
|
+
import type { AffiliateStore } from './store.js';
|
|
3
|
+
export type Invite = {
|
|
4
|
+
/** Plaintext code. Only returned by createInvite; never stored. */
|
|
5
|
+
code: string;
|
|
6
|
+
id: string;
|
|
7
|
+
inviterId: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
expiresAt: number | null;
|
|
10
|
+
usedBy: string | null;
|
|
11
|
+
usedAt: number | null;
|
|
12
|
+
note: string;
|
|
13
|
+
};
|
|
14
|
+
export type RedeemInviteResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
inviterId: string;
|
|
17
|
+
inviteId: string;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
reason: 'invalid' | 'expired' | 'used' | 'self' | 'revoked';
|
|
21
|
+
};
|
|
22
|
+
export interface AffiliatesOptions {
|
|
23
|
+
/** Clock in ms. Default Date.now. */
|
|
24
|
+
now?: () => number;
|
|
25
|
+
/** Invites each user may create unless setQuota says otherwise. Default 3. */
|
|
26
|
+
defaultQuota?: number;
|
|
27
|
+
/** Invite lifetime in ms; null for no expiry. Default 30 days. */
|
|
28
|
+
inviteTtlMs?: number | null;
|
|
29
|
+
/** Commission split passed to @profullstack/referrals. Default DEFAULT_SPLIT (60% affiliate, 20% customer). */
|
|
30
|
+
split?: ReferralSplit;
|
|
31
|
+
}
|
|
32
|
+
/** Scarce invite codes plus open affiliate links over any AffiliateStore. */
|
|
33
|
+
export declare class Affiliates {
|
|
34
|
+
readonly store: AffiliateStore;
|
|
35
|
+
readonly now: () => number;
|
|
36
|
+
readonly defaultQuota: number;
|
|
37
|
+
readonly inviteTtlMs: number | null;
|
|
38
|
+
readonly split: ReferralSplit;
|
|
39
|
+
constructor(store: AffiliateStore, opts?: AffiliatesOptions);
|
|
40
|
+
private total;
|
|
41
|
+
/** Invites a user may create, has created (revoked-before-use ones are refunded), and has left. */
|
|
42
|
+
quota(userId: string): Promise<{
|
|
43
|
+
total: number;
|
|
44
|
+
used: number;
|
|
45
|
+
left: number;
|
|
46
|
+
}>;
|
|
47
|
+
/** Admin: set a user's invite allowance (total, not additional). */
|
|
48
|
+
setQuota(userId: string, total: number): Promise<void>;
|
|
49
|
+
/** Mint one invite. Throws Error('no invites left') when the quota is spent. */
|
|
50
|
+
createInvite(inviterId: string, note?: string): Promise<Invite>;
|
|
51
|
+
/** Spend an invite on a new user. Single use; the inviter cannot redeem their own. */
|
|
52
|
+
redeemInvite(code: string, newUserId: string): Promise<RedeemInviteResult>;
|
|
53
|
+
/** Who invited this user (first redeemed invite), or null. */
|
|
54
|
+
invitedBy(userId: string): Promise<string | null>;
|
|
55
|
+
/** Users who joined on this inviter's invites, oldest first. */
|
|
56
|
+
invitees(inviterId: string): Promise<{
|
|
57
|
+
userId: string;
|
|
58
|
+
at: number;
|
|
59
|
+
}[]>;
|
|
60
|
+
/** Invites this user created, newest first, without codes (only hashes are stored). Includes revokedAt. */
|
|
61
|
+
invites(inviterId: string): Promise<(Omit<Invite, 'code'> & {
|
|
62
|
+
revokedAt: number | null;
|
|
63
|
+
})[]>;
|
|
64
|
+
/** Revoke an unused invite; it stops working and its quota slot is refunded. */
|
|
65
|
+
revokeInvite(inviteId: string): Promise<void>;
|
|
66
|
+
/** The user's public affiliate code: stable, created on first call, unlimited use. */
|
|
67
|
+
affiliateCode(userId: string): Promise<string>;
|
|
68
|
+
/** First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites. */
|
|
69
|
+
attribute(code: string, newUserId: string): Promise<{
|
|
70
|
+
ok: boolean;
|
|
71
|
+
affiliateId?: string;
|
|
72
|
+
}>;
|
|
73
|
+
/** The affiliate a user is attributed to, or null. */
|
|
74
|
+
affiliateOf(userId: string): Promise<string | null>;
|
|
75
|
+
/** Discount for the new customer and commission for the affiliate, via @profullstack/referrals. */
|
|
76
|
+
commission(amountCents: number): {
|
|
77
|
+
discountCents: number;
|
|
78
|
+
commissionCents: number;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
|
|
2
|
+
import { generateAffiliateCode, generateInviteCode, hashCode, newId, normalizeCode } from "./codes.js";
|
|
3
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
4
|
+
function publicInvite(r) {
|
|
5
|
+
return {
|
|
6
|
+
id: r.id,
|
|
7
|
+
inviterId: r.inviterId,
|
|
8
|
+
createdAt: r.createdAt,
|
|
9
|
+
expiresAt: r.expiresAt,
|
|
10
|
+
usedBy: r.usedBy,
|
|
11
|
+
usedAt: r.usedAt,
|
|
12
|
+
note: r.note,
|
|
13
|
+
revokedAt: r.revokedAt,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Scarce invite codes plus open affiliate links over any AffiliateStore. */
|
|
17
|
+
export class Affiliates {
|
|
18
|
+
store;
|
|
19
|
+
now;
|
|
20
|
+
defaultQuota;
|
|
21
|
+
inviteTtlMs;
|
|
22
|
+
split;
|
|
23
|
+
constructor(store, opts = {}) {
|
|
24
|
+
this.store = store;
|
|
25
|
+
this.now = opts.now ?? Date.now;
|
|
26
|
+
this.defaultQuota = opts.defaultQuota ?? 3;
|
|
27
|
+
this.inviteTtlMs = opts.inviteTtlMs === undefined ? 30 * DAY : opts.inviteTtlMs;
|
|
28
|
+
this.split = opts.split ?? DEFAULT_SPLIT;
|
|
29
|
+
}
|
|
30
|
+
async total(userId) {
|
|
31
|
+
const q = await this.store.getQuota(userId);
|
|
32
|
+
return q ?? this.defaultQuota;
|
|
33
|
+
}
|
|
34
|
+
/** Invites a user may create, has created (revoked-before-use ones are refunded), and has left. */
|
|
35
|
+
async quota(userId) {
|
|
36
|
+
const [total, used] = await Promise.all([this.total(userId), this.store.countInvites(userId)]);
|
|
37
|
+
return { total, used, left: Math.max(0, total - used) };
|
|
38
|
+
}
|
|
39
|
+
/** Admin: set a user's invite allowance (total, not additional). */
|
|
40
|
+
async setQuota(userId, total) {
|
|
41
|
+
if (!Number.isInteger(total) || total < 0)
|
|
42
|
+
throw new Error('quota must be a non-negative integer');
|
|
43
|
+
await this.store.setQuota(userId, total);
|
|
44
|
+
}
|
|
45
|
+
/** Mint one invite. Throws Error('no invites left') when the quota is spent. */
|
|
46
|
+
async createInvite(inviterId, note = '') {
|
|
47
|
+
if (!inviterId)
|
|
48
|
+
throw new Error('inviterId required');
|
|
49
|
+
const limit = await this.total(inviterId);
|
|
50
|
+
const createdAt = this.now();
|
|
51
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
52
|
+
const code = generateInviteCode();
|
|
53
|
+
const row = {
|
|
54
|
+
id: newId(),
|
|
55
|
+
codeHash: hashCode(code),
|
|
56
|
+
inviterId,
|
|
57
|
+
createdAt,
|
|
58
|
+
expiresAt: this.inviteTtlMs === null ? null : createdAt + this.inviteTtlMs,
|
|
59
|
+
usedBy: null,
|
|
60
|
+
usedAt: null,
|
|
61
|
+
note: String(note ?? ''),
|
|
62
|
+
revokedAt: null,
|
|
63
|
+
};
|
|
64
|
+
let inserted;
|
|
65
|
+
try {
|
|
66
|
+
inserted = await this.store.insertInvite(row, limit);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
// 60-bit code collision: astronomically unlikely, retry with a fresh code
|
|
70
|
+
if (/unique|collision|constraint/i.test(String(err?.message)))
|
|
71
|
+
continue;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
if (!inserted)
|
|
75
|
+
throw new Error('no invites left');
|
|
76
|
+
const { revokedAt: _r, ...pub } = publicInvite(row);
|
|
77
|
+
return { code, ...pub };
|
|
78
|
+
}
|
|
79
|
+
throw new Error('could not mint a unique invite code');
|
|
80
|
+
}
|
|
81
|
+
/** Spend an invite on a new user. Single use; the inviter cannot redeem their own. */
|
|
82
|
+
async redeemInvite(code, newUserId) {
|
|
83
|
+
if (!code || !newUserId)
|
|
84
|
+
return { ok: false, reason: 'invalid' };
|
|
85
|
+
const row = await this.store.getInviteByHash(hashCode(code));
|
|
86
|
+
if (!row)
|
|
87
|
+
return { ok: false, reason: 'invalid' };
|
|
88
|
+
if (row.revokedAt !== null)
|
|
89
|
+
return { ok: false, reason: 'revoked' };
|
|
90
|
+
if (row.usedBy !== null)
|
|
91
|
+
return { ok: false, reason: 'used' };
|
|
92
|
+
if (row.inviterId === newUserId)
|
|
93
|
+
return { ok: false, reason: 'self' };
|
|
94
|
+
const at = this.now();
|
|
95
|
+
if (row.expiresAt !== null && at >= row.expiresAt)
|
|
96
|
+
return { ok: false, reason: 'expired' };
|
|
97
|
+
const won = await this.store.claimInvite(row.id, newUserId, at);
|
|
98
|
+
if (!won) {
|
|
99
|
+
const again = await this.store.getInvite(row.id);
|
|
100
|
+
return { ok: false, reason: again && again.revokedAt !== null && again.usedBy === null ? 'revoked' : 'used' };
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, inviterId: row.inviterId, inviteId: row.id };
|
|
103
|
+
}
|
|
104
|
+
/** Who invited this user (first redeemed invite), or null. */
|
|
105
|
+
invitedBy(userId) {
|
|
106
|
+
return this.store.invitedBy(userId);
|
|
107
|
+
}
|
|
108
|
+
/** Users who joined on this inviter's invites, oldest first. */
|
|
109
|
+
invitees(inviterId) {
|
|
110
|
+
return this.store.invitees(inviterId);
|
|
111
|
+
}
|
|
112
|
+
/** Invites this user created, newest first, without codes (only hashes are stored). Includes revokedAt. */
|
|
113
|
+
async invites(inviterId) {
|
|
114
|
+
return (await this.store.listInvites(inviterId)).map(publicInvite);
|
|
115
|
+
}
|
|
116
|
+
/** Revoke an unused invite; it stops working and its quota slot is refunded. */
|
|
117
|
+
async revokeInvite(inviteId) {
|
|
118
|
+
await this.store.revokeInvite(inviteId, this.now());
|
|
119
|
+
}
|
|
120
|
+
/** The user's public affiliate code: stable, created on first call, unlimited use. */
|
|
121
|
+
async affiliateCode(userId) {
|
|
122
|
+
if (!userId)
|
|
123
|
+
throw new Error('userId required');
|
|
124
|
+
const existing = await this.store.getAffiliateCode(userId);
|
|
125
|
+
if (existing)
|
|
126
|
+
return existing;
|
|
127
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
128
|
+
const got = await this.store.putAffiliateCode(userId, generateAffiliateCode(), this.now());
|
|
129
|
+
if (got)
|
|
130
|
+
return got;
|
|
131
|
+
}
|
|
132
|
+
throw new Error('could not mint a unique affiliate code');
|
|
133
|
+
}
|
|
134
|
+
/** First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites. */
|
|
135
|
+
async attribute(code, newUserId) {
|
|
136
|
+
if (!code || !newUserId)
|
|
137
|
+
return { ok: false };
|
|
138
|
+
const norm = normalizeCode(code);
|
|
139
|
+
const affiliateId = await this.store.affiliateByCode(norm);
|
|
140
|
+
if (!affiliateId || affiliateId === newUserId)
|
|
141
|
+
return { ok: false };
|
|
142
|
+
const inserted = await this.store.attribute({ userId: newUserId, affiliateId, code: norm, at: this.now() });
|
|
143
|
+
if (inserted)
|
|
144
|
+
return { ok: true, affiliateId };
|
|
145
|
+
const prior = await this.store.getAttribution(newUserId);
|
|
146
|
+
return { ok: false, ...(prior ? { affiliateId: prior.affiliateId } : {}) };
|
|
147
|
+
}
|
|
148
|
+
/** The affiliate a user is attributed to, or null. */
|
|
149
|
+
async affiliateOf(userId) {
|
|
150
|
+
return (await this.store.getAttribution(userId))?.affiliateId ?? null;
|
|
151
|
+
}
|
|
152
|
+
/** Discount for the new customer and commission for the affiliate, via @profullstack/referrals. */
|
|
153
|
+
commission(amountCents) {
|
|
154
|
+
const r = calculateReferral(amountCents, this.split);
|
|
155
|
+
return { discountCents: r.discountCents, commissionCents: r.commissionCents };
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/cli.d.ts
ADDED
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,12 @@
|
|
|
1
|
+
export { Affiliates } from './affiliates.js';
|
|
2
|
+
export type { Invite, RedeemInviteResult, AffiliatesOptions } 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 { normalizeCode, generateInviteCode, hashCode } from './codes.js';
|
|
11
|
+
export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
|
|
12
|
+
export type { ReferralSplit } from '@profullstack/referrals';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
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 { normalizeCode, generateInviteCode, hashCode } from "./codes.js";
|
|
6
|
+
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/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
|
+
}
|
package/dist/sqlite.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type SqlStoreOptions } from './sql.js';
|
|
2
|
+
import type { AffiliateStore } from './store.js';
|
|
3
|
+
interface SqliteStatement {
|
|
4
|
+
all(...params: any[]): any;
|
|
5
|
+
get?(...params: any[]): any;
|
|
6
|
+
run?(...params: any[]): any;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A synchronous SQLite handle: bun:sqlite `Database` (query/prepare + exec),
|
|
10
|
+
* better-sqlite3 `Database` (prepare + exec) or node:sqlite `DatabaseSync` (prepare + exec).
|
|
11
|
+
*/
|
|
12
|
+
export type SqliteLike = {
|
|
13
|
+
query(sql: string): SqliteStatement;
|
|
14
|
+
exec(sql: string): unknown;
|
|
15
|
+
} | {
|
|
16
|
+
prepare(sql: string): SqliteStatement;
|
|
17
|
+
exec(sql: string): unknown;
|
|
18
|
+
};
|
|
19
|
+
/** Rewrite $1..$n to positional `?`, expanding params in order of appearance. */
|
|
20
|
+
export declare function toPositional(sql: string, params: unknown[]): {
|
|
21
|
+
sql: string;
|
|
22
|
+
params: unknown[];
|
|
23
|
+
};
|
|
24
|
+
/** Store on a synchronous SQLite handle. Creates its tables (prefix `aff_`) on first use. */
|
|
25
|
+
export declare function sqliteStore(db: SqliteLike, opts?: SqlStoreOptions): AffiliateStore;
|
|
26
|
+
export {};
|
package/dist/sqlite.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { sqlStore } from "./sql.js";
|
|
2
|
+
/** Rewrite $1..$n to positional `?`, expanding params in order of appearance. */
|
|
3
|
+
export function toPositional(sql, params) {
|
|
4
|
+
const out = [];
|
|
5
|
+
const text = sql.replace(/\$(\d+)/g, (_m, n) => {
|
|
6
|
+
out.push(params[Number(n) - 1]);
|
|
7
|
+
return '?';
|
|
8
|
+
});
|
|
9
|
+
return { sql: text, params: out.map((v) => (v === undefined ? null : v)) };
|
|
10
|
+
}
|
|
11
|
+
/** Store on a synchronous SQLite handle. Creates its tables (prefix `aff_`) on first use. */
|
|
12
|
+
export function sqliteStore(db, opts = {}) {
|
|
13
|
+
const anyDb = db;
|
|
14
|
+
const prep = typeof anyDb.query === 'function' ? (sql) => anyDb.query(sql) : (sql) => anyDb.prepare(sql);
|
|
15
|
+
return sqlStore(async (sql, params) => {
|
|
16
|
+
const { sql: text, params: args } = toPositional(sql, params);
|
|
17
|
+
if (args.length === 0 && /^\s*CREATE\s/i.test(text)) {
|
|
18
|
+
anyDb.exec(text);
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const st = prep(text);
|
|
22
|
+
if (st.reader === false && st.run) {
|
|
23
|
+
// better-sqlite3 refuses .all() on statements that return no rows
|
|
24
|
+
st.run(...args);
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const rows = st.all(...args);
|
|
28
|
+
return Array.isArray(rows) ? rows : [];
|
|
29
|
+
}, opts);
|
|
30
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** One invite as persisted. The plaintext code is never stored, only its sha256. */
|
|
2
|
+
export interface InviteRow {
|
|
3
|
+
id: string;
|
|
4
|
+
codeHash: string;
|
|
5
|
+
inviterId: string;
|
|
6
|
+
createdAt: number;
|
|
7
|
+
expiresAt: number | null;
|
|
8
|
+
usedBy: string | null;
|
|
9
|
+
usedAt: number | null;
|
|
10
|
+
note: string;
|
|
11
|
+
revokedAt: number | null;
|
|
12
|
+
}
|
|
13
|
+
export interface AttributionRow {
|
|
14
|
+
userId: string;
|
|
15
|
+
affiliateId: string;
|
|
16
|
+
code: string;
|
|
17
|
+
at: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Persistence contract. Every method is async so SQL and remote stores fit.
|
|
21
|
+
* The two methods that guard scarcity must be atomic in the store itself:
|
|
22
|
+
* `insertInvite` (quota check + insert) and `claimInvite` (single use).
|
|
23
|
+
*
|
|
24
|
+
* "Counted" invites are the ones that use up quota: every invite except one
|
|
25
|
+
* revoked before it was used.
|
|
26
|
+
*/
|
|
27
|
+
export interface AffiliateStore {
|
|
28
|
+
/** Custom quota for a user, or null when the default applies. */
|
|
29
|
+
getQuota(userId: string): Promise<number | null>;
|
|
30
|
+
setQuota(userId: string, total: number): Promise<void>;
|
|
31
|
+
/** Number of counted invites created by this user. */
|
|
32
|
+
countInvites(inviterId: string): Promise<number>;
|
|
33
|
+
/** Insert only if the inviter has fewer than `limit` counted invites. Returns false when over quota. */
|
|
34
|
+
insertInvite(row: InviteRow, limit: number): Promise<boolean>;
|
|
35
|
+
getInviteByHash(codeHash: string): Promise<InviteRow | null>;
|
|
36
|
+
getInvite(id: string): Promise<InviteRow | null>;
|
|
37
|
+
/** Atomic single use: sets used_by/used_at only where used_by IS NULL and not revoked. True if this call won. */
|
|
38
|
+
claimInvite(id: string, userId: string, at: number): Promise<boolean>;
|
|
39
|
+
revokeInvite(id: string, at: number): Promise<void>;
|
|
40
|
+
/** All invites created by the user, newest first. */
|
|
41
|
+
listInvites(inviterId: string): Promise<InviteRow[]>;
|
|
42
|
+
/** Inviter of the earliest invite this user redeemed. */
|
|
43
|
+
invitedBy(userId: string): Promise<string | null>;
|
|
44
|
+
/** Users who redeemed this inviter's invites, oldest first. */
|
|
45
|
+
invitees(inviterId: string): Promise<{
|
|
46
|
+
userId: string;
|
|
47
|
+
at: number;
|
|
48
|
+
}[]>;
|
|
49
|
+
getAffiliateCode(userId: string): Promise<string | null>;
|
|
50
|
+
/** Insert if the user has none yet. Returns the user's code after the call (existing one wins), or null if `code` collided with another user's. */
|
|
51
|
+
putAffiliateCode(userId: string, code: string, at: number): Promise<string | null>;
|
|
52
|
+
affiliateByCode(code: string): Promise<string | null>;
|
|
53
|
+
/** First touch: insert only if the user has no attribution. True if inserted. */
|
|
54
|
+
attribute(row: AttributionRow): Promise<boolean>;
|
|
55
|
+
getAttribution(userId: string): Promise<AttributionRow | null>;
|
|
56
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/affiliates",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Scarce invite codes (per-user quota, single use, who-invited-whom) plus open affiliate links with first-touch attribution. Commission math from @profullstack/referrals. Memory, SQLite (bun:sqlite, node:sqlite, better-sqlite3) and Postgres stores, plus a CLI.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"invite",
|
|
8
|
+
"invite-codes",
|
|
9
|
+
"invite-only",
|
|
10
|
+
"affiliate",
|
|
11
|
+
"referral",
|
|
12
|
+
"attribution",
|
|
13
|
+
"sqlite",
|
|
14
|
+
"postgres",
|
|
15
|
+
"profullstack"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/profullstack/affiliates.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/profullstack/affiliates#readme",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"affiliates": "./dist/cli.js"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && sed -i -E \"s#(from '\\./[a-z-]+)\\.ts'#\\1.js'#\" dist/*.d.ts && chmod +x dist/cli.js",
|
|
43
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
44
|
+
"test": "node --experimental-strip-types --no-warnings --test test/*.test.ts",
|
|
45
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@profullstack/referrals": "^0.1.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^24.0.0",
|
|
52
|
+
"@types/pg": "^8.23.1",
|
|
53
|
+
"pg": "^8.23.1",
|
|
54
|
+
"typescript": "^5.9.3"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=22.13"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|