@profullstack/affiliates 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,6 +1,67 @@
1
1
  {
2
2
  "name": "@profullstack/affiliates",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.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 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
+ "keywords": [
7
+ "invite",
8
+ "invite-codes",
9
+ "invite-only",
10
+ "affiliate",
11
+ "referral",
12
+ "attribution",
13
+ "sqlite",
14
+ "postgres",
15
+ "supabase",
16
+ "postgrest",
17
+ "profullstack"
18
+ ],
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/profullstack/affiliates.git"
23
+ },
24
+ "homepage": "https://github.com/profullstack/affiliates#readme",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./package.json": "./package.json",
34
+ "./migrations/supabase.sql": "./migrations/supabase.sql"
35
+ },
36
+ "bin": {
37
+ "affiliates": "./dist/cli.js"
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "migrations",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "scripts": {
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",
47
+ "typecheck": "tsc --noEmit -p tsconfig.json",
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",
50
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
51
+ },
52
+ "dependencies": {
53
+ "@profullstack/referrals": "^0.1.0"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^24.0.0",
57
+ "@types/pg": "^8.23.1",
58
+ "pg": "^8.23.1",
59
+ "typescript": "^5.9.3"
60
+ },
61
+ "engines": {
62
+ "node": ">=22.13"
63
+ },
64
+ "publishConfig": {
65
+ "access": "public"
66
+ }
67
+ }