From 6c6032639ab6403b6ed67cf8e54447a6041ed982 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 9 Feb 2026 00:52:26 +0900 Subject: [PATCH 1/4] fix: improve mailto link encoding in share dialog - Updated the buildMailtoHref function to manually encode subject and body parameters using encodeURIComponent, ensuring proper encoding of spaces and special characters per RFC 6068. - This change enhances the reliability of email links generated for sharing content. --- editor/components/dialogs/share-dialog.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/editor/components/dialogs/share-dialog.tsx b/editor/components/dialogs/share-dialog.tsx index 52e1279c7..b5cc22a7f 100644 --- a/editor/components/dialogs/share-dialog.tsx +++ b/editor/components/dialogs/share-dialog.tsx @@ -67,10 +67,12 @@ function buildMailtoHref({ subject?: string; body: string; }): string { - const params = new URLSearchParams(); - if (subject) params.set("subject", subject); - params.set("body", body); - return `mailto:?${params.toString()}`; + // Manually encode with encodeURIComponent so spaces become '%20' (not '+') + // per RFC 6068. + const pairs: string[] = []; + if (subject) pairs.push(`subject=${encodeURIComponent(subject)}`); + pairs.push(`body=${encodeURIComponent(body)}`); + return `mailto:?${pairs.join("&")}`; } async function copyToClipboard(text: string): Promise { From e09a8212402801c6b72f6a09fc30fd6b8992c257 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 9 Feb 2026 00:57:03 +0900 Subject: [PATCH 2/4] feat: implement auto-tagging for campaign invitees - Added a new column `ciam_invitee_on_claim_tag_names` to the campaign table to store tag names for automatic application to invitees upon claiming an invitation. - Created a trigger and associated function to handle the auto-tagging process when an invitation is claimed. - Updated the campaign settings UI to include a tagging section for managing invitee tags. - Added tests to verify the auto-tagging functionality works as expected. --- database/database-generated.types.ts | 11 ++ .../[campaign]/_components/settings.tsx | 71 +++++++- ...20260208000000_campaign_ciam_auto_tags.sql | 80 +++++++++ supabase/schemas/grida_west_referral.sql | 71 ++++++++ supabase/tests/test_campaign_auto_tag.sql | 158 ++++++++++++++++++ 5 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260208000000_campaign_ciam_auto_tags.sql create mode 100644 supabase/tests/test_campaign_auto_tag.sql diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts index e8202309e..f03d85f26 100644 --- a/database/database-generated.types.ts +++ b/database/database-generated.types.ts @@ -2762,6 +2762,7 @@ export type Database = { Tables: { campaign: { Row: { + ciam_invitee_on_claim_tag_names: string[] conversion_currency: string conversion_value: number | null created_at: string @@ -2782,6 +2783,7 @@ export type Database = { title: string } Insert: { + ciam_invitee_on_claim_tag_names?: string[] conversion_currency?: string conversion_value?: number | null created_at?: string @@ -2802,6 +2804,7 @@ export type Database = { title: string } Update: { + ciam_invitee_on_claim_tag_names?: string[] conversion_currency?: string conversion_value?: number | null created_at?: string @@ -3624,6 +3627,14 @@ export type Database = { name: string }[] } + apply_customer_tags: { + Args: { + p_customer_uid: string + p_project_id: number + p_tag_names: string[] + } + Returns: undefined + } claim: { Args: { p_campaign_id: string; p_code: string; p_customer_id: string } Returns: { diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/settings.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/settings.tsx index 5c38093f8..6b7fc858b 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/settings.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/settings.tsx @@ -73,9 +73,10 @@ import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useUnsavedChangesWarning } from "@/hooks/use-unsaved-changes-warning"; import { DeleteConfirmationAlertDialog } from "@/components/dialogs/delete-confirmation-dialog"; -import { useProject } from "@/scaffolds/workspace"; +import { useProject, useTags } from "@/scaffolds/workspace"; import { useRouter } from "next/navigation"; import type { Database } from "@app/database"; +import { TagInput } from "@/components/tag"; // Timezone options const timezones = [ @@ -102,6 +103,7 @@ const formSchema = z.object({ scheduling_close_at: z.date().nullable(), scheduling_tz: z.string().nullable(), public: z.any().default({}), + ciam_invitee_on_claim_tag_names: z.array(z.string()).default([]), }); type CampaignFormValues = z.infer; @@ -139,6 +141,7 @@ function useCampaignData(id: string) { data.is_referrer_profile_exposed_to_public_dangerously, max_invitations_per_referrer: data.max_invitations_per_referrer, public: data.public, + ciam_invitee_on_claim_tag_names: data.ciam_invitee_on_claim_tag_names, }) .eq("id", id) .select("*"); @@ -266,6 +269,7 @@ function Body({ Rewards Challenges Events + Customer Tagging Security Advanced )} + {activeTab === "tagging" && ( + + )} + {activeTab === "milestone" && } {activeTab === "rewards" && } {activeTab === "challenges" && } @@ -712,6 +720,67 @@ function Body({ ); } +function CampaignTaggingSection({ + control, +}: { + control: Control; +}) { + const { tags: projectTags } = useTags(); + + const autocompleteOptions = useMemo( + () => projectTags.map((t) => ({ id: t.name, text: t.name })), + [projectTags] + ); + + const [activeTagIndex, setActiveTagIndex] = useState(null); + + return ( +
+

Customer Tagging

+

+ Automatically tag customers when they join this campaign. Tags are + applied once (add-only) and will not be removed if changed later. +

+
+ { + const tags = field.value ?? []; + return ( + + Invitee tags (on claim) + + ({ id: t, text: t }))} + setTags={(newTags) => { + const resolved = + typeof newTags === "function" + ? newTags(tags.map((t) => ({ id: t, text: t }))) + : newTags; + field.onChange(resolved.map((t) => t.text)); + }} + activeTagIndex={activeTagIndex} + setActiveTagIndex={setActiveTagIndex} + enableAutocomplete={autocompleteOptions.length > 0} + autocompleteOptions={autocompleteOptions} + placeholder="Add tags" + /> + + + These tags will be automatically applied to the invitee's + customer profile when they claim an invitation. + + + + ); + }} + /> +
+
+ ); +} + function CampaignPublicDataFields({ control, projectId, diff --git a/supabase/migrations/20260208000000_campaign_ciam_auto_tags.sql b/supabase/migrations/20260208000000_campaign_ciam_auto_tags.sql new file mode 100644 index 000000000..cfc8b4ee4 --- /dev/null +++ b/supabase/migrations/20260208000000_campaign_ciam_auto_tags.sql @@ -0,0 +1,80 @@ +-- Campaign CIAM auto-tagging +-- Adds per-campaign tag-name list that is automatically applied to the +-- invitee customer when an invitation is claimed. +-- Fully DB-side via trigger; no server changes. + +--------------------------------------------------------------------- +-- 1. Add column to campaign +--------------------------------------------------------------------- +ALTER TABLE grida_west_referral.campaign + ADD COLUMN ciam_invitee_on_claim_tag_names text[] NOT NULL DEFAULT '{}'; + +COMMENT ON COLUMN grida_west_referral.campaign.ciam_invitee_on_claim_tag_names + IS 'Tag names auto-applied to invitee customer when invitation is claimed'; + +--------------------------------------------------------------------- +-- 2. Helper: add-only tag application (no removals) +--------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION grida_west_referral.apply_customer_tags( + p_customer_uid uuid, + p_project_id bigint, + p_tag_names text[] +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public, grida_ciam +AS $$ +DECLARE + tag text; +BEGIN + IF p_tag_names IS NULL OR array_length(p_tag_names, 1) IS NULL THEN + RETURN; + END IF; + + FOREACH tag IN ARRAY p_tag_names LOOP + INSERT INTO public.tag (project_id, name) + VALUES (p_project_id, tag) + ON CONFLICT (project_id, name) DO NOTHING; + + INSERT INTO grida_ciam.customer_tag (customer_uid, project_id, tag_name) + VALUES (p_customer_uid, p_project_id, tag) + ON CONFLICT DO NOTHING; + END LOOP; +END; +$$; + +-- Only callable by triggers / service_role; block direct RPC from anon/authenticated. +REVOKE EXECUTE ON FUNCTION grida_west_referral.apply_customer_tags(uuid, bigint, text[]) FROM anon, authenticated; + +--------------------------------------------------------------------- +-- 3. Trigger: auto-tag invitee on claim +--------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION grida_west_referral.trg_auto_tag_invitee_on_claim() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + v_tag_names text[]; + v_project_id bigint; +BEGIN + IF OLD.is_claimed = false AND NEW.is_claimed = true AND NEW.customer_id IS NOT NULL THEN + SELECT c.ciam_invitee_on_claim_tag_names, c.project_id + INTO v_tag_names, v_project_id + FROM grida_west_referral.campaign c + WHERE c.id = NEW.campaign_id; + + IF v_tag_names IS NOT NULL AND array_length(v_tag_names, 1) > 0 THEN + PERFORM grida_west_referral.apply_customer_tags(NEW.customer_id, v_project_id, v_tag_names); + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_auto_tag_invitee_on_claim +AFTER UPDATE ON grida_west_referral.invitation +FOR EACH ROW +EXECUTE FUNCTION grida_west_referral.trg_auto_tag_invitee_on_claim(); diff --git a/supabase/schemas/grida_west_referral.sql b/supabase/schemas/grida_west_referral.sql index 80068124b..ec1f7a64f 100644 --- a/supabase/schemas/grida_west_referral.sql +++ b/supabase/schemas/grida_west_referral.sql @@ -153,6 +153,8 @@ CREATE TABLE grida_west_referral.campaign ( metadata JSONB DEFAULT NULL, -- Flexible additional private data for the campaign / will NOT be public created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- timestamp + ciam_invitee_on_claim_tag_names text[] NOT NULL DEFAULT '{}', -- Tag names auto-applied to invitee customer when invitation is claimed + CONSTRAINT fk_campaign_layout_document_id FOREIGN KEY (layout_id, id) REFERENCES grida_www.layout(id, document_id) ON DELETE SET NULL, CONSTRAINT unique_campaign_id_project_id UNIQUE (id, project_id) ); @@ -843,3 +845,72 @@ $$ LANGUAGE plpgsql SECURITY DEFINER; REVOKE EXECUTE ON FUNCTION grida_west_referral.analyze FROM anon, authenticated; GRANT EXECUTE ON FUNCTION grida_west_referral.analyze TO service_role; + + +--------------------------------------------------------------------- +-- [CIAM Auto-Tagging] -- +--------------------------------------------------------------------- + +-- Helper: add-only tag application (no removals, idempotent) +CREATE OR REPLACE FUNCTION grida_west_referral.apply_customer_tags( + p_customer_uid uuid, + p_project_id bigint, + p_tag_names text[] +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public, grida_ciam +AS $$ +DECLARE + tag text; +BEGIN + IF p_tag_names IS NULL OR array_length(p_tag_names, 1) IS NULL THEN + RETURN; + END IF; + + FOREACH tag IN ARRAY p_tag_names LOOP + INSERT INTO public.tag (project_id, name) + VALUES (p_project_id, tag) + ON CONFLICT (project_id, name) DO NOTHING; + + INSERT INTO grida_ciam.customer_tag (customer_uid, project_id, tag_name) + VALUES (p_customer_uid, p_project_id, tag) + ON CONFLICT DO NOTHING; + END LOOP; +END; +$$; + +-- Only callable by triggers / service_role; block direct RPC from anon/authenticated. +REVOKE EXECUTE ON FUNCTION grida_west_referral.apply_customer_tags(uuid, bigint, text[]) FROM anon, authenticated; + +-- Trigger function: auto-tag invitee on claim +CREATE OR REPLACE FUNCTION grida_west_referral.trg_auto_tag_invitee_on_claim() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + v_tag_names text[]; + v_project_id bigint; +BEGIN + IF OLD.is_claimed = false AND NEW.is_claimed = true AND NEW.customer_id IS NOT NULL THEN + SELECT c.ciam_invitee_on_claim_tag_names, c.project_id + INTO v_tag_names, v_project_id + FROM grida_west_referral.campaign c + WHERE c.id = NEW.campaign_id; + + IF v_tag_names IS NOT NULL AND array_length(v_tag_names, 1) > 0 THEN + PERFORM grida_west_referral.apply_customer_tags(NEW.customer_id, v_project_id, v_tag_names); + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_auto_tag_invitee_on_claim +AFTER UPDATE ON grida_west_referral.invitation +FOR EACH ROW +EXECUTE FUNCTION grida_west_referral.trg_auto_tag_invitee_on_claim(); + diff --git a/supabase/tests/test_campaign_auto_tag.sql b/supabase/tests/test_campaign_auto_tag.sql new file mode 100644 index 000000000..fdf66fe40 --- /dev/null +++ b/supabase/tests/test_campaign_auto_tag.sql @@ -0,0 +1,158 @@ +BEGIN; +SELECT plan(5); + +-- --------------------------------------------------------------------------- +-- Setup: create project, campaign, customers, referrer, invitation fixtures. +-- All done as service_role (bypasses RLS). +-- --------------------------------------------------------------------------- +DO $$ +DECLARE + v_org_id bigint; + v_project_id bigint; + v_project_name text := + 'at-' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 8); + v_campaign_id uuid; + v_referrer_customer_uid uuid; + v_invitee_customer_uid uuid; + v_referrer_id uuid; + v_invitation_code text; +BEGIN + -- Resolve seeded org + SELECT id INTO v_org_id FROM public.organization WHERE name = 'local' LIMIT 1; + IF v_org_id IS NULL THEN + RAISE EXCEPTION 'seed org "local" not found'; + END IF; + + SET LOCAL ROLE service_role; + + -- Create project + INSERT INTO public.project (organization_id, name) + VALUES (v_org_id, v_project_name) + RETURNING id INTO v_project_id; + + -- Create customers + INSERT INTO public.customer (project_id, uuid, email, name) + VALUES (v_project_id, gen_random_uuid(), 'referrer@example.com', 'Referrer') + RETURNING uid INTO v_referrer_customer_uid; + + INSERT INTO public.customer (project_id, uuid, email, name) + VALUES (v_project_id, gen_random_uuid(), 'invitee@example.com', 'Invitee') + RETURNING uid INTO v_invitee_customer_uid; + + -- Create campaign document + campaign with auto-tag config + v_campaign_id := gen_random_uuid(); + + INSERT INTO public.document (id, doctype, project_id, title) + VALUES (v_campaign_id, 'v0_campaign_referral', v_project_id, 'Auto-Tag Campaign'); + + INSERT INTO grida_west_referral.campaign ( + id, project_id, title, + ciam_invitee_on_claim_tag_names + ) + VALUES ( + v_campaign_id, v_project_id, 'Auto-Tag Campaign', + ARRAY['invitee-tag', 'campaign-member'] + ); + + -- Create referrer + INSERT INTO grida_west_referral.referrer (project_id, campaign_id, customer_id) + VALUES (v_project_id, v_campaign_id, v_referrer_customer_uid) + RETURNING id INTO v_referrer_id; + + -- Create invitation (unclaimed initially) + INSERT INTO grida_west_referral.invitation (campaign_id, referrer_id) + VALUES (v_campaign_id, v_referrer_id) + RETURNING code INTO v_invitation_code; + + RESET ROLE; + + -- Stash IDs + PERFORM set_config('test.project_id', v_project_id::text, false); + PERFORM set_config('test.campaign_id', v_campaign_id::text, false); + PERFORM set_config('test.invitee_customer_uid', v_invitee_customer_uid::text, false); + PERFORM set_config('test.invitation_code', v_invitation_code, false); +END $$; + + +-- ========================================================================= +-- Test 1: Invitee has NO tags before claim (invitation is unclaimed) +-- ========================================================================= +SELECT ok( + NOT EXISTS( + SELECT 1 FROM grida_ciam.customer_tag + WHERE customer_uid = current_setting('test.invitee_customer_uid')::uuid + AND project_id = current_setting('test.project_id')::bigint + AND tag_name = 'invitee-tag' + ), + 'invitee should NOT have invitee-tag before claiming' +); + +-- ========================================================================= +-- Claim the invitation (triggers auto-tag for invitee) +-- ========================================================================= +DO $$ +BEGIN + SET LOCAL ROLE service_role; + + PERFORM grida_west_referral.claim( + current_setting('test.campaign_id')::uuid, + current_setting('test.invitation_code'), + current_setting('test.invitee_customer_uid')::uuid + ); + + RESET ROLE; +END $$; + +-- ========================================================================= +-- Test 2: Invitee customer has 'invitee-tag' after claim +-- ========================================================================= +SELECT ok( + EXISTS( + SELECT 1 FROM grida_ciam.customer_tag + WHERE customer_uid = current_setting('test.invitee_customer_uid')::uuid + AND project_id = current_setting('test.project_id')::bigint + AND tag_name = 'invitee-tag' + ), + 'invitee customer should have invitee-tag after claiming' +); + +-- ========================================================================= +-- Test 3: Invitee customer has 'campaign-member' after claim +-- ========================================================================= +SELECT ok( + EXISTS( + SELECT 1 FROM grida_ciam.customer_tag + WHERE customer_uid = current_setting('test.invitee_customer_uid')::uuid + AND project_id = current_setting('test.project_id')::bigint + AND tag_name = 'campaign-member' + ), + 'invitee customer should have campaign-member tag after claiming' +); + +-- ========================================================================= +-- Test 4: 'invitee-tag' was auto-created in public.tag +-- ========================================================================= +SELECT ok( + EXISTS( + SELECT 1 FROM public.tag + WHERE project_id = current_setting('test.project_id')::bigint + AND name = 'invitee-tag' + ), + 'invitee-tag should be auto-created in public.tag on claim' +); + +-- ========================================================================= +-- Test 5: 'campaign-member' was auto-created in public.tag +-- ========================================================================= +SELECT ok( + EXISTS( + SELECT 1 FROM public.tag + WHERE project_id = current_setting('test.project_id')::bigint + AND name = 'campaign-member' + ), + 'campaign-member tag should be auto-created in public.tag on claim' +); + + +SELECT * FROM finish(); +ROLLBACK; From 87708b9131f9d47c6739083ac3c583e457ef5846 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 9 Feb 2026 01:09:20 +0900 Subject: [PATCH 3/4] chore --- editor/components/formfield-type-select/index.tsx | 4 ++-- editor/scaffolds/grid/wellknown/customer-grid.tsx | 4 +++- editor/scaffolds/panels/field-edit-panel.tsx | 7 +++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/editor/components/formfield-type-select/index.tsx b/editor/components/formfield-type-select/index.tsx index 625bed9c9..1e7902d21 100644 --- a/editor/components/formfield-type-select/index.tsx +++ b/editor/components/formfield-type-select/index.tsx @@ -52,11 +52,11 @@ export function TypeSelect({ variant="outline" role="combobox" aria-expanded={open} - className="w-full justify-between capitalize" + className="w-full justify-between" >
{value && } - {value ? value : "Select"} + {value ? (annotations[value]?.label ?? value) : "Select"}
diff --git a/editor/scaffolds/grid/wellknown/customer-grid.tsx b/editor/scaffolds/grid/wellknown/customer-grid.tsx index c98af3fd2..4d7897ff3 100644 --- a/editor/scaffolds/grid/wellknown/customer-grid.tsx +++ b/editor/scaffolds/grid/wellknown/customer-grid.tsx @@ -364,7 +364,9 @@ function Grid(
Date: Mon, 9 Feb 2026 01:45:09 +0900 Subject: [PATCH 4/4] feat: enhance customer tagging functionality with project validation - Updated the `apply_customer_tags` function to include a validation step ensuring that the customer belongs to the specified project before applying tags. - Adjusted the security settings to restrict function access to the service role only. - Enhanced test setup to accommodate multiple tenants and ensure proper tagging behavior across different projects. --- ...20260208000000_campaign_ciam_auto_tags.sql | 19 +- supabase/schemas/grida_west_referral.sql | 20 +- supabase/tests/test_campaign_auto_tag.sql | 232 +++++++++++++----- 3 files changed, 203 insertions(+), 68 deletions(-) diff --git a/supabase/migrations/20260208000000_campaign_ciam_auto_tags.sql b/supabase/migrations/20260208000000_campaign_ciam_auto_tags.sql index cfc8b4ee4..0402ace01 100644 --- a/supabase/migrations/20260208000000_campaign_ciam_auto_tags.sql +++ b/supabase/migrations/20260208000000_campaign_ciam_auto_tags.sql @@ -14,6 +14,8 @@ COMMENT ON COLUMN grida_west_referral.campaign.ciam_invitee_on_claim_tag_names --------------------------------------------------------------------- -- 2. Helper: add-only tag application (no removals) +-- SECURITY DEFINER with minimal search_path. +-- Validates customer belongs to the given project before tagging. --------------------------------------------------------------------- CREATE OR REPLACE FUNCTION grida_west_referral.apply_customer_tags( p_customer_uid uuid, @@ -23,15 +25,26 @@ CREATE OR REPLACE FUNCTION grida_west_referral.apply_customer_tags( RETURNS void LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, public, grida_ciam +SET search_path = pg_catalog, public AS $$ DECLARE tag text; + v_valid boolean; BEGIN IF p_tag_names IS NULL OR array_length(p_tag_names, 1) IS NULL THEN RETURN; END IF; + -- Tenant boundary: ensure customer belongs to the target project. + SELECT EXISTS( + SELECT 1 FROM public.customer + WHERE uid = p_customer_uid AND project_id = p_project_id + ) INTO v_valid; + + IF NOT v_valid THEN + RETURN; + END IF; + FOREACH tag IN ARRAY p_tag_names LOOP INSERT INTO public.tag (project_id, name) VALUES (p_project_id, tag) @@ -44,8 +57,10 @@ BEGIN END; $$; --- Only callable by triggers / service_role; block direct RPC from anon/authenticated. +-- Lock down: only service_role (and SECURITY DEFINER triggers) may call this. +REVOKE ALL ON FUNCTION grida_west_referral.apply_customer_tags(uuid, bigint, text[]) FROM PUBLIC; REVOKE EXECUTE ON FUNCTION grida_west_referral.apply_customer_tags(uuid, bigint, text[]) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION grida_west_referral.apply_customer_tags(uuid, bigint, text[]) TO service_role; --------------------------------------------------------------------- -- 3. Trigger: auto-tag invitee on claim diff --git a/supabase/schemas/grida_west_referral.sql b/supabase/schemas/grida_west_referral.sql index ec1f7a64f..57b41f373 100644 --- a/supabase/schemas/grida_west_referral.sql +++ b/supabase/schemas/grida_west_referral.sql @@ -851,7 +851,8 @@ GRANT EXECUTE ON FUNCTION grida_west_referral.analyze TO service_role; -- [CIAM Auto-Tagging] -- --------------------------------------------------------------------- --- Helper: add-only tag application (no removals, idempotent) +-- Helper: add-only tag application (no removals, idempotent). +-- Validates customer belongs to the target project before tagging. CREATE OR REPLACE FUNCTION grida_west_referral.apply_customer_tags( p_customer_uid uuid, p_project_id bigint, @@ -860,15 +861,26 @@ CREATE OR REPLACE FUNCTION grida_west_referral.apply_customer_tags( RETURNS void LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, public, grida_ciam +SET search_path = pg_catalog, public AS $$ DECLARE tag text; + v_valid boolean; BEGIN IF p_tag_names IS NULL OR array_length(p_tag_names, 1) IS NULL THEN RETURN; END IF; + -- Tenant boundary: ensure customer belongs to the target project. + SELECT EXISTS( + SELECT 1 FROM public.customer + WHERE uid = p_customer_uid AND project_id = p_project_id + ) INTO v_valid; + + IF NOT v_valid THEN + RETURN; + END IF; + FOREACH tag IN ARRAY p_tag_names LOOP INSERT INTO public.tag (project_id, name) VALUES (p_project_id, tag) @@ -881,8 +893,10 @@ BEGIN END; $$; --- Only callable by triggers / service_role; block direct RPC from anon/authenticated. +-- Lock down: only service_role (and SECURITY DEFINER triggers) may call this. +REVOKE ALL ON FUNCTION grida_west_referral.apply_customer_tags(uuid, bigint, text[]) FROM PUBLIC; REVOKE EXECUTE ON FUNCTION grida_west_referral.apply_customer_tags(uuid, bigint, text[]) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION grida_west_referral.apply_customer_tags(uuid, bigint, text[]) TO service_role; -- Trigger function: auto-tag invitee on claim CREATE OR REPLACE FUNCTION grida_west_referral.trg_auto_tag_invitee_on_claim() diff --git a/supabase/tests/test_campaign_auto_tag.sql b/supabase/tests/test_campaign_auto_tag.sql index fdf66fe40..a09c8730b 100644 --- a/supabase/tests/test_campaign_auto_tag.sql +++ b/supabase/tests/test_campaign_auto_tag.sql @@ -1,158 +1,264 @@ BEGIN; -SELECT plan(5); +SELECT plan(10); -- --------------------------------------------------------------------------- --- Setup: create project, campaign, customers, referrer, invitation fixtures. --- All done as service_role (bypasses RLS). +-- Setup: two tenants (local + acme), each with a project, campaign, customers, +-- referrer, and invitation. All done as service_role (bypasses RLS). -- --------------------------------------------------------------------------- DO $$ DECLARE - v_org_id bigint; - v_project_id bigint; - v_project_name text := + -- local tenant + v_local_org_id bigint; + v_local_project_id bigint; + v_local_project_name text := 'at-' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 8); - v_campaign_id uuid; - v_referrer_customer_uid uuid; - v_invitee_customer_uid uuid; - v_referrer_id uuid; - v_invitation_code text; + v_local_campaign_id uuid; + v_local_referrer_customer_uid uuid; + v_local_invitee_customer_uid uuid; + v_local_referrer_id uuid; + v_local_invitation_code text; + -- acme tenant + v_acme_org_id bigint; + v_acme_project_id bigint; + v_acme_project_name text := + 'aa-' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 8); + v_acme_customer_uid uuid; BEGIN - -- Resolve seeded org - SELECT id INTO v_org_id FROM public.organization WHERE name = 'local' LIMIT 1; - IF v_org_id IS NULL THEN - RAISE EXCEPTION 'seed org "local" not found'; - END IF; + -- Resolve seeded orgs + SELECT id INTO v_local_org_id FROM public.organization WHERE name = 'local' LIMIT 1; + IF v_local_org_id IS NULL THEN RAISE EXCEPTION 'seed org "local" not found'; END IF; + + SELECT id INTO v_acme_org_id FROM public.organization WHERE name = 'acme' LIMIT 1; + IF v_acme_org_id IS NULL THEN RAISE EXCEPTION 'seed org "acme" not found'; END IF; SET LOCAL ROLE service_role; - -- Create project + -- ===== LOCAL tenant ===== INSERT INTO public.project (organization_id, name) - VALUES (v_org_id, v_project_name) - RETURNING id INTO v_project_id; + VALUES (v_local_org_id, v_local_project_name) + RETURNING id INTO v_local_project_id; - -- Create customers INSERT INTO public.customer (project_id, uuid, email, name) - VALUES (v_project_id, gen_random_uuid(), 'referrer@example.com', 'Referrer') - RETURNING uid INTO v_referrer_customer_uid; + VALUES (v_local_project_id, gen_random_uuid(), 'referrer@example.com', 'Referrer') + RETURNING uid INTO v_local_referrer_customer_uid; INSERT INTO public.customer (project_id, uuid, email, name) - VALUES (v_project_id, gen_random_uuid(), 'invitee@example.com', 'Invitee') - RETURNING uid INTO v_invitee_customer_uid; + VALUES (v_local_project_id, gen_random_uuid(), 'invitee@example.com', 'Invitee') + RETURNING uid INTO v_local_invitee_customer_uid; - -- Create campaign document + campaign with auto-tag config - v_campaign_id := gen_random_uuid(); + v_local_campaign_id := gen_random_uuid(); INSERT INTO public.document (id, doctype, project_id, title) - VALUES (v_campaign_id, 'v0_campaign_referral', v_project_id, 'Auto-Tag Campaign'); + VALUES (v_local_campaign_id, 'v0_campaign_referral', v_local_project_id, 'Local Auto-Tag Campaign'); INSERT INTO grida_west_referral.campaign ( id, project_id, title, ciam_invitee_on_claim_tag_names ) VALUES ( - v_campaign_id, v_project_id, 'Auto-Tag Campaign', + v_local_campaign_id, v_local_project_id, 'Local Auto-Tag Campaign', ARRAY['invitee-tag', 'campaign-member'] ); - -- Create referrer INSERT INTO grida_west_referral.referrer (project_id, campaign_id, customer_id) - VALUES (v_project_id, v_campaign_id, v_referrer_customer_uid) - RETURNING id INTO v_referrer_id; + VALUES (v_local_project_id, v_local_campaign_id, v_local_referrer_customer_uid) + RETURNING id INTO v_local_referrer_id; - -- Create invitation (unclaimed initially) INSERT INTO grida_west_referral.invitation (campaign_id, referrer_id) - VALUES (v_campaign_id, v_referrer_id) - RETURNING code INTO v_invitation_code; + VALUES (v_local_campaign_id, v_local_referrer_id) + RETURNING code INTO v_local_invitation_code; + + -- ===== ACME tenant ===== + INSERT INTO public.project (organization_id, name) + VALUES (v_acme_org_id, v_acme_project_name) + RETURNING id INTO v_acme_project_id; + + INSERT INTO public.customer (project_id, uuid, email, name) + VALUES (v_acme_project_id, gen_random_uuid(), 'acme-customer@example.com', 'Acme Customer') + RETURNING uid INTO v_acme_customer_uid; RESET ROLE; -- Stash IDs - PERFORM set_config('test.project_id', v_project_id::text, false); - PERFORM set_config('test.campaign_id', v_campaign_id::text, false); - PERFORM set_config('test.invitee_customer_uid', v_invitee_customer_uid::text, false); - PERFORM set_config('test.invitation_code', v_invitation_code, false); + PERFORM set_config('test.local_project_id', v_local_project_id::text, false); + PERFORM set_config('test.local_campaign_id', v_local_campaign_id::text, false); + PERFORM set_config('test.local_invitee_uid', v_local_invitee_customer_uid::text, false); + PERFORM set_config('test.local_invitation_code', v_local_invitation_code, false); + PERFORM set_config('test.acme_project_id', v_acme_project_id::text, false); + PERFORM set_config('test.acme_customer_uid', v_acme_customer_uid::text, false); END $$; +-- Helper: set auth context as authenticated user. +CREATE OR REPLACE FUNCTION test_set_auth(user_email text) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + user_id uuid; +BEGIN + SELECT id INTO user_id FROM auth.users WHERE email = user_email; + IF user_id IS NULL THEN + RAISE EXCEPTION 'seed user not found: %', user_email; + END IF; + PERFORM set_config('request.jwt.claim.sub', user_id::text, true); + SET LOCAL ROLE authenticated; +END; +$$; + +-- Helper: reset auth context. +CREATE OR REPLACE FUNCTION test_reset_auth() +RETURNS void +LANGUAGE sql +AS $$ + SELECT set_config('request.jwt.claim.sub', '', true); + RESET ROLE; +$$; + -- ========================================================================= --- Test 1: Invitee has NO tags before claim (invitation is unclaimed) +-- HAPPY PATH: claim triggers auto-tag -- ========================================================================= + +-- Test 1: Invitee has NO tags before claim SELECT ok( NOT EXISTS( SELECT 1 FROM grida_ciam.customer_tag - WHERE customer_uid = current_setting('test.invitee_customer_uid')::uuid - AND project_id = current_setting('test.project_id')::bigint + WHERE customer_uid = current_setting('test.local_invitee_uid')::uuid + AND project_id = current_setting('test.local_project_id')::bigint AND tag_name = 'invitee-tag' ), 'invitee should NOT have invitee-tag before claiming' ); --- ========================================================================= --- Claim the invitation (triggers auto-tag for invitee) --- ========================================================================= +-- Claim the invitation (as service_role, same as the public API route) DO $$ BEGIN SET LOCAL ROLE service_role; - PERFORM grida_west_referral.claim( - current_setting('test.campaign_id')::uuid, - current_setting('test.invitation_code'), - current_setting('test.invitee_customer_uid')::uuid + current_setting('test.local_campaign_id')::uuid, + current_setting('test.local_invitation_code'), + current_setting('test.local_invitee_uid')::uuid ); - RESET ROLE; END $$; --- ========================================================================= -- Test 2: Invitee customer has 'invitee-tag' after claim --- ========================================================================= SELECT ok( EXISTS( SELECT 1 FROM grida_ciam.customer_tag - WHERE customer_uid = current_setting('test.invitee_customer_uid')::uuid - AND project_id = current_setting('test.project_id')::bigint + WHERE customer_uid = current_setting('test.local_invitee_uid')::uuid + AND project_id = current_setting('test.local_project_id')::bigint AND tag_name = 'invitee-tag' ), 'invitee customer should have invitee-tag after claiming' ); --- ========================================================================= -- Test 3: Invitee customer has 'campaign-member' after claim --- ========================================================================= SELECT ok( EXISTS( SELECT 1 FROM grida_ciam.customer_tag - WHERE customer_uid = current_setting('test.invitee_customer_uid')::uuid - AND project_id = current_setting('test.project_id')::bigint + WHERE customer_uid = current_setting('test.local_invitee_uid')::uuid + AND project_id = current_setting('test.local_project_id')::bigint AND tag_name = 'campaign-member' ), 'invitee customer should have campaign-member tag after claiming' ); --- ========================================================================= -- Test 4: 'invitee-tag' was auto-created in public.tag --- ========================================================================= SELECT ok( EXISTS( SELECT 1 FROM public.tag - WHERE project_id = current_setting('test.project_id')::bigint + WHERE project_id = current_setting('test.local_project_id')::bigint AND name = 'invitee-tag' ), 'invitee-tag should be auto-created in public.tag on claim' ); --- ========================================================================= -- Test 5: 'campaign-member' was auto-created in public.tag --- ========================================================================= SELECT ok( EXISTS( SELECT 1 FROM public.tag - WHERE project_id = current_setting('test.project_id')::bigint + WHERE project_id = current_setting('test.local_project_id')::bigint AND name = 'campaign-member' ), 'campaign-member tag should be auto-created in public.tag on claim' ); +-- ========================================================================= +-- TENANT ISOLATION: tags do NOT leak to acme project +-- ========================================================================= + +-- Test 6: Auto-created tags do not appear under the acme project +SELECT ok( + NOT EXISTS( + SELECT 1 FROM public.tag + WHERE project_id = current_setting('test.acme_project_id')::bigint + AND name IN ('invitee-tag', 'campaign-member') + ), + 'auto-created tags should NOT appear under acme project' +); + +-- Test 7: Acme customer has no tags from the local campaign claim +SELECT ok( + NOT EXISTS( + SELECT 1 FROM grida_ciam.customer_tag + WHERE customer_uid = current_setting('test.acme_customer_uid')::uuid + AND tag_name IN ('invitee-tag', 'campaign-member') + ), + 'acme customer should have no tags from local campaign' +); + +-- Test 8: apply_customer_tags silently rejects cross-project customer +-- (acme customer + local project_id => no-op) +DO $$ +BEGIN + SET LOCAL ROLE service_role; + PERFORM grida_west_referral.apply_customer_tags( + current_setting('test.acme_customer_uid')::uuid, + current_setting('test.local_project_id')::bigint, + ARRAY['rogue-tag'] + ); + RESET ROLE; +END $$; + +SELECT ok( + NOT EXISTS( + SELECT 1 FROM grida_ciam.customer_tag + WHERE customer_uid = current_setting('test.acme_customer_uid')::uuid + AND tag_name = 'rogue-tag' + ), + 'apply_customer_tags should reject cross-project customer (no tag written)' +); + +SELECT ok( + NOT EXISTS( + SELECT 1 FROM public.tag + WHERE project_id = current_setting('test.local_project_id')::bigint + AND name = 'rogue-tag' + ), + 'apply_customer_tags should not create tag row for cross-project attempt' +); + + +-- ========================================================================= +-- PRIVILEGE: authenticated users cannot call apply_customer_tags directly +-- ========================================================================= + +-- Test 10: Insider (authenticated) cannot execute apply_customer_tags +SELECT test_set_auth('insider@grida.co'); +SELECT throws_ok( + format( + 'SELECT grida_west_referral.apply_customer_tags(%L::uuid, %s::bigint, ARRAY[''test''])', + current_setting('test.local_invitee_uid'), + current_setting('test.local_project_id') + ), + '42501', -- insufficient_privilege + NULL, + 'authenticated user should be denied EXECUTE on apply_customer_tags' +); +SELECT test_reset_auth(); + + SELECT * FROM finish(); ROLLBACK;