diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts index e8202309ee..f03d85f262 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 5c38093f88..6b7fc858bb 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/editor/components/dialogs/share-dialog.tsx b/editor/components/dialogs/share-dialog.tsx index 52e1279c73..b5cc22a7ff 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 { diff --git a/editor/components/formfield-type-select/index.tsx b/editor/components/formfield-type-select/index.tsx index 625bed9c98..1e7902d21e 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 c98af3fd20..4d7897ff33 100644 --- a/editor/scaffolds/grid/wellknown/customer-grid.tsx +++ b/editor/scaffolds/grid/wellknown/customer-grid.tsx @@ -364,7 +364,9 @@ function Grid(
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 80068124b6..57b41f373e 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,86 @@ $$ 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). +-- 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, + p_tag_names text[] +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +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) + 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; +$$; + +-- 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() +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 0000000000..a09c8730b4 --- /dev/null +++ b/supabase/tests/test_campaign_auto_tag.sql @@ -0,0 +1,264 @@ +BEGIN; +SELECT plan(10); + +-- --------------------------------------------------------------------------- +-- Setup: two tenants (local + acme), each with a project, campaign, customers, +-- referrer, and invitation. All done as service_role (bypasses RLS). +-- --------------------------------------------------------------------------- +DO $$ +DECLARE + -- 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_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 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; + + -- ===== LOCAL tenant ===== + INSERT INTO public.project (organization_id, name) + VALUES (v_local_org_id, v_local_project_name) + RETURNING id INTO v_local_project_id; + + INSERT INTO public.customer (project_id, uuid, email, name) + 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_local_project_id, gen_random_uuid(), 'invitee@example.com', 'Invitee') + RETURNING uid INTO v_local_invitee_customer_uid; + + v_local_campaign_id := gen_random_uuid(); + + INSERT INTO public.document (id, doctype, project_id, title) + 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_local_campaign_id, v_local_project_id, 'Local Auto-Tag Campaign', + ARRAY['invitee-tag', 'campaign-member'] + ); + + INSERT INTO grida_west_referral.referrer (project_id, campaign_id, customer_id) + VALUES (v_local_project_id, v_local_campaign_id, v_local_referrer_customer_uid) + RETURNING id INTO v_local_referrer_id; + + INSERT INTO grida_west_referral.invitation (campaign_id, referrer_id) + 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.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; +$$; + + +-- ========================================================================= +-- 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.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 (as service_role, same as the public API route) +DO $$ +BEGIN + SET LOCAL ROLE service_role; + PERFORM grida_west_referral.claim( + 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.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.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.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.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;