Skip to content

Commit 7f3d892

Browse files
Merge pull request #537 from gridaco/canary
Grida Campaigns - Auto tag invitation claims
2 parents 4e58e84 + f49f670 commit 7f3d892

File tree

9 files changed

+543
-8
lines changed

9 files changed

+543
-8
lines changed

database/database-generated.types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2762,6 +2762,7 @@ export type Database = {
27622762
Tables: {
27632763
campaign: {
27642764
Row: {
2765+
ciam_invitee_on_claim_tag_names: string[]
27652766
conversion_currency: string
27662767
conversion_value: number | null
27672768
created_at: string
@@ -2782,6 +2783,7 @@ export type Database = {
27822783
title: string
27832784
}
27842785
Insert: {
2786+
ciam_invitee_on_claim_tag_names?: string[]
27852787
conversion_currency?: string
27862788
conversion_value?: number | null
27872789
created_at?: string
@@ -2802,6 +2804,7 @@ export type Database = {
28022804
title: string
28032805
}
28042806
Update: {
2807+
ciam_invitee_on_claim_tag_names?: string[]
28052808
conversion_currency?: string
28062809
conversion_value?: number | null
28072810
created_at?: string
@@ -3624,6 +3627,14 @@ export type Database = {
36243627
name: string
36253628
}[]
36263629
}
3630+
apply_customer_tags: {
3631+
Args: {
3632+
p_customer_uid: string
3633+
p_project_id: number
3634+
p_tag_names: string[]
3635+
}
3636+
Returns: undefined
3637+
}
36273638
claim: {
36283639
Args: { p_campaign_id: string; p_code: string; p_customer_id: string }
36293640
Returns: {

editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/settings.tsx

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,10 @@ import { Spinner } from "@/components/ui/spinner";
7373
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
7474
import { useUnsavedChangesWarning } from "@/hooks/use-unsaved-changes-warning";
7575
import { DeleteConfirmationAlertDialog } from "@/components/dialogs/delete-confirmation-dialog";
76-
import { useProject } from "@/scaffolds/workspace";
76+
import { useProject, useTags } from "@/scaffolds/workspace";
7777
import { useRouter } from "next/navigation";
7878
import type { Database } from "@app/database";
79+
import { TagInput } from "@/components/tag";
7980

8081
// Timezone options
8182
const timezones = [
@@ -102,6 +103,7 @@ const formSchema = z.object({
102103
scheduling_close_at: z.date().nullable(),
103104
scheduling_tz: z.string().nullable(),
104105
public: z.any().default({}),
106+
ciam_invitee_on_claim_tag_names: z.array(z.string()).default([]),
105107
});
106108

107109
type CampaignFormValues = z.infer<typeof formSchema>;
@@ -139,6 +141,7 @@ function useCampaignData(id: string) {
139141
data.is_referrer_profile_exposed_to_public_dangerously,
140142
max_invitations_per_referrer: data.max_invitations_per_referrer,
141143
public: data.public,
144+
ciam_invitee_on_claim_tag_names: data.ciam_invitee_on_claim_tag_names,
142145
})
143146
.eq("id", id)
144147
.select("*");
@@ -266,6 +269,7 @@ function Body({
266269
<TabsTrigger value="rewards">Rewards</TabsTrigger>
267270
<TabsTrigger value="challenges">Challenges</TabsTrigger>
268271
<TabsTrigger value="events">Events</TabsTrigger>
272+
<TabsTrigger value="tagging">Customer Tagging</TabsTrigger>
269273
<TabsTrigger value="security">Security</TabsTrigger>
270274
<TabsTrigger value="advanced">Advanced</TabsTrigger>
271275
<TabsTrigger
@@ -487,6 +491,10 @@ function Body({
487491
</div>
488492
)}
489493

494+
{activeTab === "tagging" && (
495+
<CampaignTaggingSection control={form.control} />
496+
)}
497+
490498
{activeTab === "milestone" && <ComingSoonCard />}
491499
{activeTab === "rewards" && <ComingSoonCard />}
492500
{activeTab === "challenges" && <ComingSoonCard />}
@@ -712,6 +720,67 @@ function Body({
712720
);
713721
}
714722

723+
function CampaignTaggingSection({
724+
control,
725+
}: {
726+
control: Control<CampaignFormValues>;
727+
}) {
728+
const { tags: projectTags } = useTags();
729+
730+
const autocompleteOptions = useMemo(
731+
() => projectTags.map((t) => ({ id: t.name, text: t.name })),
732+
[projectTags]
733+
);
734+
735+
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
736+
737+
return (
738+
<div>
739+
<h3 className="text-lg font-medium">Customer Tagging</h3>
740+
<p className="text-sm text-muted-foreground mb-4">
741+
Automatically tag customers when they join this campaign. Tags are
742+
applied once (add-only) and will not be removed if changed later.
743+
</p>
744+
<div className="space-y-8">
745+
<FormField
746+
control={control}
747+
name="ciam_invitee_on_claim_tag_names"
748+
render={({ field }) => {
749+
const tags = field.value ?? [];
750+
return (
751+
<FormItem>
752+
<FormLabel>Invitee tags (on claim)</FormLabel>
753+
<FormControl>
754+
<TagInput
755+
tags={tags.map((t) => ({ id: t, text: t }))}
756+
setTags={(newTags) => {
757+
const resolved =
758+
typeof newTags === "function"
759+
? newTags(tags.map((t) => ({ id: t, text: t })))
760+
: newTags;
761+
field.onChange(resolved.map((t) => t.text));
762+
}}
763+
activeTagIndex={activeTagIndex}
764+
setActiveTagIndex={setActiveTagIndex}
765+
enableAutocomplete={autocompleteOptions.length > 0}
766+
autocompleteOptions={autocompleteOptions}
767+
placeholder="Add tags"
768+
/>
769+
</FormControl>
770+
<FormDescription>
771+
These tags will be automatically applied to the invitee&apos;s
772+
customer profile when they claim an invitation.
773+
</FormDescription>
774+
<FormMessage />
775+
</FormItem>
776+
);
777+
}}
778+
/>
779+
</div>
780+
</div>
781+
);
782+
}
783+
715784
function CampaignPublicDataFields({
716785
control,
717786
projectId,

editor/components/dialogs/share-dialog.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,12 @@ function buildMailtoHref({
6767
subject?: string;
6868
body: string;
6969
}): string {
70-
const params = new URLSearchParams();
71-
if (subject) params.set("subject", subject);
72-
params.set("body", body);
73-
return `mailto:?${params.toString()}`;
70+
// Manually encode with encodeURIComponent so spaces become '%20' (not '+')
71+
// per RFC 6068.
72+
const pairs: string[] = [];
73+
if (subject) pairs.push(`subject=${encodeURIComponent(subject)}`);
74+
pairs.push(`body=${encodeURIComponent(body)}`);
75+
return `mailto:?${pairs.join("&")}`;
7476
}
7577

7678
async function copyToClipboard(text: string): Promise<boolean> {

editor/components/formfield-type-select/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ export function TypeSelect({
5252
variant="outline"
5353
role="combobox"
5454
aria-expanded={open}
55-
className="w-full justify-between capitalize"
55+
className="w-full justify-between"
5656
>
5757
<div className="flex gap-2 items-center">
5858
{value && <FormFieldTypeIcon type={value} className="size-4" />}
59-
{value ? value : "Select"}
59+
{value ? (annotations[value]?.label ?? value) : "Select"}
6060
</div>
6161
<ChevronDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
6262
</Button>

editor/scaffolds/grid/wellknown/customer-grid.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,9 @@ function Grid(
364364
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
365365
<CustomerGrid
366366
loading={tablespace.loading}
367-
tokens={tablespace.q_text_search ? [tablespace.q_text_search.query] : []}
367+
tokens={
368+
tablespace.q_text_search ? [tablespace.q_text_search.query] : []
369+
}
368370
selectedRows={selection}
369371
onSelectedRowsChange={setSelection}
370372
rows={tablespace.stream || []}

editor/scaffolds/panels/field-edit-panel.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ const default_field_init: {
102102
placeholder: "alice@example.com",
103103
pattern: "[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$",
104104
},
105+
challenge_email: {
106+
type: "challenge_email",
107+
name: "email",
108+
label: "Email",
109+
placeholder: "alice@example.com",
110+
pattern: "[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$",
111+
},
105112
select: {
106113
type: "select",
107114
options: [
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
-- Campaign CIAM auto-tagging
2+
-- Adds per-campaign tag-name list that is automatically applied to the
3+
-- invitee customer when an invitation is claimed.
4+
-- Fully DB-side via trigger; no server changes.
5+
6+
---------------------------------------------------------------------
7+
-- 1. Add column to campaign
8+
---------------------------------------------------------------------
9+
ALTER TABLE grida_west_referral.campaign
10+
ADD COLUMN ciam_invitee_on_claim_tag_names text[] NOT NULL DEFAULT '{}';
11+
12+
COMMENT ON COLUMN grida_west_referral.campaign.ciam_invitee_on_claim_tag_names
13+
IS 'Tag names auto-applied to invitee customer when invitation is claimed';
14+
15+
---------------------------------------------------------------------
16+
-- 2. Helper: add-only tag application (no removals)
17+
-- SECURITY DEFINER with minimal search_path.
18+
-- Validates customer belongs to the given project before tagging.
19+
---------------------------------------------------------------------
20+
CREATE OR REPLACE FUNCTION grida_west_referral.apply_customer_tags(
21+
p_customer_uid uuid,
22+
p_project_id bigint,
23+
p_tag_names text[]
24+
)
25+
RETURNS void
26+
LANGUAGE plpgsql
27+
SECURITY DEFINER
28+
SET search_path = pg_catalog, public
29+
AS $$
30+
DECLARE
31+
tag text;
32+
v_valid boolean;
33+
BEGIN
34+
IF p_tag_names IS NULL OR array_length(p_tag_names, 1) IS NULL THEN
35+
RETURN;
36+
END IF;
37+
38+
-- Tenant boundary: ensure customer belongs to the target project.
39+
SELECT EXISTS(
40+
SELECT 1 FROM public.customer
41+
WHERE uid = p_customer_uid AND project_id = p_project_id
42+
) INTO v_valid;
43+
44+
IF NOT v_valid THEN
45+
RETURN;
46+
END IF;
47+
48+
FOREACH tag IN ARRAY p_tag_names LOOP
49+
INSERT INTO public.tag (project_id, name)
50+
VALUES (p_project_id, tag)
51+
ON CONFLICT (project_id, name) DO NOTHING;
52+
53+
INSERT INTO grida_ciam.customer_tag (customer_uid, project_id, tag_name)
54+
VALUES (p_customer_uid, p_project_id, tag)
55+
ON CONFLICT DO NOTHING;
56+
END LOOP;
57+
END;
58+
$$;
59+
60+
-- Lock down: only service_role (and SECURITY DEFINER triggers) may call this.
61+
REVOKE ALL ON FUNCTION grida_west_referral.apply_customer_tags(uuid, bigint, text[]) FROM PUBLIC;
62+
REVOKE EXECUTE ON FUNCTION grida_west_referral.apply_customer_tags(uuid, bigint, text[]) FROM anon, authenticated;
63+
GRANT EXECUTE ON FUNCTION grida_west_referral.apply_customer_tags(uuid, bigint, text[]) TO service_role;
64+
65+
---------------------------------------------------------------------
66+
-- 3. Trigger: auto-tag invitee on claim
67+
---------------------------------------------------------------------
68+
CREATE OR REPLACE FUNCTION grida_west_referral.trg_auto_tag_invitee_on_claim()
69+
RETURNS trigger
70+
LANGUAGE plpgsql
71+
SECURITY DEFINER
72+
SET search_path = pg_catalog, public
73+
AS $$
74+
DECLARE
75+
v_tag_names text[];
76+
v_project_id bigint;
77+
BEGIN
78+
IF OLD.is_claimed = false AND NEW.is_claimed = true AND NEW.customer_id IS NOT NULL THEN
79+
SELECT c.ciam_invitee_on_claim_tag_names, c.project_id
80+
INTO v_tag_names, v_project_id
81+
FROM grida_west_referral.campaign c
82+
WHERE c.id = NEW.campaign_id;
83+
84+
IF v_tag_names IS NOT NULL AND array_length(v_tag_names, 1) > 0 THEN
85+
PERFORM grida_west_referral.apply_customer_tags(NEW.customer_id, v_project_id, v_tag_names);
86+
END IF;
87+
END IF;
88+
RETURN NEW;
89+
END;
90+
$$;
91+
92+
CREATE TRIGGER trg_auto_tag_invitee_on_claim
93+
AFTER UPDATE ON grida_west_referral.invitation
94+
FOR EACH ROW
95+
EXECUTE FUNCTION grida_west_referral.trg_auto_tag_invitee_on_claim();

0 commit comments

Comments
 (0)