@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.
@@ -1,8 +1,17 @@
1
1
  import { AuthenticatedRequest } from '../middleware/authMiddleware.js';
2
2
  /**
3
- * Admin database helper - for organization admin operations
4
- * Enforces admin role and logs access for audit
5
- * RLS policies will allow access to all organization data
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;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,OAAO,CAAC,GAAG,EAAE,oBAAoB,8FA0BtD"}
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"}
@@ -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
- * Enforces admin role and logs access for audit
7
- * RLS policies will allow access to all organization data
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
@@ -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;;;;;;;;;;;;;;;;;GAiBG;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"}
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.9",
3
+ "version": "0.1.10",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },
7
- "description": "The foundation framework for VCA platform projects — config-driven Express + Supabase + TypeScript",
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;