@profullstack/affiliates 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +248 -2
- package/dist/affiliates.d.ts +101 -0
- package/dist/affiliates.js +180 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +169 -0
- package/dist/codes.d.ts +19 -0
- package/dist/codes.js +48 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +8 -0
- package/dist/memory.d.ts +3 -0
- package/dist/memory.js +105 -0
- package/dist/postgres.d.ts +10 -0
- package/dist/postgres.js +8 -0
- package/dist/ref.d.ts +22 -0
- package/dist/ref.js +49 -0
- package/dist/sql.d.ts +12 -0
- package/dist/sql.js +172 -0
- package/dist/sqlite.d.ts +26 -0
- package/dist/sqlite.js +30 -0
- package/dist/store.d.ts +61 -0
- package/dist/store.js +1 -0
- package/dist/supabase.d.ts +31 -0
- package/dist/supabase.js +226 -0
- package/migrations/supabase.sql +319 -0
- package/package.json +65 -4
package/dist/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,61 @@
|
|
|
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
|
+
/**
|
|
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>;
|
|
61
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
}
|