@profullstack/affiliates 0.1.0 → 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/README.md +90 -5
- package/dist/affiliates.d.ts +26 -5
- package/dist/affiliates.js +26 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/ref.d.ts +22 -0
- package/dist/ref.js +49 -0
- package/dist/store.d.ts +5 -0
- package/dist/supabase.d.ts +31 -0
- package/dist/supabase.js +226 -0
- package/migrations/supabase.sql +319 -0
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -13,8 +13,9 @@ Scarce invite codes plus open affiliate links, for every Profullstack property.
|
|
|
13
13
|
(`calculateReferral`, `DEFAULT_SPLIT` = 60% affiliate, 20% customer discount), and its
|
|
14
14
|
`buildReferralUrl` / `extractCode` are re-exported.
|
|
15
15
|
|
|
16
|
-
Stores: in-memory, SQLite (`bun:sqlite`, `node:sqlite`, `better-sqlite3`)
|
|
17
|
-
with a node-postgres style `query`)
|
|
16
|
+
Stores: in-memory, SQLite (`bun:sqlite`, `node:sqlite`, `better-sqlite3`), Postgres (anything
|
|
17
|
+
with a node-postgres style `query`) and Supabase (a supabase-js client, through `rpc()` only). SQL
|
|
18
|
+
stores create their own tables (prefix `aff_`); the Supabase store ships its schema as a migration.
|
|
18
19
|
|
|
19
20
|
```sh
|
|
20
21
|
npm i @profullstack/affiliates
|
|
@@ -52,6 +53,72 @@ import { Affiliates, postgresStore } from '@profullstack/affiliates';
|
|
|
52
53
|
const aff = new Affiliates(postgresStore(new pg.Pool({ connectionString: process.env.DATABASE_URL })));
|
|
53
54
|
```
|
|
54
55
|
|
|
56
|
+
## On Supabase
|
|
57
|
+
|
|
58
|
+
For apps that only have a supabase-js client (service role key, no direct `DATABASE_URL`). Every
|
|
59
|
+
operation is one `client.rpc()` call to a Postgres function, so the scarce parts are atomic in the
|
|
60
|
+
database, not read-modify-write in JS: the quota check and insert run under a per-inviter advisory
|
|
61
|
+
lock (the same logic as `postgresStore`'s `aff_insert_invite()`), redemption is
|
|
62
|
+
`UPDATE ... WHERE used_by IS NULL`, and attribution is `INSERT ... ON CONFLICT DO NOTHING`.
|
|
63
|
+
`@supabase/supabase-js` is not a dependency; anything with `rpc(fn, args) -> { data, error }` works.
|
|
64
|
+
|
|
65
|
+
1. Add the migration (tables, RLS on with no policies, `SECURITY DEFINER` functions with a pinned
|
|
66
|
+
`search_path`, executable only by `service_role`):
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
cp node_modules/@profullstack/affiliates/migrations/supabase.sql supabase/migrations/20261026000000_affiliates.sql
|
|
70
|
+
# other prefix: node -e "import('@profullstack/affiliates').then(m => process.stdout.write(m.supabaseMigrationSql('gc_')))" > ...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
2. Use a service-role client on the server only:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// lib/affiliates.ts (server only)
|
|
77
|
+
import { createClient } from '@supabase/supabase-js';
|
|
78
|
+
import { Affiliates, supabaseStore } from '@profullstack/affiliates';
|
|
79
|
+
let aff: Affiliates | undefined;
|
|
80
|
+
export const getAffiliates = () => (aff ??= new Affiliates(supabaseStore(createClient(
|
|
81
|
+
process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { auth: { persistSession: false } }))));
|
|
82
|
+
|
|
83
|
+
// app/auth/callback/route.ts, after exchangeCodeForSession() gave you `user`
|
|
84
|
+
import { refFromRequest } from '@profullstack/affiliates';
|
|
85
|
+
const ref = refFromRequest(request); // ?ref= or the referral_code cookie
|
|
86
|
+
if (ref) await getAffiliates().attribute(ref, user.id); // { external: true } = not ours, see below
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Build the client lazily (as above), not at module top level: Next.js inlines `process.env.X` at
|
|
90
|
+
build time, and a secret that is only present at runtime would compile in as `undefined`.
|
|
91
|
+
|
|
92
|
+
The tables are the same as `postgresStore`'s, so a script with a direct Postgres URL can use
|
|
93
|
+
`postgresStore` on the same data. `supabaseStore(client, { prefix })` must match the prefix the
|
|
94
|
+
migration was generated with. The anon and authenticated keys can neither read the tables nor call
|
|
95
|
+
the functions.
|
|
96
|
+
|
|
97
|
+
## Already using @profullstack/referrals
|
|
98
|
+
|
|
99
|
+
Sites that already ship `@profullstack/referrals` keep it: its tables, its `trackReferralCode`
|
|
100
|
+
middleware and its single `referral_code` cookie. Affiliates reads that cookie instead of setting
|
|
101
|
+
its own, so one sign-up is never counted twice.
|
|
102
|
+
|
|
103
|
+
- **Do not set a second cookie.** In the signup or auth callback, take the code with
|
|
104
|
+
`refFromRequest(request)` (it reads `?ref=` and then `REF_COOKIE`, which is `'referral_code'`, the
|
|
105
|
+
name `@profullstack/referrals/next` sets) and pass it to `aff.attribute(code, newUserId)`.
|
|
106
|
+
- **Codes that are theirs come back `{ ok: false, external: true }`** and nothing is recorded; hand
|
|
107
|
+
them to the existing referrals flow (`applyReferral`, the site's own usage table). Our codes come
|
|
108
|
+
back `{ ok: true, affiliateId }`. Unknown codes come back `{ ok: false }`.
|
|
109
|
+
- How "theirs" is decided: `supabaseStore` checks the `referral_codes` table from
|
|
110
|
+
`20260602000000_referrals.sql` when it exists. Other stores take
|
|
111
|
+
`new Affiliates(store, { externalCodes })`, where `externalCodes` is your @profullstack/referrals
|
|
112
|
+
`ReferralStore` (its `getCode` is used) or a function `(code) => boolean | Promise<boolean>`.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const code = refFromRequest(request);
|
|
116
|
+
if (code) {
|
|
117
|
+
const r = await aff.attribute(code, user.id);
|
|
118
|
+
if (r.external) await applyReferral({ code, newUserId: user.id, amountCents, store: referralStore });
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
55
122
|
## Invite-only signup in 5 lines
|
|
56
123
|
|
|
57
124
|
```ts
|
|
@@ -90,6 +157,18 @@ export function postgresStore(
|
|
|
90
157
|
pool: { query(sql: string, params?: unknown[]): Promise<{ rows: any[] }> },
|
|
91
158
|
opts?: { prefix?: string },
|
|
92
159
|
): AffiliateStore;
|
|
160
|
+
export function supabaseStore(
|
|
161
|
+
client: { rpc(fn: string, args?: Record<string, unknown>): PromiseLike<{ data: any; error: any }> },
|
|
162
|
+
opts?: { prefix?: string },
|
|
163
|
+
): AffiliateStore;
|
|
164
|
+
export function supabaseMigrationSql(prefix?: string): string; // = migrations/supabase.sql for 'aff_'
|
|
165
|
+
|
|
166
|
+
export const REF_COOKIE = 'referral_code'; // @profullstack/referrals' cookie
|
|
167
|
+
export const REF_PARAM = 'ref';
|
|
168
|
+
export function refFromRequest(
|
|
169
|
+
req: Request | { headers: Headers; url: string },
|
|
170
|
+
opts?: { param?: string; cookieName?: string },
|
|
171
|
+
): string | null; // ?ref= first, then the cookie
|
|
93
172
|
|
|
94
173
|
export class Affiliates {
|
|
95
174
|
constructor(store: AffiliateStore, opts?: {
|
|
@@ -97,6 +176,7 @@ export class Affiliates {
|
|
|
97
176
|
defaultQuota?: number; // default 3
|
|
98
177
|
inviteTtlMs?: number | null; // default 30 days, null = never expires
|
|
99
178
|
split?: ReferralSplit; // default DEFAULT_SPLIT from @profullstack/referrals
|
|
179
|
+
externalCodes?: ReferralStore | ((code: string) => boolean | Promise<boolean>);
|
|
100
180
|
});
|
|
101
181
|
quota(userId: string): Promise<{ total: number; used: number; left: number }>;
|
|
102
182
|
setQuota(userId: string, total: number): Promise<void>;
|
|
@@ -107,7 +187,8 @@ export class Affiliates {
|
|
|
107
187
|
invites(inviterId: string): Promise<Omit<Invite, 'code'>[]>; // newest first; also carries revokedAt
|
|
108
188
|
revokeInvite(inviteId: string): Promise<void>;
|
|
109
189
|
affiliateCode(userId: string): Promise<string>;
|
|
110
|
-
attribute(code: string, newUserId: string): Promise<{ ok: boolean; affiliateId?: string }>;
|
|
190
|
+
attribute(code: string, newUserId: string): Promise<{ ok: boolean; affiliateId?: string; external?: boolean }>;
|
|
191
|
+
isExternalCode(code: string): Promise<boolean>;
|
|
111
192
|
affiliateOf(userId: string): Promise<string | null>;
|
|
112
193
|
commission(amountCents: number): { discountCents: number; commissionCents: number };
|
|
113
194
|
}
|
|
@@ -130,7 +211,8 @@ Semantics worth knowing:
|
|
|
130
211
|
`O` to `0` and `I`/`L` to `1`. Invite codes come back as `INV-XXXX-XXXX-XXXX` (the `INV` prefix is
|
|
131
212
|
optional on input), so `inv 7k3m qx9d 2f8h` redeems.
|
|
132
213
|
- **Affiliate attribution** is first touch: a second `attribute` for the same user returns
|
|
133
|
-
`{ ok: false, affiliateId: <the original> }`. Users cannot attribute themselves.
|
|
214
|
+
`{ ok: false, affiliateId: <the original> }`. Users cannot attribute themselves. A code that is not
|
|
215
|
+
ours but belongs to the site's @profullstack/referrals setup returns `{ ok: false, external: true }`.
|
|
134
216
|
|
|
135
217
|
Tables (prefix configurable): `aff_invites`, `aff_quotas`, `aff_codes`, `aff_attributions`.
|
|
136
218
|
|
|
@@ -157,7 +239,10 @@ command takes `--json`. Other options: `--prefix`, `--default-quota`, `--ttl-day
|
|
|
157
239
|
|
|
158
240
|
```sh
|
|
159
241
|
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
|
|
242
|
+
AFFILIATES_TEST_PG_URL=postgres://postgres:x@127.0.0.1:55432/postgres npm test # adds real Postgres + supabaseStore over rpc()
|
|
243
|
+
# adds real PostgREST too (see .github/workflows and test/setup-postgrest.ts):
|
|
244
|
+
# AFFILIATES_TEST_POSTGREST_URL=http://127.0.0.1:3000 AFFILIATES_TEST_POSTGREST_JWT_SECRET=... npm test
|
|
245
|
+
npm run gen:migration # rewrite migrations/supabase.sql after changing supabaseMigrationSql()
|
|
161
246
|
npm run build # tsc to dist/
|
|
162
247
|
```
|
|
163
248
|
|
package/dist/affiliates.d.ts
CHANGED
|
@@ -19,6 +19,16 @@ export type RedeemInviteResult = {
|
|
|
19
19
|
ok: false;
|
|
20
20
|
reason: 'invalid' | 'expired' | 'used' | 'self' | 'revoked';
|
|
21
21
|
};
|
|
22
|
+
/** Result of `attribute`. `external: true` means the code belongs to the site's other referral system. */
|
|
23
|
+
export type AttributeResult = {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
affiliateId?: string;
|
|
26
|
+
external?: boolean;
|
|
27
|
+
};
|
|
28
|
+
/** A check for codes owned by another referral system: a function, or an @profullstack/referrals ReferralStore. */
|
|
29
|
+
export type ExternalCodes = ((code: string) => boolean | Promise<boolean>) | {
|
|
30
|
+
getCode(code: string): Promise<unknown | null>;
|
|
31
|
+
};
|
|
22
32
|
export interface AffiliatesOptions {
|
|
23
33
|
/** Clock in ms. Default Date.now. */
|
|
24
34
|
now?: () => number;
|
|
@@ -28,6 +38,13 @@ export interface AffiliatesOptions {
|
|
|
28
38
|
inviteTtlMs?: number | null;
|
|
29
39
|
/** Commission split passed to @profullstack/referrals. Default DEFAULT_SPLIT (60% affiliate, 20% customer). */
|
|
30
40
|
split?: ReferralSplit;
|
|
41
|
+
/**
|
|
42
|
+
* Codes that belong to an existing referral system (sites already on @profullstack/referrals).
|
|
43
|
+
* When `attribute()` gets a code that is not ours and this says it is theirs, it returns
|
|
44
|
+
* `{ ok: false, external: true }` and records nothing, so the existing flow handles it.
|
|
45
|
+
* Default: the store's `isExternalCode` when it has one (supabaseStore checks `referral_codes`).
|
|
46
|
+
*/
|
|
47
|
+
externalCodes?: ExternalCodes;
|
|
31
48
|
}
|
|
32
49
|
/** Scarce invite codes plus open affiliate links over any AffiliateStore. */
|
|
33
50
|
export declare class Affiliates {
|
|
@@ -36,7 +53,10 @@ export declare class Affiliates {
|
|
|
36
53
|
readonly defaultQuota: number;
|
|
37
54
|
readonly inviteTtlMs: number | null;
|
|
38
55
|
readonly split: ReferralSplit;
|
|
56
|
+
private readonly externalCodes;
|
|
39
57
|
constructor(store: AffiliateStore, opts?: AffiliatesOptions);
|
|
58
|
+
/** True when `code` belongs to the site's other referral system (see `externalCodes`). */
|
|
59
|
+
isExternalCode(code: string): Promise<boolean>;
|
|
40
60
|
private total;
|
|
41
61
|
/** Invites a user may create, has created (revoked-before-use ones are refunded), and has left. */
|
|
42
62
|
quota(userId: string): Promise<{
|
|
@@ -65,11 +85,12 @@ export declare class Affiliates {
|
|
|
65
85
|
revokeInvite(inviteId: string): Promise<void>;
|
|
66
86
|
/** The user's public affiliate code: stable, created on first call, unlimited use. */
|
|
67
87
|
affiliateCode(userId: string): Promise<string>;
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
/**
|
|
89
|
+
* First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites.
|
|
90
|
+
* A code that is not ours but belongs to the site's existing referral system returns
|
|
91
|
+
* `{ ok: false, external: true }` and records nothing.
|
|
92
|
+
*/
|
|
93
|
+
attribute(code: string, newUserId: string): Promise<AttributeResult>;
|
|
73
94
|
/** The affiliate a user is attributed to, or null. */
|
|
74
95
|
affiliateOf(userId: string): Promise<string | null>;
|
|
75
96
|
/** Discount for the new customer and commission for the affiliate, via @profullstack/referrals. */
|
package/dist/affiliates.js
CHANGED
|
@@ -20,12 +20,29 @@ export class Affiliates {
|
|
|
20
20
|
defaultQuota;
|
|
21
21
|
inviteTtlMs;
|
|
22
22
|
split;
|
|
23
|
+
externalCodes;
|
|
23
24
|
constructor(store, opts = {}) {
|
|
24
25
|
this.store = store;
|
|
25
26
|
this.now = opts.now ?? Date.now;
|
|
26
27
|
this.defaultQuota = opts.defaultQuota ?? 3;
|
|
27
28
|
this.inviteTtlMs = opts.inviteTtlMs === undefined ? 30 * DAY : opts.inviteTtlMs;
|
|
28
29
|
this.split = opts.split ?? DEFAULT_SPLIT;
|
|
30
|
+
this.externalCodes = opts.externalCodes;
|
|
31
|
+
}
|
|
32
|
+
/** True when `code` belongs to the site's other referral system (see `externalCodes`). */
|
|
33
|
+
async isExternalCode(code) {
|
|
34
|
+
const raw = String(code ?? '').trim();
|
|
35
|
+
if (!raw)
|
|
36
|
+
return false;
|
|
37
|
+
const ext = this.externalCodes;
|
|
38
|
+
if (typeof ext === 'function')
|
|
39
|
+
return Boolean(await ext(raw));
|
|
40
|
+
// @profullstack/referrals looks codes up upper-cased and trimmed (validateCode)
|
|
41
|
+
if (ext)
|
|
42
|
+
return (await ext.getCode(raw.toUpperCase())) != null;
|
|
43
|
+
if (this.store.isExternalCode)
|
|
44
|
+
return this.store.isExternalCode(raw);
|
|
45
|
+
return false;
|
|
29
46
|
}
|
|
30
47
|
async total(userId) {
|
|
31
48
|
const q = await this.store.getQuota(userId);
|
|
@@ -131,13 +148,19 @@ export class Affiliates {
|
|
|
131
148
|
}
|
|
132
149
|
throw new Error('could not mint a unique affiliate code');
|
|
133
150
|
}
|
|
134
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites.
|
|
153
|
+
* A code that is not ours but belongs to the site's existing referral system returns
|
|
154
|
+
* `{ ok: false, external: true }` and records nothing.
|
|
155
|
+
*/
|
|
135
156
|
async attribute(code, newUserId) {
|
|
136
157
|
if (!code || !newUserId)
|
|
137
158
|
return { ok: false };
|
|
138
159
|
const norm = normalizeCode(code);
|
|
139
|
-
const affiliateId = await this.store.affiliateByCode(norm);
|
|
140
|
-
if (!affiliateId
|
|
160
|
+
const affiliateId = norm ? await this.store.affiliateByCode(norm) : null;
|
|
161
|
+
if (!affiliateId)
|
|
162
|
+
return (await this.isExternalCode(code)) ? { ok: false, external: true } : { ok: false };
|
|
163
|
+
if (affiliateId === newUserId)
|
|
141
164
|
return { ok: false };
|
|
142
165
|
const inserted = await this.store.attribute({ userId: newUserId, affiliateId, code: norm, at: this.now() });
|
|
143
166
|
if (inserted)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { Affiliates } from './affiliates.js';
|
|
2
|
-
export type { Invite, RedeemInviteResult, AffiliatesOptions } from './affiliates.js';
|
|
2
|
+
export type { Invite, RedeemInviteResult, AffiliatesOptions, AttributeResult, ExternalCodes } from './affiliates.js';
|
|
3
3
|
export type { AffiliateStore, InviteRow, AttributionRow } from './store.js';
|
|
4
4
|
export { memoryStore } from './memory.js';
|
|
5
5
|
export { sqliteStore } from './sqlite.js';
|
|
@@ -7,6 +7,10 @@ export type { SqliteLike } from './sqlite.js';
|
|
|
7
7
|
export { postgresStore } from './postgres.js';
|
|
8
8
|
export type { PgLike } from './postgres.js';
|
|
9
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';
|
|
10
14
|
export { normalizeCode, generateInviteCode, hashCode } from './codes.js';
|
|
11
15
|
export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
|
|
12
16
|
export type { ReferralSplit } from '@profullstack/referrals';
|
package/dist/index.js
CHANGED
|
@@ -2,5 +2,7 @@ export { Affiliates } from "./affiliates.js";
|
|
|
2
2
|
export { memoryStore } from "./memory.js";
|
|
3
3
|
export { sqliteStore } from "./sqlite.js";
|
|
4
4
|
export { postgresStore } from "./postgres.js";
|
|
5
|
+
export { supabaseStore, supabaseMigrationSql } from "./supabase.js";
|
|
6
|
+
export { REF_COOKIE, REF_PARAM, refFromRequest } from "./ref.js";
|
|
5
7
|
export { normalizeCode, generateInviteCode, hashCode } from "./codes.js";
|
|
6
8
|
export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
|
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/store.d.ts
CHANGED
|
@@ -53,4 +53,9 @@ export interface AffiliateStore {
|
|
|
53
53
|
/** First touch: insert only if the user has no attribution. True if inserted. */
|
|
54
54
|
attribute(row: AttributionRow): Promise<boolean>;
|
|
55
55
|
getAttribution(userId: string): Promise<AttributionRow | null>;
|
|
56
|
+
/**
|
|
57
|
+
* Optional: true when `code` is not one of ours but belongs to another referral system on the same
|
|
58
|
+
* site (for example an @profullstack/referrals `referral_codes` row). supabaseStore implements it.
|
|
59
|
+
*/
|
|
60
|
+
isExternalCode?(code: string): Promise<boolean>;
|
|
56
61
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AffiliateStore } from './store.js';
|
|
2
|
+
/**
|
|
3
|
+
* The one supabase-js call this store uses: `client.rpc(fn, args)`. A `SupabaseClient` from
|
|
4
|
+
* `@supabase/supabase-js` (v2) fits as is; so does any PostgREST client with the same shape.
|
|
5
|
+
* Use a service-role client: the functions are not executable by `anon` or `authenticated`.
|
|
6
|
+
*/
|
|
7
|
+
export interface SupabaseLike {
|
|
8
|
+
rpc(fn: string, args?: Record<string, unknown>): PromiseLike<{
|
|
9
|
+
data: any;
|
|
10
|
+
error: any;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
export interface SupabaseStoreOptions {
|
|
14
|
+
/** Table and function name prefix. Default `aff_`. Must match the prefix the migration was generated with. */
|
|
15
|
+
prefix?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* SQL for a Supabase migration: the four tables (same schema as `postgresStore`, so both stores can
|
|
19
|
+
* share them), RLS enabled with no policies (anon and authenticated keys see nothing), and one
|
|
20
|
+
* SECURITY DEFINER function per store operation with a pinned search_path, executable only by
|
|
21
|
+
* `service_role`. The scarce operations are atomic inside those functions: quota check + insert
|
|
22
|
+
* under a per-inviter advisory lock, single-use claim with `UPDATE ... WHERE used_by IS NULL`, and
|
|
23
|
+
* first-touch attribution with `ON CONFLICT DO NOTHING`. Idempotent: safe to run twice.
|
|
24
|
+
*/
|
|
25
|
+
export declare function supabaseMigrationSql(prefix?: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Store over supabase-js / PostgREST (service-role client). Every operation is one `rpc()` call to a
|
|
28
|
+
* function from `supabaseMigrationSql()` (shipped as `migrations/supabase.sql`); apply it first.
|
|
29
|
+
* Also answers `isExternalCode` from @profullstack/referrals' `referral_codes` table when it exists.
|
|
30
|
+
*/
|
|
31
|
+
export declare function supabaseStore(client: SupabaseLike, opts?: SupabaseStoreOptions): AffiliateStore;
|
package/dist/supabase.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { ddl } from "./sql.js";
|
|
2
|
+
function checkPrefix(prefix) {
|
|
3
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix))
|
|
4
|
+
throw new Error(`invalid table prefix: ${prefix}`);
|
|
5
|
+
return prefix;
|
|
6
|
+
}
|
|
7
|
+
const num = (v) => (v === null || v === undefined ? null : Number(v));
|
|
8
|
+
const str = (v) => (v === null || v === undefined ? null : String(v));
|
|
9
|
+
function toInvite(r) {
|
|
10
|
+
return {
|
|
11
|
+
id: String(r.id),
|
|
12
|
+
codeHash: String(r.code_hash),
|
|
13
|
+
inviterId: String(r.inviter_id),
|
|
14
|
+
createdAt: Number(r.created_at),
|
|
15
|
+
expiresAt: num(r.expires_at),
|
|
16
|
+
usedBy: str(r.used_by),
|
|
17
|
+
usedAt: num(r.used_at),
|
|
18
|
+
note: r.note == null ? '' : String(r.note),
|
|
19
|
+
revokedAt: num(r.revoked_at),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** PostgREST returns a set-returning function as an array and a scalar one as the bare value. */
|
|
23
|
+
const rows = (data) => (Array.isArray(data) ? data : data == null ? [] : [data]);
|
|
24
|
+
const scalar = (data) => (Array.isArray(data) ? (data[0] ?? null) : data);
|
|
25
|
+
const truthy = (v) => v === true || v === 't' || v === 'true' || v === 1;
|
|
26
|
+
/**
|
|
27
|
+
* SQL for a Supabase migration: the four tables (same schema as `postgresStore`, so both stores can
|
|
28
|
+
* share them), RLS enabled with no policies (anon and authenticated keys see nothing), and one
|
|
29
|
+
* SECURITY DEFINER function per store operation with a pinned search_path, executable only by
|
|
30
|
+
* `service_role`. The scarce operations are atomic inside those functions: quota check + insert
|
|
31
|
+
* under a per-inviter advisory lock, single-use claim with `UPDATE ... WHERE used_by IS NULL`, and
|
|
32
|
+
* first-touch attribution with `ON CONFLICT DO NOTHING`. Idempotent: safe to run twice.
|
|
33
|
+
*/
|
|
34
|
+
export function supabaseMigrationSql(prefix = 'aff_') {
|
|
35
|
+
const p = checkPrefix(prefix);
|
|
36
|
+
const sigs = [];
|
|
37
|
+
const bodies = [];
|
|
38
|
+
const fn = (name, args, returns, body, lang = 'sql') => {
|
|
39
|
+
const types = args ? args.split(',').map((a) => a.trim().split(/\s+/).slice(1).join(' ')) : [];
|
|
40
|
+
sigs.push(`public.${p}${name}(${types.join(', ')})`);
|
|
41
|
+
bodies.push(`CREATE OR REPLACE FUNCTION public.${p}${name}(${args})
|
|
42
|
+
RETURNS ${returns}
|
|
43
|
+
LANGUAGE ${lang}
|
|
44
|
+
SECURITY DEFINER
|
|
45
|
+
SET search_path = public, pg_temp
|
|
46
|
+
AS $fn$
|
|
47
|
+
${body.trim()}
|
|
48
|
+
$fn$;
|
|
49
|
+
`);
|
|
50
|
+
};
|
|
51
|
+
const T = `public.${p}invites`;
|
|
52
|
+
const COUNTED = `(revoked_at IS NULL OR used_by IS NOT NULL)`;
|
|
53
|
+
fn('get_quota', 'p_user TEXT', 'INTEGER', `SELECT total FROM public.${p}quotas WHERE user_id = p_user`);
|
|
54
|
+
fn('set_quota', 'p_user TEXT, p_total INTEGER', 'VOID', `INSERT INTO public.${p}quotas (user_id, total) VALUES (p_user, p_total)
|
|
55
|
+
ON CONFLICT (user_id) DO UPDATE SET total = excluded.total`);
|
|
56
|
+
fn('count_invites', 'p_inviter TEXT', 'INTEGER', `SELECT count(*)::INTEGER FROM ${T} WHERE inviter_id = p_inviter AND ${COUNTED}`);
|
|
57
|
+
// Same logic and signature as postgresStore's insert_invite(), so the two stores can share a schema.
|
|
58
|
+
fn('insert_invite', 'p_id TEXT, p_hash TEXT, p_inviter TEXT, p_created BIGINT, p_expires BIGINT, p_note TEXT, p_limit INTEGER', 'BOOLEAN', `BEGIN
|
|
59
|
+
-- Per-inviter lock; plpgsql takes a fresh snapshot per statement, so the count sees every committed invite.
|
|
60
|
+
PERFORM pg_advisory_xact_lock(hashtext('${p}invites'), hashtext(p_inviter));
|
|
61
|
+
IF (SELECT count(*) FROM ${T} WHERE inviter_id = p_inviter AND ${COUNTED}) >= p_limit THEN
|
|
62
|
+
RETURN FALSE;
|
|
63
|
+
END IF;
|
|
64
|
+
INSERT INTO ${T} (id, code_hash, inviter_id, created_at, expires_at, note)
|
|
65
|
+
VALUES (p_id, p_hash, p_inviter, p_created, p_expires, coalesce(p_note, ''));
|
|
66
|
+
RETURN TRUE;
|
|
67
|
+
END`, 'plpgsql');
|
|
68
|
+
fn('get_invite_by_hash', 'p_hash TEXT', `SETOF ${T}`, `SELECT * FROM ${T} WHERE code_hash = p_hash`);
|
|
69
|
+
fn('get_invite', 'p_id TEXT', `SETOF ${T}`, `SELECT * FROM ${T} WHERE id = p_id`);
|
|
70
|
+
fn('claim_invite', 'p_id TEXT, p_user TEXT, p_at BIGINT', 'BOOLEAN', `WITH won AS (
|
|
71
|
+
UPDATE ${T} SET used_by = p_user, used_at = p_at
|
|
72
|
+
WHERE id = p_id AND used_by IS NULL AND revoked_at IS NULL
|
|
73
|
+
RETURNING 1
|
|
74
|
+
)
|
|
75
|
+
SELECT EXISTS (SELECT 1 FROM won)`);
|
|
76
|
+
fn('revoke_invite', 'p_id TEXT, p_at BIGINT', 'VOID', `UPDATE ${T} SET revoked_at = p_at WHERE id = p_id AND revoked_at IS NULL`);
|
|
77
|
+
fn('list_invites', 'p_inviter TEXT', `SETOF ${T}`, `SELECT * FROM ${T} WHERE inviter_id = p_inviter ORDER BY created_at DESC, id`);
|
|
78
|
+
fn('invited_by', 'p_user TEXT', 'TEXT', `SELECT inviter_id FROM ${T} WHERE used_by = p_user ORDER BY used_at ASC LIMIT 1`);
|
|
79
|
+
fn('invitees', 'p_inviter TEXT', 'TABLE (user_id TEXT, at BIGINT)', `SELECT used_by, used_at FROM ${T}
|
|
80
|
+
WHERE inviter_id = p_inviter AND used_by IS NOT NULL ORDER BY used_at ASC`);
|
|
81
|
+
fn('get_affiliate_code', 'p_user TEXT', 'TEXT', `SELECT code FROM public.${p}codes WHERE user_id = p_user`);
|
|
82
|
+
fn('put_affiliate_code', 'p_user TEXT, p_code TEXT, p_at BIGINT', 'TEXT', `BEGIN
|
|
83
|
+
INSERT INTO public.${p}codes (user_id, code, created_at) VALUES (p_user, p_code, p_at) ON CONFLICT DO NOTHING;
|
|
84
|
+
RETURN (SELECT code FROM public.${p}codes WHERE user_id = p_user);
|
|
85
|
+
END`, 'plpgsql');
|
|
86
|
+
fn('affiliate_by_code', 'p_code TEXT', 'TEXT', `SELECT user_id FROM public.${p}codes WHERE code = p_code`);
|
|
87
|
+
fn('attribute', 'p_user TEXT, p_affiliate TEXT, p_code TEXT, p_at BIGINT', 'BOOLEAN', `WITH ins AS (
|
|
88
|
+
INSERT INTO public.${p}attributions (user_id, affiliate_id, code, at) VALUES (p_user, p_affiliate, p_code, p_at)
|
|
89
|
+
ON CONFLICT (user_id) DO NOTHING
|
|
90
|
+
RETURNING 1
|
|
91
|
+
)
|
|
92
|
+
SELECT EXISTS (SELECT 1 FROM ins)`);
|
|
93
|
+
fn('get_attribution', 'p_user TEXT', `SETOF public.${p}attributions`, `SELECT * FROM public.${p}attributions WHERE user_id = p_user`);
|
|
94
|
+
// True when the code is an @profullstack/referrals code: a row in its `referral_codes` table
|
|
95
|
+
// (20260602000000_referrals.sql, codes stored upper-case). False when that table does not exist.
|
|
96
|
+
fn('is_external_code', 'p_code TEXT', 'BOOLEAN', `DECLARE found BOOLEAN := FALSE;
|
|
97
|
+
BEGIN
|
|
98
|
+
IF to_regclass('public.referral_codes') IS NULL THEN
|
|
99
|
+
RETURN FALSE;
|
|
100
|
+
END IF;
|
|
101
|
+
EXECUTE 'SELECT EXISTS (SELECT 1 FROM public.referral_codes WHERE code = $1)'
|
|
102
|
+
INTO found USING upper(btrim(p_code));
|
|
103
|
+
RETURN found;
|
|
104
|
+
END`, 'plpgsql');
|
|
105
|
+
const tables = ddl(p, 'sqlite').map((s) => s.replace(/CREATE (TABLE|INDEX) IF NOT EXISTS (\w+)/, (_m, kind, name) => kind === 'TABLE' ? `CREATE TABLE IF NOT EXISTS public.${name}` : `CREATE INDEX IF NOT EXISTS ${name}`).replace(/ ON (\w+) \(/, ' ON public.$1 (').replace(/\n\s+\)$/, '\n)').replace(/\n\s+/g, '\n ') + ';');
|
|
106
|
+
const names = ['invites', 'quotas', 'codes', 'attributions'];
|
|
107
|
+
const each = (role, stmt) => ` IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN\n${sigs.map((s) => ` ${stmt(s)}`).join('\n')}\n END IF;`;
|
|
108
|
+
return [
|
|
109
|
+
`-- @profullstack/affiliates: Supabase migration (prefix ${p})`,
|
|
110
|
+
`-- Generated by supabaseMigrationSql('${p}'). Same tables as postgresStore, so both stores can share them.`,
|
|
111
|
+
`-- Use with supabaseStore(serviceRoleClient). Idempotent.`,
|
|
112
|
+
'',
|
|
113
|
+
...tables,
|
|
114
|
+
'',
|
|
115
|
+
'-- RLS on with no policies: anon and authenticated keys see nothing. Access goes only through the',
|
|
116
|
+
'-- SECURITY DEFINER functions below, which only service_role may execute.',
|
|
117
|
+
...names.map((n) => `ALTER TABLE public.${p}${n} ENABLE ROW LEVEL SECURITY;`),
|
|
118
|
+
'',
|
|
119
|
+
...bodies,
|
|
120
|
+
...sigs.map((s) => `REVOKE ALL ON FUNCTION ${s} FROM PUBLIC;`),
|
|
121
|
+
'',
|
|
122
|
+
`DO $grants$
|
|
123
|
+
BEGIN
|
|
124
|
+
${each('anon', (s) => `REVOKE ALL ON FUNCTION ${s} FROM anon;`)}
|
|
125
|
+
${each('authenticated', (s) => `REVOKE ALL ON FUNCTION ${s} FROM authenticated;`)}
|
|
126
|
+
${each('service_role', (s) => `GRANT EXECUTE ON FUNCTION ${s} TO service_role;`)}
|
|
127
|
+
${['anon', 'authenticated'].map((role) => ` IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN\n${names.map((n) => ` REVOKE ALL ON TABLE public.${p}${n} FROM ${role};`).join('\n')}\n END IF;`).join('\n')}
|
|
128
|
+
END
|
|
129
|
+
$grants$;`,
|
|
130
|
+
'',
|
|
131
|
+
"NOTIFY pgrst, 'reload schema';",
|
|
132
|
+
'',
|
|
133
|
+
].join('\n');
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Store over supabase-js / PostgREST (service-role client). Every operation is one `rpc()` call to a
|
|
137
|
+
* function from `supabaseMigrationSql()` (shipped as `migrations/supabase.sql`); apply it first.
|
|
138
|
+
* Also answers `isExternalCode` from @profullstack/referrals' `referral_codes` table when it exists.
|
|
139
|
+
*/
|
|
140
|
+
export function supabaseStore(client, opts = {}) {
|
|
141
|
+
const p = checkPrefix(opts.prefix ?? 'aff_');
|
|
142
|
+
const call = async (name, args) => {
|
|
143
|
+
const { data, error } = await client.rpc(`${p}${name}`, args);
|
|
144
|
+
if (error) {
|
|
145
|
+
const msg = typeof error === 'string' ? error : (error.message ?? JSON.stringify(error));
|
|
146
|
+
throw new Error(`${p}${name}: ${msg}`, { cause: error });
|
|
147
|
+
}
|
|
148
|
+
return data;
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
async getQuota(userId) {
|
|
152
|
+
return num(scalar(await call('get_quota', { p_user: userId })));
|
|
153
|
+
},
|
|
154
|
+
async setQuota(userId, total) {
|
|
155
|
+
await call('set_quota', { p_user: userId, p_total: total });
|
|
156
|
+
},
|
|
157
|
+
async countInvites(inviterId) {
|
|
158
|
+
return Number(scalar(await call('count_invites', { p_inviter: inviterId })) ?? 0);
|
|
159
|
+
},
|
|
160
|
+
async insertInvite(row, limit) {
|
|
161
|
+
const ok = await call('insert_invite', {
|
|
162
|
+
p_id: row.id,
|
|
163
|
+
p_hash: row.codeHash,
|
|
164
|
+
p_inviter: row.inviterId,
|
|
165
|
+
p_created: row.createdAt,
|
|
166
|
+
p_expires: row.expiresAt,
|
|
167
|
+
p_note: row.note,
|
|
168
|
+
p_limit: limit,
|
|
169
|
+
});
|
|
170
|
+
return truthy(scalar(ok));
|
|
171
|
+
},
|
|
172
|
+
async getInviteByHash(codeHash) {
|
|
173
|
+
const r = rows(await call('get_invite_by_hash', { p_hash: codeHash }))[0];
|
|
174
|
+
return r ? toInvite(r) : null;
|
|
175
|
+
},
|
|
176
|
+
async getInvite(id) {
|
|
177
|
+
const r = rows(await call('get_invite', { p_id: id }))[0];
|
|
178
|
+
return r ? toInvite(r) : null;
|
|
179
|
+
},
|
|
180
|
+
async claimInvite(id, userId, at) {
|
|
181
|
+
return truthy(scalar(await call('claim_invite', { p_id: id, p_user: userId, p_at: at })));
|
|
182
|
+
},
|
|
183
|
+
async revokeInvite(id, at) {
|
|
184
|
+
await call('revoke_invite', { p_id: id, p_at: at });
|
|
185
|
+
},
|
|
186
|
+
async listInvites(inviterId) {
|
|
187
|
+
return rows(await call('list_invites', { p_inviter: inviterId })).map(toInvite);
|
|
188
|
+
},
|
|
189
|
+
async invitedBy(userId) {
|
|
190
|
+
return str(scalar(await call('invited_by', { p_user: userId })));
|
|
191
|
+
},
|
|
192
|
+
async invitees(inviterId) {
|
|
193
|
+
return rows(await call('invitees', { p_inviter: inviterId })).map((r) => ({
|
|
194
|
+
userId: String(r.user_id),
|
|
195
|
+
at: Number(r.at),
|
|
196
|
+
}));
|
|
197
|
+
},
|
|
198
|
+
async getAffiliateCode(userId) {
|
|
199
|
+
return str(scalar(await call('get_affiliate_code', { p_user: userId })));
|
|
200
|
+
},
|
|
201
|
+
async putAffiliateCode(userId, code, at) {
|
|
202
|
+
return str(scalar(await call('put_affiliate_code', { p_user: userId, p_code: code, p_at: at })));
|
|
203
|
+
},
|
|
204
|
+
async affiliateByCode(code) {
|
|
205
|
+
return str(scalar(await call('affiliate_by_code', { p_code: code })));
|
|
206
|
+
},
|
|
207
|
+
async attribute(row) {
|
|
208
|
+
const ok = await call('attribute', {
|
|
209
|
+
p_user: row.userId,
|
|
210
|
+
p_affiliate: row.affiliateId,
|
|
211
|
+
p_code: row.code,
|
|
212
|
+
p_at: row.at,
|
|
213
|
+
});
|
|
214
|
+
return truthy(scalar(ok));
|
|
215
|
+
},
|
|
216
|
+
async getAttribution(userId) {
|
|
217
|
+
const r = rows(await call('get_attribution', { p_user: userId }))[0];
|
|
218
|
+
return r
|
|
219
|
+
? { userId: String(r.user_id), affiliateId: String(r.affiliate_id), code: String(r.code), at: Number(r.at) }
|
|
220
|
+
: null;
|
|
221
|
+
},
|
|
222
|
+
async isExternalCode(code) {
|
|
223
|
+
return truthy(scalar(await call('is_external_code', { p_code: code })));
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
-- @profullstack/affiliates: Supabase migration (prefix aff_)
|
|
2
|
+
-- Generated by supabaseMigrationSql('aff_'). Same tables as postgresStore, so both stores can share them.
|
|
3
|
+
-- Use with supabaseStore(serviceRoleClient). Idempotent.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS public.aff_invites (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
code_hash TEXT NOT NULL UNIQUE,
|
|
8
|
+
inviter_id TEXT NOT NULL,
|
|
9
|
+
created_at BIGINT NOT NULL,
|
|
10
|
+
expires_at BIGINT,
|
|
11
|
+
used_by TEXT,
|
|
12
|
+
used_at BIGINT,
|
|
13
|
+
note TEXT NOT NULL DEFAULT '',
|
|
14
|
+
revoked_at BIGINT
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS aff_invites_inviter_idx ON public.aff_invites (inviter_id);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS aff_invites_used_by_idx ON public.aff_invites (used_by);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS public.aff_quotas (
|
|
19
|
+
user_id TEXT PRIMARY KEY,
|
|
20
|
+
total INTEGER NOT NULL
|
|
21
|
+
);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS public.aff_codes (
|
|
23
|
+
user_id TEXT PRIMARY KEY,
|
|
24
|
+
code TEXT NOT NULL UNIQUE,
|
|
25
|
+
created_at BIGINT NOT NULL
|
|
26
|
+
);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS public.aff_attributions (
|
|
28
|
+
user_id TEXT PRIMARY KEY,
|
|
29
|
+
affiliate_id TEXT NOT NULL,
|
|
30
|
+
code TEXT NOT NULL,
|
|
31
|
+
at BIGINT NOT NULL
|
|
32
|
+
);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS aff_attributions_affiliate_idx ON public.aff_attributions (affiliate_id);
|
|
34
|
+
|
|
35
|
+
-- RLS on with no policies: anon and authenticated keys see nothing. Access goes only through the
|
|
36
|
+
-- SECURITY DEFINER functions below, which only service_role may execute.
|
|
37
|
+
ALTER TABLE public.aff_invites ENABLE ROW LEVEL SECURITY;
|
|
38
|
+
ALTER TABLE public.aff_quotas ENABLE ROW LEVEL SECURITY;
|
|
39
|
+
ALTER TABLE public.aff_codes ENABLE ROW LEVEL SECURITY;
|
|
40
|
+
ALTER TABLE public.aff_attributions ENABLE ROW LEVEL SECURITY;
|
|
41
|
+
|
|
42
|
+
CREATE OR REPLACE FUNCTION public.aff_get_quota(p_user TEXT)
|
|
43
|
+
RETURNS INTEGER
|
|
44
|
+
LANGUAGE sql
|
|
45
|
+
SECURITY DEFINER
|
|
46
|
+
SET search_path = public, pg_temp
|
|
47
|
+
AS $fn$
|
|
48
|
+
SELECT total FROM public.aff_quotas WHERE user_id = p_user
|
|
49
|
+
$fn$;
|
|
50
|
+
|
|
51
|
+
CREATE OR REPLACE FUNCTION public.aff_set_quota(p_user TEXT, p_total INTEGER)
|
|
52
|
+
RETURNS VOID
|
|
53
|
+
LANGUAGE sql
|
|
54
|
+
SECURITY DEFINER
|
|
55
|
+
SET search_path = public, pg_temp
|
|
56
|
+
AS $fn$
|
|
57
|
+
INSERT INTO public.aff_quotas (user_id, total) VALUES (p_user, p_total)
|
|
58
|
+
ON CONFLICT (user_id) DO UPDATE SET total = excluded.total
|
|
59
|
+
$fn$;
|
|
60
|
+
|
|
61
|
+
CREATE OR REPLACE FUNCTION public.aff_count_invites(p_inviter TEXT)
|
|
62
|
+
RETURNS INTEGER
|
|
63
|
+
LANGUAGE sql
|
|
64
|
+
SECURITY DEFINER
|
|
65
|
+
SET search_path = public, pg_temp
|
|
66
|
+
AS $fn$
|
|
67
|
+
SELECT count(*)::INTEGER FROM public.aff_invites WHERE inviter_id = p_inviter AND (revoked_at IS NULL OR used_by IS NOT NULL)
|
|
68
|
+
$fn$;
|
|
69
|
+
|
|
70
|
+
CREATE OR REPLACE FUNCTION public.aff_insert_invite(p_id TEXT, p_hash TEXT, p_inviter TEXT, p_created BIGINT, p_expires BIGINT, p_note TEXT, p_limit INTEGER)
|
|
71
|
+
RETURNS BOOLEAN
|
|
72
|
+
LANGUAGE plpgsql
|
|
73
|
+
SECURITY DEFINER
|
|
74
|
+
SET search_path = public, pg_temp
|
|
75
|
+
AS $fn$
|
|
76
|
+
BEGIN
|
|
77
|
+
-- Per-inviter lock; plpgsql takes a fresh snapshot per statement, so the count sees every committed invite.
|
|
78
|
+
PERFORM pg_advisory_xact_lock(hashtext('aff_invites'), hashtext(p_inviter));
|
|
79
|
+
IF (SELECT count(*) FROM public.aff_invites WHERE inviter_id = p_inviter AND (revoked_at IS NULL OR used_by IS NOT NULL)) >= p_limit THEN
|
|
80
|
+
RETURN FALSE;
|
|
81
|
+
END IF;
|
|
82
|
+
INSERT INTO public.aff_invites (id, code_hash, inviter_id, created_at, expires_at, note)
|
|
83
|
+
VALUES (p_id, p_hash, p_inviter, p_created, p_expires, coalesce(p_note, ''));
|
|
84
|
+
RETURN TRUE;
|
|
85
|
+
END
|
|
86
|
+
$fn$;
|
|
87
|
+
|
|
88
|
+
CREATE OR REPLACE FUNCTION public.aff_get_invite_by_hash(p_hash TEXT)
|
|
89
|
+
RETURNS SETOF public.aff_invites
|
|
90
|
+
LANGUAGE sql
|
|
91
|
+
SECURITY DEFINER
|
|
92
|
+
SET search_path = public, pg_temp
|
|
93
|
+
AS $fn$
|
|
94
|
+
SELECT * FROM public.aff_invites WHERE code_hash = p_hash
|
|
95
|
+
$fn$;
|
|
96
|
+
|
|
97
|
+
CREATE OR REPLACE FUNCTION public.aff_get_invite(p_id TEXT)
|
|
98
|
+
RETURNS SETOF public.aff_invites
|
|
99
|
+
LANGUAGE sql
|
|
100
|
+
SECURITY DEFINER
|
|
101
|
+
SET search_path = public, pg_temp
|
|
102
|
+
AS $fn$
|
|
103
|
+
SELECT * FROM public.aff_invites WHERE id = p_id
|
|
104
|
+
$fn$;
|
|
105
|
+
|
|
106
|
+
CREATE OR REPLACE FUNCTION public.aff_claim_invite(p_id TEXT, p_user TEXT, p_at BIGINT)
|
|
107
|
+
RETURNS BOOLEAN
|
|
108
|
+
LANGUAGE sql
|
|
109
|
+
SECURITY DEFINER
|
|
110
|
+
SET search_path = public, pg_temp
|
|
111
|
+
AS $fn$
|
|
112
|
+
WITH won AS (
|
|
113
|
+
UPDATE public.aff_invites SET used_by = p_user, used_at = p_at
|
|
114
|
+
WHERE id = p_id AND used_by IS NULL AND revoked_at IS NULL
|
|
115
|
+
RETURNING 1
|
|
116
|
+
)
|
|
117
|
+
SELECT EXISTS (SELECT 1 FROM won)
|
|
118
|
+
$fn$;
|
|
119
|
+
|
|
120
|
+
CREATE OR REPLACE FUNCTION public.aff_revoke_invite(p_id TEXT, p_at BIGINT)
|
|
121
|
+
RETURNS VOID
|
|
122
|
+
LANGUAGE sql
|
|
123
|
+
SECURITY DEFINER
|
|
124
|
+
SET search_path = public, pg_temp
|
|
125
|
+
AS $fn$
|
|
126
|
+
UPDATE public.aff_invites SET revoked_at = p_at WHERE id = p_id AND revoked_at IS NULL
|
|
127
|
+
$fn$;
|
|
128
|
+
|
|
129
|
+
CREATE OR REPLACE FUNCTION public.aff_list_invites(p_inviter TEXT)
|
|
130
|
+
RETURNS SETOF public.aff_invites
|
|
131
|
+
LANGUAGE sql
|
|
132
|
+
SECURITY DEFINER
|
|
133
|
+
SET search_path = public, pg_temp
|
|
134
|
+
AS $fn$
|
|
135
|
+
SELECT * FROM public.aff_invites WHERE inviter_id = p_inviter ORDER BY created_at DESC, id
|
|
136
|
+
$fn$;
|
|
137
|
+
|
|
138
|
+
CREATE OR REPLACE FUNCTION public.aff_invited_by(p_user TEXT)
|
|
139
|
+
RETURNS TEXT
|
|
140
|
+
LANGUAGE sql
|
|
141
|
+
SECURITY DEFINER
|
|
142
|
+
SET search_path = public, pg_temp
|
|
143
|
+
AS $fn$
|
|
144
|
+
SELECT inviter_id FROM public.aff_invites WHERE used_by = p_user ORDER BY used_at ASC LIMIT 1
|
|
145
|
+
$fn$;
|
|
146
|
+
|
|
147
|
+
CREATE OR REPLACE FUNCTION public.aff_invitees(p_inviter TEXT)
|
|
148
|
+
RETURNS TABLE (user_id TEXT, at BIGINT)
|
|
149
|
+
LANGUAGE sql
|
|
150
|
+
SECURITY DEFINER
|
|
151
|
+
SET search_path = public, pg_temp
|
|
152
|
+
AS $fn$
|
|
153
|
+
SELECT used_by, used_at FROM public.aff_invites
|
|
154
|
+
WHERE inviter_id = p_inviter AND used_by IS NOT NULL ORDER BY used_at ASC
|
|
155
|
+
$fn$;
|
|
156
|
+
|
|
157
|
+
CREATE OR REPLACE FUNCTION public.aff_get_affiliate_code(p_user TEXT)
|
|
158
|
+
RETURNS TEXT
|
|
159
|
+
LANGUAGE sql
|
|
160
|
+
SECURITY DEFINER
|
|
161
|
+
SET search_path = public, pg_temp
|
|
162
|
+
AS $fn$
|
|
163
|
+
SELECT code FROM public.aff_codes WHERE user_id = p_user
|
|
164
|
+
$fn$;
|
|
165
|
+
|
|
166
|
+
CREATE OR REPLACE FUNCTION public.aff_put_affiliate_code(p_user TEXT, p_code TEXT, p_at BIGINT)
|
|
167
|
+
RETURNS TEXT
|
|
168
|
+
LANGUAGE plpgsql
|
|
169
|
+
SECURITY DEFINER
|
|
170
|
+
SET search_path = public, pg_temp
|
|
171
|
+
AS $fn$
|
|
172
|
+
BEGIN
|
|
173
|
+
INSERT INTO public.aff_codes (user_id, code, created_at) VALUES (p_user, p_code, p_at) ON CONFLICT DO NOTHING;
|
|
174
|
+
RETURN (SELECT code FROM public.aff_codes WHERE user_id = p_user);
|
|
175
|
+
END
|
|
176
|
+
$fn$;
|
|
177
|
+
|
|
178
|
+
CREATE OR REPLACE FUNCTION public.aff_affiliate_by_code(p_code TEXT)
|
|
179
|
+
RETURNS TEXT
|
|
180
|
+
LANGUAGE sql
|
|
181
|
+
SECURITY DEFINER
|
|
182
|
+
SET search_path = public, pg_temp
|
|
183
|
+
AS $fn$
|
|
184
|
+
SELECT user_id FROM public.aff_codes WHERE code = p_code
|
|
185
|
+
$fn$;
|
|
186
|
+
|
|
187
|
+
CREATE OR REPLACE FUNCTION public.aff_attribute(p_user TEXT, p_affiliate TEXT, p_code TEXT, p_at BIGINT)
|
|
188
|
+
RETURNS BOOLEAN
|
|
189
|
+
LANGUAGE sql
|
|
190
|
+
SECURITY DEFINER
|
|
191
|
+
SET search_path = public, pg_temp
|
|
192
|
+
AS $fn$
|
|
193
|
+
WITH ins AS (
|
|
194
|
+
INSERT INTO public.aff_attributions (user_id, affiliate_id, code, at) VALUES (p_user, p_affiliate, p_code, p_at)
|
|
195
|
+
ON CONFLICT (user_id) DO NOTHING
|
|
196
|
+
RETURNING 1
|
|
197
|
+
)
|
|
198
|
+
SELECT EXISTS (SELECT 1 FROM ins)
|
|
199
|
+
$fn$;
|
|
200
|
+
|
|
201
|
+
CREATE OR REPLACE FUNCTION public.aff_get_attribution(p_user TEXT)
|
|
202
|
+
RETURNS SETOF public.aff_attributions
|
|
203
|
+
LANGUAGE sql
|
|
204
|
+
SECURITY DEFINER
|
|
205
|
+
SET search_path = public, pg_temp
|
|
206
|
+
AS $fn$
|
|
207
|
+
SELECT * FROM public.aff_attributions WHERE user_id = p_user
|
|
208
|
+
$fn$;
|
|
209
|
+
|
|
210
|
+
CREATE OR REPLACE FUNCTION public.aff_is_external_code(p_code TEXT)
|
|
211
|
+
RETURNS BOOLEAN
|
|
212
|
+
LANGUAGE plpgsql
|
|
213
|
+
SECURITY DEFINER
|
|
214
|
+
SET search_path = public, pg_temp
|
|
215
|
+
AS $fn$
|
|
216
|
+
DECLARE found BOOLEAN := FALSE;
|
|
217
|
+
BEGIN
|
|
218
|
+
IF to_regclass('public.referral_codes') IS NULL THEN
|
|
219
|
+
RETURN FALSE;
|
|
220
|
+
END IF;
|
|
221
|
+
EXECUTE 'SELECT EXISTS (SELECT 1 FROM public.referral_codes WHERE code = $1)'
|
|
222
|
+
INTO found USING upper(btrim(p_code));
|
|
223
|
+
RETURN found;
|
|
224
|
+
END
|
|
225
|
+
$fn$;
|
|
226
|
+
|
|
227
|
+
REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM PUBLIC;
|
|
228
|
+
REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM PUBLIC;
|
|
229
|
+
REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM PUBLIC;
|
|
230
|
+
REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM PUBLIC;
|
|
231
|
+
REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM PUBLIC;
|
|
232
|
+
REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM PUBLIC;
|
|
233
|
+
REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM PUBLIC;
|
|
234
|
+
REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM PUBLIC;
|
|
235
|
+
REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM PUBLIC;
|
|
236
|
+
REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM PUBLIC;
|
|
237
|
+
REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM PUBLIC;
|
|
238
|
+
REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM PUBLIC;
|
|
239
|
+
REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM PUBLIC;
|
|
240
|
+
REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM PUBLIC;
|
|
241
|
+
REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM PUBLIC;
|
|
242
|
+
REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM PUBLIC;
|
|
243
|
+
REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM PUBLIC;
|
|
244
|
+
|
|
245
|
+
DO $grants$
|
|
246
|
+
BEGIN
|
|
247
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
|
|
248
|
+
REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM anon;
|
|
249
|
+
REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM anon;
|
|
250
|
+
REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM anon;
|
|
251
|
+
REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM anon;
|
|
252
|
+
REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM anon;
|
|
253
|
+
REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM anon;
|
|
254
|
+
REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM anon;
|
|
255
|
+
REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM anon;
|
|
256
|
+
REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM anon;
|
|
257
|
+
REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM anon;
|
|
258
|
+
REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM anon;
|
|
259
|
+
REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM anon;
|
|
260
|
+
REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM anon;
|
|
261
|
+
REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM anon;
|
|
262
|
+
REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM anon;
|
|
263
|
+
REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM anon;
|
|
264
|
+
REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM anon;
|
|
265
|
+
END IF;
|
|
266
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
|
|
267
|
+
REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM authenticated;
|
|
268
|
+
REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM authenticated;
|
|
269
|
+
REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM authenticated;
|
|
270
|
+
REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM authenticated;
|
|
271
|
+
REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM authenticated;
|
|
272
|
+
REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM authenticated;
|
|
273
|
+
REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM authenticated;
|
|
274
|
+
REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM authenticated;
|
|
275
|
+
REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM authenticated;
|
|
276
|
+
REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM authenticated;
|
|
277
|
+
REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM authenticated;
|
|
278
|
+
REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM authenticated;
|
|
279
|
+
REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM authenticated;
|
|
280
|
+
REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM authenticated;
|
|
281
|
+
REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM authenticated;
|
|
282
|
+
REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM authenticated;
|
|
283
|
+
REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM authenticated;
|
|
284
|
+
END IF;
|
|
285
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
|
|
286
|
+
GRANT EXECUTE ON FUNCTION public.aff_get_quota(TEXT) TO service_role;
|
|
287
|
+
GRANT EXECUTE ON FUNCTION public.aff_set_quota(TEXT, INTEGER) TO service_role;
|
|
288
|
+
GRANT EXECUTE ON FUNCTION public.aff_count_invites(TEXT) TO service_role;
|
|
289
|
+
GRANT EXECUTE ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) TO service_role;
|
|
290
|
+
GRANT EXECUTE ON FUNCTION public.aff_get_invite_by_hash(TEXT) TO service_role;
|
|
291
|
+
GRANT EXECUTE ON FUNCTION public.aff_get_invite(TEXT) TO service_role;
|
|
292
|
+
GRANT EXECUTE ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) TO service_role;
|
|
293
|
+
GRANT EXECUTE ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) TO service_role;
|
|
294
|
+
GRANT EXECUTE ON FUNCTION public.aff_list_invites(TEXT) TO service_role;
|
|
295
|
+
GRANT EXECUTE ON FUNCTION public.aff_invited_by(TEXT) TO service_role;
|
|
296
|
+
GRANT EXECUTE ON FUNCTION public.aff_invitees(TEXT) TO service_role;
|
|
297
|
+
GRANT EXECUTE ON FUNCTION public.aff_get_affiliate_code(TEXT) TO service_role;
|
|
298
|
+
GRANT EXECUTE ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) TO service_role;
|
|
299
|
+
GRANT EXECUTE ON FUNCTION public.aff_affiliate_by_code(TEXT) TO service_role;
|
|
300
|
+
GRANT EXECUTE ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) TO service_role;
|
|
301
|
+
GRANT EXECUTE ON FUNCTION public.aff_get_attribution(TEXT) TO service_role;
|
|
302
|
+
GRANT EXECUTE ON FUNCTION public.aff_is_external_code(TEXT) TO service_role;
|
|
303
|
+
END IF;
|
|
304
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
|
|
305
|
+
REVOKE ALL ON TABLE public.aff_invites FROM anon;
|
|
306
|
+
REVOKE ALL ON TABLE public.aff_quotas FROM anon;
|
|
307
|
+
REVOKE ALL ON TABLE public.aff_codes FROM anon;
|
|
308
|
+
REVOKE ALL ON TABLE public.aff_attributions FROM anon;
|
|
309
|
+
END IF;
|
|
310
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
|
|
311
|
+
REVOKE ALL ON TABLE public.aff_invites FROM authenticated;
|
|
312
|
+
REVOKE ALL ON TABLE public.aff_quotas FROM authenticated;
|
|
313
|
+
REVOKE ALL ON TABLE public.aff_codes FROM authenticated;
|
|
314
|
+
REVOKE ALL ON TABLE public.aff_attributions FROM authenticated;
|
|
315
|
+
END IF;
|
|
316
|
+
END
|
|
317
|
+
$grants$;
|
|
318
|
+
|
|
319
|
+
NOTIFY pgrst, 'reload schema';
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/affiliates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
|
|
5
|
+
"description": "Scarce invite codes (per-user quota, single use, who-invited-whom) plus open affiliate links with first-touch attribution. Commission math and the referral_code cookie from @profullstack/referrals. Memory, SQLite (bun:sqlite, node:sqlite, better-sqlite3), Postgres and Supabase (supabase-js rpc) stores, plus a CLI.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"invite",
|
|
8
8
|
"invite-codes",
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
"attribution",
|
|
13
13
|
"sqlite",
|
|
14
14
|
"postgres",
|
|
15
|
+
"supabase",
|
|
16
|
+
"postgrest",
|
|
15
17
|
"profullstack"
|
|
16
18
|
],
|
|
17
19
|
"license": "MIT",
|
|
@@ -28,13 +30,15 @@
|
|
|
28
30
|
"import": "./dist/index.js",
|
|
29
31
|
"default": "./dist/index.js"
|
|
30
32
|
},
|
|
31
|
-
"./package.json": "./package.json"
|
|
33
|
+
"./package.json": "./package.json",
|
|
34
|
+
"./migrations/supabase.sql": "./migrations/supabase.sql"
|
|
32
35
|
},
|
|
33
36
|
"bin": {
|
|
34
37
|
"affiliates": "./dist/cli.js"
|
|
35
38
|
},
|
|
36
39
|
"files": [
|
|
37
40
|
"dist",
|
|
41
|
+
"migrations",
|
|
38
42
|
"README.md",
|
|
39
43
|
"LICENSE"
|
|
40
44
|
],
|
|
@@ -42,6 +46,7 @@
|
|
|
42
46
|
"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
47
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
44
48
|
"test": "node --experimental-strip-types --no-warnings --test test/*.test.ts",
|
|
49
|
+
"gen:migration": "node --experimental-strip-types --no-warnings scripts/gen-migration.ts",
|
|
45
50
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
46
51
|
},
|
|
47
52
|
"dependencies": {
|