@soulbatical/tetra-core 0.1.9 → 0.1.10
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/dist/core/adminDb.d.ts +12 -3
- package/dist/core/adminDb.d.ts.map +1 -1
- package/dist/core/adminDb.js +12 -3
- package/dist/core/adminDb.js.map +1 -1
- package/package.json +5 -3
- package/src/shared/affiliate/migrations/001_create_affiliates.sql +49 -0
- package/src/shared/affiliate/migrations/002_create_affiliate_commissions.sql +31 -0
- package/src/shared/affiliate/migrations/003_create_affiliate_clicks.sql +26 -0
- package/src/shared/affiliate/migrations/004_create_affiliate_payments.sql +34 -0
- package/src/shared/affiliate/migrations/005_create_affiliate_tier_history.sql +19 -0
- package/src/shared/affiliate/migrations/006_create_affiliate_rpc_functions.sql +209 -0
- package/src/shared/affiliate/migrations/007_create_affiliate_rls_policies.sql +123 -0
- package/src/shared/auth/migrations/000_auth_org_helpers.sql +71 -0
package/dist/core/adminDb.d.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { AuthenticatedRequest } from '../middleware/authMiddleware.js';
|
|
2
2
|
/**
|
|
3
|
-
* Admin database helper - for organization admin operations
|
|
4
|
-
*
|
|
5
|
-
* RLS policies
|
|
3
|
+
* Admin database helper - for organization admin operations.
|
|
4
|
+
* Creates a Supabase client authenticated with the user's JWT token,
|
|
5
|
+
* so RLS policies are enforced using the user's identity.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT — RLS Organization Filtering:
|
|
8
|
+
* This client passes the user's JWT to Supabase. RLS policies that use
|
|
9
|
+
* auth_org_id() will filter data by organization. The auth_org_id()
|
|
10
|
+
* function MUST read from users_public.active_organization_id (database),
|
|
11
|
+
* NOT from JWT app_metadata claims. JWT claims are set at login time and
|
|
12
|
+
* become stale when users switch organizations.
|
|
13
|
+
*
|
|
14
|
+
* See: packages/core/src/shared/auth/migrations/000_auth_org_helpers.sql
|
|
6
15
|
*
|
|
7
16
|
* @example
|
|
8
17
|
* ```typescript
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adminDb.d.ts","sourceRoot":"","sources":["../../src/core/adminDb.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,iCAAiC,CAAC;AAMvE
|
|
1
|
+
{"version":3,"file":"adminDb.d.ts","sourceRoot":"","sources":["../../src/core/adminDb.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,iCAAiC,CAAC;AAMvE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,OAAO,CAAC,GAAG,EAAE,oBAAoB,8FA0BtD"}
|
package/dist/core/adminDb.js
CHANGED
|
@@ -2,9 +2,18 @@ import { SupabaseUserClient } from './SupabaseUserClient.js';
|
|
|
2
2
|
import { createLogger } from '../utils/logger.js';
|
|
3
3
|
const logger = createLogger('security:db:admin');
|
|
4
4
|
/**
|
|
5
|
-
* Admin database helper - for organization admin operations
|
|
6
|
-
*
|
|
7
|
-
* RLS policies
|
|
5
|
+
* Admin database helper - for organization admin operations.
|
|
6
|
+
* Creates a Supabase client authenticated with the user's JWT token,
|
|
7
|
+
* so RLS policies are enforced using the user's identity.
|
|
8
|
+
*
|
|
9
|
+
* IMPORTANT — RLS Organization Filtering:
|
|
10
|
+
* This client passes the user's JWT to Supabase. RLS policies that use
|
|
11
|
+
* auth_org_id() will filter data by organization. The auth_org_id()
|
|
12
|
+
* function MUST read from users_public.active_organization_id (database),
|
|
13
|
+
* NOT from JWT app_metadata claims. JWT claims are set at login time and
|
|
14
|
+
* become stale when users switch organizations.
|
|
15
|
+
*
|
|
16
|
+
* See: packages/core/src/shared/auth/migrations/000_auth_org_helpers.sql
|
|
8
17
|
*
|
|
9
18
|
* @example
|
|
10
19
|
* ```typescript
|
package/dist/core/adminDb.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adminDb.js","sourceRoot":"","sources":["../../src/core/adminDb.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,MAAM,MAAM,GAAG,YAAY,CAAC,mBAAmB,CAAC,CAAC;AAEjD
|
|
1
|
+
{"version":3,"file":"adminDb.js","sourceRoot":"","sources":["../../src/core/adminDb.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,MAAM,MAAM,GAAG,YAAY,CAAC,mBAAmB,CAAC,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,GAAyB;IACrD,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC;QACnB,0EAA0E;QAC1E,MAAM,IAAI,KAAK,CACb,0CAA0C;YAC1C,wEAAwE;YACxE,yDAAyD,CAC1D,CAAC;IACJ,CAAC;IAED,sEAAsE;IACtE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,cAAc,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,aAAa,EAAE,CAAC;QAC1D,MAAM,CAAC,KAAK,CAAC,kDAAkD,GAAG,CAAC,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;QAC/E,4EAA4E;QAC5E,MAAM,IAAI,KAAK,CACb,qCAAqC;YACrC,sFAAsF;YACtF,mEAAmE,CACpE,CAAC;IACJ,CAAC;IAED,gEAAgE;IAChE,MAAM,CAAC,KAAK,CAAC,+BAA+B,GAAG,CAAC,IAAI,CAAC,EAAE,QAAQ,GAAG,CAAC,IAAI,CAAC,cAAc,IAAI,YAAY,EAAE,CAAC,CAAC;IAE1G,8DAA8D;IAC9D,OAAO,kBAAkB,CAAC,aAAa,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;AACzD,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soulbatical/tetra-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "restricted"
|
|
6
6
|
},
|
|
7
|
-
"description": "The foundation framework for
|
|
7
|
+
"description": "The foundation framework for Tetra platform projects (Soulbatical BV) — config-driven Express + Supabase + TypeScript",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"types": "dist/index.d.ts",
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"dist",
|
|
30
|
-
"scripts"
|
|
30
|
+
"scripts",
|
|
31
|
+
"src/shared/auth/migrations",
|
|
32
|
+
"src/shared/affiliate/migrations"
|
|
31
33
|
],
|
|
32
34
|
"scripts": {
|
|
33
35
|
"build": "tsc",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Affiliate Module: affiliates table
|
|
3
|
+
-- Template from @soulbatical/tetra-core
|
|
4
|
+
-- Multi-tenant: organization_id on every row
|
|
5
|
+
-- ============================================================================
|
|
6
|
+
|
|
7
|
+
CREATE TABLE IF NOT EXISTS public.affiliates (
|
|
8
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
9
|
+
organization_id uuid NOT NULL REFERENCES public.organizations(id),
|
|
10
|
+
user_id uuid REFERENCES public.users_public(id),
|
|
11
|
+
email text NOT NULL,
|
|
12
|
+
contact_name text NOT NULL,
|
|
13
|
+
company_name text,
|
|
14
|
+
phone text,
|
|
15
|
+
address text,
|
|
16
|
+
postal_code text,
|
|
17
|
+
city text,
|
|
18
|
+
country text DEFAULT 'NL',
|
|
19
|
+
vat_number text,
|
|
20
|
+
kvk_number text,
|
|
21
|
+
website text,
|
|
22
|
+
status text NOT NULL DEFAULT 'pending',
|
|
23
|
+
referral_code text NOT NULL,
|
|
24
|
+
tier text NOT NULL DEFAULT 'starter',
|
|
25
|
+
commission_percentage numeric NOT NULL DEFAULT 30,
|
|
26
|
+
cookie_duration_days integer DEFAULT 90,
|
|
27
|
+
total_clicks integer DEFAULT 0,
|
|
28
|
+
total_sales integer DEFAULT 0,
|
|
29
|
+
total_revenue numeric DEFAULT 0,
|
|
30
|
+
total_commission_earned numeric DEFAULT 0,
|
|
31
|
+
total_commission_paid numeric DEFAULT 0,
|
|
32
|
+
active_since timestamptz,
|
|
33
|
+
last_sale_at timestamptz,
|
|
34
|
+
notes text,
|
|
35
|
+
created_at timestamptz DEFAULT now(),
|
|
36
|
+
updated_at timestamptz DEFAULT now()
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
-- Unique constraints
|
|
40
|
+
ALTER TABLE public.affiliates ADD CONSTRAINT affiliates_email_org_unique UNIQUE (organization_id, email);
|
|
41
|
+
ALTER TABLE public.affiliates ADD CONSTRAINT affiliates_referral_code_unique UNIQUE (referral_code);
|
|
42
|
+
|
|
43
|
+
-- Indexes
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_affiliates_org_id ON public.affiliates(organization_id);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_affiliates_referral_code ON public.affiliates(referral_code);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_affiliates_status ON public.affiliates(status);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_affiliates_tier ON public.affiliates(tier);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_affiliates_user_id ON public.affiliates(user_id);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_affiliates_email ON public.affiliates(email);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Affiliate Module: affiliate_commissions table
|
|
3
|
+
-- Template from @soulbatical/tetra-core
|
|
4
|
+
-- ============================================================================
|
|
5
|
+
|
|
6
|
+
CREATE TABLE IF NOT EXISTS public.affiliate_commissions (
|
|
7
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
8
|
+
organization_id uuid NOT NULL REFERENCES public.organizations(id),
|
|
9
|
+
affiliate_id uuid NOT NULL REFERENCES public.affiliates(id) ON DELETE CASCADE,
|
|
10
|
+
order_id uuid NOT NULL,
|
|
11
|
+
affiliate_source text NOT NULL DEFAULT 'internal',
|
|
12
|
+
external_click_ref text,
|
|
13
|
+
product_name text NOT NULL DEFAULT 'Order',
|
|
14
|
+
order_amount_excl_vat numeric NOT NULL,
|
|
15
|
+
commission_percentage numeric NOT NULL,
|
|
16
|
+
commission_amount numeric NOT NULL,
|
|
17
|
+
status text NOT NULL DEFAULT 'pending',
|
|
18
|
+
approved_at timestamptz,
|
|
19
|
+
approved_by uuid REFERENCES public.users_public(id),
|
|
20
|
+
paid_at timestamptz,
|
|
21
|
+
payment_id uuid,
|
|
22
|
+
notes text,
|
|
23
|
+
created_at timestamptz DEFAULT now(),
|
|
24
|
+
updated_at timestamptz DEFAULT now()
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- Indexes
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_affiliate_commissions_org_id ON public.affiliate_commissions(organization_id);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_affiliate_commissions_affiliate ON public.affiliate_commissions(affiliate_id);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_affiliate_commissions_order ON public.affiliate_commissions(order_id);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_affiliate_commissions_status ON public.affiliate_commissions(status);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Affiliate Module: affiliate_clicks table
|
|
3
|
+
-- Template from @soulbatical/tetra-core
|
|
4
|
+
-- ============================================================================
|
|
5
|
+
|
|
6
|
+
CREATE TABLE IF NOT EXISTS public.affiliate_clicks (
|
|
7
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
8
|
+
organization_id uuid REFERENCES public.organizations(id),
|
|
9
|
+
affiliate_id uuid REFERENCES public.affiliates(id) ON DELETE SET NULL,
|
|
10
|
+
visitor_ip text,
|
|
11
|
+
visitor_country text,
|
|
12
|
+
user_agent text,
|
|
13
|
+
referrer_url text,
|
|
14
|
+
landing_page text,
|
|
15
|
+
affiliate_source text NOT NULL DEFAULT 'internal',
|
|
16
|
+
external_click_ref text,
|
|
17
|
+
converted boolean DEFAULT false,
|
|
18
|
+
order_id uuid,
|
|
19
|
+
conversion_time_hours integer,
|
|
20
|
+
clicked_at timestamptz DEFAULT now()
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
-- Indexes
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_affiliate_clicks_org_id ON public.affiliate_clicks(organization_id);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_affiliate_clicks_affiliate ON public.affiliate_clicks(affiliate_id);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_affiliate_clicks_converted ON public.affiliate_clicks(converted);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Affiliate Module: affiliate_payments table
|
|
3
|
+
-- Template from @soulbatical/tetra-core
|
|
4
|
+
-- ============================================================================
|
|
5
|
+
|
|
6
|
+
CREATE TABLE IF NOT EXISTS public.affiliate_payments (
|
|
7
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
8
|
+
organization_id uuid NOT NULL REFERENCES public.organizations(id),
|
|
9
|
+
affiliate_id uuid NOT NULL REFERENCES public.affiliates(id) ON DELETE CASCADE,
|
|
10
|
+
payment_amount numeric NOT NULL,
|
|
11
|
+
payment_date date NOT NULL,
|
|
12
|
+
payment_method text,
|
|
13
|
+
payment_reference text,
|
|
14
|
+
commission_ids uuid[] NOT NULL,
|
|
15
|
+
notes text,
|
|
16
|
+
created_at timestamptz DEFAULT now()
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
-- Add FK from commissions to payments (after payments table exists)
|
|
20
|
+
DO $$
|
|
21
|
+
BEGIN
|
|
22
|
+
IF NOT EXISTS (
|
|
23
|
+
SELECT 1 FROM information_schema.table_constraints
|
|
24
|
+
WHERE constraint_name = 'affiliate_commissions_payment_id_fkey'
|
|
25
|
+
) THEN
|
|
26
|
+
ALTER TABLE public.affiliate_commissions
|
|
27
|
+
ADD CONSTRAINT affiliate_commissions_payment_id_fkey
|
|
28
|
+
FOREIGN KEY (payment_id) REFERENCES public.affiliate_payments(id);
|
|
29
|
+
END IF;
|
|
30
|
+
END $$;
|
|
31
|
+
|
|
32
|
+
-- Indexes
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_affiliate_payments_org_id ON public.affiliate_payments(organization_id);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_affiliate_payments_affiliate ON public.affiliate_payments(affiliate_id);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Affiliate Module: affiliate_tier_history table
|
|
3
|
+
-- Template from @soulbatical/tetra-core
|
|
4
|
+
-- ============================================================================
|
|
5
|
+
|
|
6
|
+
CREATE TABLE IF NOT EXISTS public.affiliate_tier_history (
|
|
7
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
8
|
+
affiliate_id uuid NOT NULL REFERENCES public.affiliates(id) ON DELETE CASCADE,
|
|
9
|
+
old_tier text NOT NULL,
|
|
10
|
+
new_tier text NOT NULL,
|
|
11
|
+
old_commission_percentage numeric NOT NULL,
|
|
12
|
+
new_commission_percentage numeric NOT NULL,
|
|
13
|
+
reason text,
|
|
14
|
+
triggered_by_sale_count integer,
|
|
15
|
+
changed_at timestamptz DEFAULT now()
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
-- Indexes
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_affiliate_tier_history_affiliate ON public.affiliate_tier_history(affiliate_id);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Affiliate Module: RPC functions
|
|
3
|
+
-- Template from @soulbatical/tetra-core
|
|
4
|
+
--
|
|
5
|
+
-- NOTE: These are template functions. Projects should generate their own
|
|
6
|
+
-- using `npm run generate:rpc affiliates` from the featureConfig, which
|
|
7
|
+
-- produces optimized SQL matching the project's exact filter/count setup.
|
|
8
|
+
--
|
|
9
|
+
-- These templates provide a working baseline.
|
|
10
|
+
-- ============================================================================
|
|
11
|
+
|
|
12
|
+
-- ─── Results Function ─────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
DROP FUNCTION IF EXISTS public.get_affiliates_results;
|
|
15
|
+
|
|
16
|
+
CREATE OR REPLACE FUNCTION public.get_affiliates_results(
|
|
17
|
+
p_org_id uuid DEFAULT NULL,
|
|
18
|
+
p_ids uuid[] DEFAULT NULL,
|
|
19
|
+
p_status text DEFAULT NULL,
|
|
20
|
+
p_tier text DEFAULT NULL,
|
|
21
|
+
p_search text DEFAULT NULL,
|
|
22
|
+
p_has_user text DEFAULT NULL,
|
|
23
|
+
p_time_period text DEFAULT NULL,
|
|
24
|
+
p_limit int DEFAULT 20,
|
|
25
|
+
p_offset int DEFAULT 0,
|
|
26
|
+
p_sort_by text DEFAULT 'date-desc'
|
|
27
|
+
)
|
|
28
|
+
RETURNS TABLE (
|
|
29
|
+
data jsonb,
|
|
30
|
+
total_count bigint
|
|
31
|
+
)
|
|
32
|
+
LANGUAGE plpgsql
|
|
33
|
+
STABLE
|
|
34
|
+
SECURITY DEFINER
|
|
35
|
+
SET search_path = ''
|
|
36
|
+
AS $$
|
|
37
|
+
DECLARE
|
|
38
|
+
v_is_service_role boolean := (
|
|
39
|
+
auth.role() = 'service_role'
|
|
40
|
+
OR session_user = 'postgres'
|
|
41
|
+
);
|
|
42
|
+
BEGIN
|
|
43
|
+
IF NOT v_is_service_role THEN
|
|
44
|
+
IF NOT EXISTS (SELECT 1 FROM public.auth_admin_organizations()) THEN
|
|
45
|
+
RAISE EXCEPTION 'Access denied: authentication required'
|
|
46
|
+
USING ERRCODE = '42501';
|
|
47
|
+
END IF;
|
|
48
|
+
IF p_org_id IS NOT NULL AND p_org_id NOT IN (SELECT public.auth_admin_organizations()) THEN
|
|
49
|
+
RAISE EXCEPTION 'Access denied: not authorized for organization %', p_org_id
|
|
50
|
+
USING ERRCODE = '42501';
|
|
51
|
+
END IF;
|
|
52
|
+
END IF;
|
|
53
|
+
|
|
54
|
+
RETURN QUERY
|
|
55
|
+
WITH results AS (
|
|
56
|
+
SELECT
|
|
57
|
+
a.*,
|
|
58
|
+
COUNT(*) OVER() as total_count
|
|
59
|
+
FROM public.affiliates a
|
|
60
|
+
WHERE (p_org_id IS NULL OR a.organization_id = p_org_id OR a.organization_id IS NULL)
|
|
61
|
+
AND (p_ids IS NULL OR a.id = ANY(p_ids))
|
|
62
|
+
AND (p_status IS NULL OR p_status = 'all' OR a.status::text = p_status)
|
|
63
|
+
AND (p_tier IS NULL OR p_tier = 'all' OR a.tier::text = p_tier)
|
|
64
|
+
AND (
|
|
65
|
+
p_search IS NULL OR p_search = '' OR p_search = 'all'
|
|
66
|
+
OR a.contact_name ILIKE '%' || p_search || '%'
|
|
67
|
+
OR a.email ILIKE '%' || p_search || '%'
|
|
68
|
+
OR a.company_name ILIKE '%' || p_search || '%'
|
|
69
|
+
OR a.referral_code ILIKE '%' || p_search || '%'
|
|
70
|
+
)
|
|
71
|
+
AND (
|
|
72
|
+
p_has_user IS NULL OR p_has_user = 'all'
|
|
73
|
+
OR (p_has_user = 'with' AND a.user_id IS NOT NULL)
|
|
74
|
+
OR (p_has_user = 'without' AND a.user_id IS NULL)
|
|
75
|
+
)
|
|
76
|
+
AND (
|
|
77
|
+
p_time_period IS NULL OR p_time_period = 'all'
|
|
78
|
+
OR (p_time_period = 'today' AND a.created_at >= CURRENT_DATE)
|
|
79
|
+
OR (p_time_period = 'this_week' AND a.created_at >= CURRENT_DATE - INTERVAL '7 days')
|
|
80
|
+
OR (p_time_period = 'this_month' AND a.created_at >= CURRENT_DATE - INTERVAL '30 days')
|
|
81
|
+
OR (p_time_period = 'older' AND a.created_at >= CURRENT_DATE - INTERVAL '90 days')
|
|
82
|
+
)
|
|
83
|
+
AND (
|
|
84
|
+
v_is_service_role
|
|
85
|
+
OR a.organization_id IN (SELECT public.auth_admin_organizations())
|
|
86
|
+
OR a.organization_id IS NULL
|
|
87
|
+
)
|
|
88
|
+
ORDER BY
|
|
89
|
+
CASE WHEN p_sort_by = 'date-desc' THEN a.created_at END DESC NULLS LAST,
|
|
90
|
+
CASE WHEN p_sort_by = 'date-asc' THEN a.created_at END ASC NULLS LAST,
|
|
91
|
+
CASE WHEN p_sort_by = 'name-asc' THEN LOWER(a.contact_name) END ASC NULLS LAST,
|
|
92
|
+
CASE WHEN p_sort_by = 'name-desc' THEN LOWER(a.contact_name) END DESC NULLS LAST,
|
|
93
|
+
CASE WHEN p_sort_by = 'sales-desc' THEN a.total_sales END DESC NULLS LAST,
|
|
94
|
+
CASE WHEN p_sort_by = 'sales-asc' THEN a.total_sales END ASC NULLS LAST,
|
|
95
|
+
CASE WHEN p_sort_by = 'revenue-desc' THEN a.total_revenue END DESC NULLS LAST,
|
|
96
|
+
CASE WHEN p_sort_by = 'revenue-asc' THEN a.total_revenue END ASC NULLS LAST,
|
|
97
|
+
CASE WHEN p_sort_by = 'commission-desc' THEN a.total_commission_earned END DESC NULLS LAST,
|
|
98
|
+
CASE WHEN p_sort_by = 'commission-asc' THEN a.total_commission_earned END ASC NULLS LAST,
|
|
99
|
+
a.created_at DESC
|
|
100
|
+
LIMIT p_limit
|
|
101
|
+
OFFSET p_offset
|
|
102
|
+
)
|
|
103
|
+
SELECT
|
|
104
|
+
to_jsonb(results.*) - 'total_count' as data,
|
|
105
|
+
results.total_count
|
|
106
|
+
FROM results;
|
|
107
|
+
END;
|
|
108
|
+
$$;
|
|
109
|
+
|
|
110
|
+
-- ─── Counts Function ──────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
DROP FUNCTION IF EXISTS public.get_affiliates_counts;
|
|
113
|
+
|
|
114
|
+
CREATE OR REPLACE FUNCTION public.get_affiliates_counts(
|
|
115
|
+
p_org_id uuid DEFAULT NULL,
|
|
116
|
+
p_ids uuid[] DEFAULT NULL,
|
|
117
|
+
p_status text DEFAULT NULL,
|
|
118
|
+
p_tier text DEFAULT NULL,
|
|
119
|
+
p_search text DEFAULT NULL,
|
|
120
|
+
p_has_user text DEFAULT NULL,
|
|
121
|
+
p_time_period text DEFAULT NULL
|
|
122
|
+
)
|
|
123
|
+
RETURNS jsonb
|
|
124
|
+
LANGUAGE plpgsql
|
|
125
|
+
STABLE
|
|
126
|
+
SECURITY DEFINER
|
|
127
|
+
SET search_path = ''
|
|
128
|
+
AS $$
|
|
129
|
+
DECLARE
|
|
130
|
+
result jsonb;
|
|
131
|
+
v_is_service_role boolean := (
|
|
132
|
+
auth.role() = 'service_role'
|
|
133
|
+
OR session_user = 'postgres'
|
|
134
|
+
);
|
|
135
|
+
BEGIN
|
|
136
|
+
IF NOT v_is_service_role THEN
|
|
137
|
+
IF NOT EXISTS (SELECT 1 FROM public.auth_admin_organizations()) THEN
|
|
138
|
+
RAISE EXCEPTION 'Access denied: authentication required'
|
|
139
|
+
USING ERRCODE = '42501';
|
|
140
|
+
END IF;
|
|
141
|
+
IF p_org_id IS NOT NULL AND p_org_id NOT IN (SELECT public.auth_admin_organizations()) THEN
|
|
142
|
+
RAISE EXCEPTION 'Access denied: not authorized for organization %', p_org_id
|
|
143
|
+
USING ERRCODE = '42501';
|
|
144
|
+
END IF;
|
|
145
|
+
END IF;
|
|
146
|
+
|
|
147
|
+
WITH filtered_items AS (
|
|
148
|
+
SELECT a.*
|
|
149
|
+
FROM public.affiliates a
|
|
150
|
+
WHERE (p_org_id IS NULL OR a.organization_id = p_org_id OR a.organization_id IS NULL)
|
|
151
|
+
AND (p_ids IS NULL OR a.id = ANY(p_ids))
|
|
152
|
+
AND (p_status IS NULL OR p_status = 'all' OR a.status::text = p_status)
|
|
153
|
+
AND (p_tier IS NULL OR p_tier = 'all' OR a.tier::text = p_tier)
|
|
154
|
+
AND (
|
|
155
|
+
p_search IS NULL OR p_search = '' OR p_search = 'all'
|
|
156
|
+
OR a.contact_name ILIKE '%' || p_search || '%'
|
|
157
|
+
OR a.email ILIKE '%' || p_search || '%'
|
|
158
|
+
OR a.company_name ILIKE '%' || p_search || '%'
|
|
159
|
+
OR a.referral_code ILIKE '%' || p_search || '%'
|
|
160
|
+
)
|
|
161
|
+
AND (
|
|
162
|
+
p_has_user IS NULL OR p_has_user = 'all'
|
|
163
|
+
OR (p_has_user = 'with' AND a.user_id IS NOT NULL)
|
|
164
|
+
OR (p_has_user = 'without' AND a.user_id IS NULL)
|
|
165
|
+
)
|
|
166
|
+
AND (
|
|
167
|
+
p_time_period IS NULL OR p_time_period = 'all'
|
|
168
|
+
OR (p_time_period = 'today' AND a.created_at >= CURRENT_DATE)
|
|
169
|
+
OR (p_time_period = 'this_week' AND a.created_at >= CURRENT_DATE - INTERVAL '7 days')
|
|
170
|
+
OR (p_time_period = 'this_month' AND a.created_at >= CURRENT_DATE - INTERVAL '30 days')
|
|
171
|
+
OR (p_time_period = 'older' AND a.created_at >= CURRENT_DATE - INTERVAL '90 days')
|
|
172
|
+
)
|
|
173
|
+
AND (
|
|
174
|
+
v_is_service_role
|
|
175
|
+
OR a.organization_id IN (SELECT public.auth_admin_organizations())
|
|
176
|
+
OR a.organization_id IS NULL
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
SELECT jsonb_build_object(
|
|
180
|
+
'total', (SELECT COUNT(*)::int FROM filtered_items),
|
|
181
|
+
'byStatus', (
|
|
182
|
+
SELECT jsonb_object_agg(status_value, cnt) FROM (
|
|
183
|
+
SELECT 'pending' as status_value, COUNT(*)::int as cnt FROM filtered_items WHERE status::text = 'pending'
|
|
184
|
+
UNION ALL SELECT 'active', COUNT(*)::int FROM filtered_items WHERE status::text = 'active'
|
|
185
|
+
UNION ALL SELECT 'inactive', COUNT(*)::int FROM filtered_items WHERE status::text = 'inactive'
|
|
186
|
+
UNION ALL SELECT 'rejected', COUNT(*)::int FROM filtered_items WHERE status::text = 'rejected'
|
|
187
|
+
) subquery
|
|
188
|
+
),
|
|
189
|
+
'byTier', (
|
|
190
|
+
SELECT jsonb_object_agg(tier_value, cnt) FROM (
|
|
191
|
+
SELECT 'starter' as tier_value, COUNT(*)::int as cnt FROM filtered_items WHERE tier::text = 'starter'
|
|
192
|
+
UNION ALL SELECT 'active', COUNT(*)::int FROM filtered_items WHERE tier::text = 'active'
|
|
193
|
+
) subquery
|
|
194
|
+
),
|
|
195
|
+
'byUserAccount', jsonb_build_object(
|
|
196
|
+
'with', (SELECT COUNT(*)::int FROM filtered_items WHERE user_id IS NOT NULL),
|
|
197
|
+
'without', (SELECT COUNT(*)::int FROM filtered_items WHERE user_id IS NULL)
|
|
198
|
+
),
|
|
199
|
+
'byTimePeriod', jsonb_build_object(
|
|
200
|
+
'today', (SELECT COUNT(*)::int FROM filtered_items WHERE created_at >= CURRENT_DATE),
|
|
201
|
+
'this_week', (SELECT COUNT(*)::int FROM filtered_items WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'),
|
|
202
|
+
'this_month', (SELECT COUNT(*)::int FROM filtered_items WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'),
|
|
203
|
+
'older', (SELECT COUNT(*)::int FROM filtered_items WHERE created_at >= CURRENT_DATE - INTERVAL '90 days')
|
|
204
|
+
)
|
|
205
|
+
) INTO result;
|
|
206
|
+
|
|
207
|
+
RETURN result;
|
|
208
|
+
END;
|
|
209
|
+
$$;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Affiliate Module: RLS Policies
|
|
3
|
+
-- Template from @soulbatical/tetra-core
|
|
4
|
+
-- Multi-tenant: all policies filter by organization_id
|
|
5
|
+
-- ============================================================================
|
|
6
|
+
|
|
7
|
+
-- Enable RLS on all affiliate tables
|
|
8
|
+
ALTER TABLE public.affiliates ENABLE ROW LEVEL SECURITY;
|
|
9
|
+
ALTER TABLE public.affiliate_commissions ENABLE ROW LEVEL SECURITY;
|
|
10
|
+
ALTER TABLE public.affiliate_clicks ENABLE ROW LEVEL SECURITY;
|
|
11
|
+
ALTER TABLE public.affiliate_payments ENABLE ROW LEVEL SECURITY;
|
|
12
|
+
ALTER TABLE public.affiliate_tier_history ENABLE ROW LEVEL SECURITY;
|
|
13
|
+
|
|
14
|
+
-- ─── affiliates ───────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
-- Admin: full access within organization
|
|
17
|
+
CREATE POLICY "Admin: full access to affiliates"
|
|
18
|
+
ON public.affiliates
|
|
19
|
+
FOR ALL
|
|
20
|
+
TO authenticated
|
|
21
|
+
USING (organization_id IN (SELECT public.auth_admin_organizations()))
|
|
22
|
+
WITH CHECK (organization_id IN (SELECT public.auth_admin_organizations()));
|
|
23
|
+
|
|
24
|
+
-- User: read own affiliate record
|
|
25
|
+
CREATE POLICY "User: read own affiliate"
|
|
26
|
+
ON public.affiliates
|
|
27
|
+
FOR SELECT
|
|
28
|
+
TO authenticated
|
|
29
|
+
USING (user_id = auth.uid());
|
|
30
|
+
|
|
31
|
+
-- Service role: bypass RLS
|
|
32
|
+
CREATE POLICY "Service role: full access to affiliates"
|
|
33
|
+
ON public.affiliates
|
|
34
|
+
FOR ALL
|
|
35
|
+
TO service_role
|
|
36
|
+
USING (true)
|
|
37
|
+
WITH CHECK (true);
|
|
38
|
+
|
|
39
|
+
-- ─── affiliate_commissions ────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
CREATE POLICY "Admin: full access to affiliate_commissions"
|
|
42
|
+
ON public.affiliate_commissions
|
|
43
|
+
FOR ALL
|
|
44
|
+
TO authenticated
|
|
45
|
+
USING (organization_id IN (SELECT public.auth_admin_organizations()))
|
|
46
|
+
WITH CHECK (organization_id IN (SELECT public.auth_admin_organizations()));
|
|
47
|
+
|
|
48
|
+
-- User: read own commissions (via affiliate)
|
|
49
|
+
CREATE POLICY "User: read own commissions"
|
|
50
|
+
ON public.affiliate_commissions
|
|
51
|
+
FOR SELECT
|
|
52
|
+
TO authenticated
|
|
53
|
+
USING (
|
|
54
|
+
affiliate_id IN (
|
|
55
|
+
SELECT id FROM public.affiliates WHERE user_id = auth.uid()
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE POLICY "Service role: full access to affiliate_commissions"
|
|
60
|
+
ON public.affiliate_commissions
|
|
61
|
+
FOR ALL
|
|
62
|
+
TO service_role
|
|
63
|
+
USING (true)
|
|
64
|
+
WITH CHECK (true);
|
|
65
|
+
|
|
66
|
+
-- ─── affiliate_clicks ─────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
CREATE POLICY "Admin: full access to affiliate_clicks"
|
|
69
|
+
ON public.affiliate_clicks
|
|
70
|
+
FOR ALL
|
|
71
|
+
TO authenticated
|
|
72
|
+
USING (organization_id IN (SELECT public.auth_admin_organizations()))
|
|
73
|
+
WITH CHECK (organization_id IN (SELECT public.auth_admin_organizations()));
|
|
74
|
+
|
|
75
|
+
-- Public: insert clicks (anonymous tracking)
|
|
76
|
+
CREATE POLICY "Public: insert affiliate clicks"
|
|
77
|
+
ON public.affiliate_clicks
|
|
78
|
+
FOR INSERT
|
|
79
|
+
TO anon
|
|
80
|
+
WITH CHECK (true);
|
|
81
|
+
|
|
82
|
+
CREATE POLICY "Service role: full access to affiliate_clicks"
|
|
83
|
+
ON public.affiliate_clicks
|
|
84
|
+
FOR ALL
|
|
85
|
+
TO service_role
|
|
86
|
+
USING (true)
|
|
87
|
+
WITH CHECK (true);
|
|
88
|
+
|
|
89
|
+
-- ─── affiliate_payments ───────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
CREATE POLICY "Admin: full access to affiliate_payments"
|
|
92
|
+
ON public.affiliate_payments
|
|
93
|
+
FOR ALL
|
|
94
|
+
TO authenticated
|
|
95
|
+
USING (organization_id IN (SELECT public.auth_admin_organizations()))
|
|
96
|
+
WITH CHECK (organization_id IN (SELECT public.auth_admin_organizations()));
|
|
97
|
+
|
|
98
|
+
CREATE POLICY "Service role: full access to affiliate_payments"
|
|
99
|
+
ON public.affiliate_payments
|
|
100
|
+
FOR ALL
|
|
101
|
+
TO service_role
|
|
102
|
+
USING (true)
|
|
103
|
+
WITH CHECK (true);
|
|
104
|
+
|
|
105
|
+
-- ─── affiliate_tier_history ───────────────────────────────────
|
|
106
|
+
|
|
107
|
+
CREATE POLICY "Admin: read affiliate tier history"
|
|
108
|
+
ON public.affiliate_tier_history
|
|
109
|
+
FOR SELECT
|
|
110
|
+
TO authenticated
|
|
111
|
+
USING (
|
|
112
|
+
affiliate_id IN (
|
|
113
|
+
SELECT id FROM public.affiliates
|
|
114
|
+
WHERE organization_id IN (SELECT public.auth_admin_organizations())
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE POLICY "Service role: full access to affiliate_tier_history"
|
|
119
|
+
ON public.affiliate_tier_history
|
|
120
|
+
FOR ALL
|
|
121
|
+
TO service_role
|
|
122
|
+
USING (true)
|
|
123
|
+
WITH CHECK (true);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
-- Tetra Auth Helpers — Organization-scoped RLS functions
|
|
2
|
+
--
|
|
3
|
+
-- PREREQUISITE: This migration requires the users_public table with:
|
|
4
|
+
-- - id UUID PRIMARY KEY REFERENCES auth.users(id)
|
|
5
|
+
-- - active_organization_id UUID
|
|
6
|
+
--
|
|
7
|
+
-- These functions provide the single source of truth for organization
|
|
8
|
+
-- context in RLS policies. They MUST read from the database, not from
|
|
9
|
+
-- JWT claims, because JWT claims are immutable after login and will
|
|
10
|
+
-- get out of sync when users switch organizations.
|
|
11
|
+
--
|
|
12
|
+
-- ARCHITECTURE DECISION:
|
|
13
|
+
-- ┌─────────────────┐ ┌──────────────────────┐
|
|
14
|
+
-- │ Backend (Node) │ │ Database (RLS) │
|
|
15
|
+
-- │ │ │ │
|
|
16
|
+
-- │ reads from: │ │ reads from: │
|
|
17
|
+
-- │ users_public │────▶│ users_public │
|
|
18
|
+
-- │ .active_org_id │ │ .active_org_id │
|
|
19
|
+
-- └─────────────────┘ └──────────────────────┘
|
|
20
|
+
-- ▲ ▲
|
|
21
|
+
-- │ SAME SOURCE │
|
|
22
|
+
-- └─────────────────────────┘
|
|
23
|
+
--
|
|
24
|
+
-- DO NOT read from auth.jwt() -> 'app_metadata' for organization_id.
|
|
25
|
+
-- JWT claims are set at login and don't update when org switches happen.
|
|
26
|
+
|
|
27
|
+
-- ============================================================================
|
|
28
|
+
-- auth_org_id() — Returns the user's active organization UUID
|
|
29
|
+
--
|
|
30
|
+
-- Usage in RLS policies:
|
|
31
|
+
-- CREATE POLICY "org_select" ON my_table
|
|
32
|
+
-- FOR SELECT USING (organization_id = auth_org_id());
|
|
33
|
+
--
|
|
34
|
+
-- This reads from users_public.active_organization_id, ensuring it
|
|
35
|
+
-- always matches what the backend session service returns.
|
|
36
|
+
-- ============================================================================
|
|
37
|
+
|
|
38
|
+
CREATE OR REPLACE FUNCTION auth_org_id() RETURNS UUID AS $$
|
|
39
|
+
SELECT active_organization_id
|
|
40
|
+
FROM users_public
|
|
41
|
+
WHERE id = auth.uid();
|
|
42
|
+
$$ LANGUAGE sql STABLE SECURITY DEFINER;
|
|
43
|
+
|
|
44
|
+
-- ============================================================================
|
|
45
|
+
-- auth_admin_organizations() — Returns org(s) for admin RLS
|
|
46
|
+
--
|
|
47
|
+
-- Used by sparkbuddy-live pattern. Returns a TABLE so it works with
|
|
48
|
+
-- IN (SELECT auth_admin_organizations()) syntax in RLS.
|
|
49
|
+
--
|
|
50
|
+
-- Supports test mode via app.test_user_id / app.test_org_id settings
|
|
51
|
+
-- for integration testing without real JWT tokens.
|
|
52
|
+
-- ============================================================================
|
|
53
|
+
|
|
54
|
+
CREATE OR REPLACE FUNCTION auth_admin_organizations()
|
|
55
|
+
RETURNS TABLE(organizationid UUID) AS $$
|
|
56
|
+
SELECT
|
|
57
|
+
CASE
|
|
58
|
+
WHEN current_setting('app.test_user_id', true) IS NOT NULL
|
|
59
|
+
THEN current_setting('app.test_org_id', true)::uuid
|
|
60
|
+
ELSE up.active_organization_id
|
|
61
|
+
END AS organizationid
|
|
62
|
+
FROM users_public up
|
|
63
|
+
WHERE up.id = COALESCE(
|
|
64
|
+
current_setting('app.test_user_id', true)::uuid,
|
|
65
|
+
auth.uid()
|
|
66
|
+
)
|
|
67
|
+
AND (
|
|
68
|
+
current_setting('app.test_user_id', true) IS NOT NULL
|
|
69
|
+
OR up.active_organization_id IS NOT NULL
|
|
70
|
+
);
|
|
71
|
+
$$ LANGUAGE sql STABLE SECURITY DEFINER;
|