Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions database/database-generated.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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<typeof formSchema>;
Expand Down Expand Up @@ -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("*");
Expand Down Expand Up @@ -266,6 +269,7 @@ function Body({
<TabsTrigger value="rewards">Rewards</TabsTrigger>
<TabsTrigger value="challenges">Challenges</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
<TabsTrigger value="tagging">Customer Tagging</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
<TabsTrigger
Expand Down Expand Up @@ -487,6 +491,10 @@ function Body({
</div>
)}

{activeTab === "tagging" && (
<CampaignTaggingSection control={form.control} />
)}

{activeTab === "milestone" && <ComingSoonCard />}
{activeTab === "rewards" && <ComingSoonCard />}
{activeTab === "challenges" && <ComingSoonCard />}
Expand Down Expand Up @@ -712,6 +720,67 @@ function Body({
);
}

function CampaignTaggingSection({
control,
}: {
control: Control<CampaignFormValues>;
}) {
const { tags: projectTags } = useTags();

const autocompleteOptions = useMemo(
() => projectTags.map((t) => ({ id: t.name, text: t.name })),
[projectTags]
);

const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);

return (
<div>
<h3 className="text-lg font-medium">Customer Tagging</h3>
<p className="text-sm text-muted-foreground mb-4">
Automatically tag customers when they join this campaign. Tags are
applied once (add-only) and will not be removed if changed later.
</p>
<div className="space-y-8">
<FormField
control={control}
name="ciam_invitee_on_claim_tag_names"
render={({ field }) => {
const tags = field.value ?? [];
return (
<FormItem>
<FormLabel>Invitee tags (on claim)</FormLabel>
<FormControl>
<TagInput
tags={tags.map((t) => ({ 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"
/>
</FormControl>
<FormDescription>
These tags will be automatically applied to the invitee&apos;s
customer profile when they claim an invitation.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
</div>
</div>
);
}

function CampaignPublicDataFields({
control,
projectId,
Expand Down
10 changes: 6 additions & 4 deletions editor/components/dialogs/share-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
Expand Down
4 changes: 2 additions & 2 deletions editor/components/formfield-type-select/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<div className="flex gap-2 items-center">
{value && <FormFieldTypeIcon type={value} className="size-4" />}
{value ? value : "Select"}
{value ? (annotations[value]?.label ?? value) : "Select"}
</div>
<ChevronDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
Expand Down
4 changes: 3 additions & 1 deletion editor/scaffolds/grid/wellknown/customer-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,9 @@ function Grid(
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<CustomerGrid
loading={tablespace.loading}
tokens={tablespace.q_text_search ? [tablespace.q_text_search.query] : []}
tokens={
tablespace.q_text_search ? [tablespace.q_text_search.query] : []
}
selectedRows={selection}
onSelectedRowsChange={setSelection}
rows={tablespace.stream || []}
Expand Down
7 changes: 7 additions & 0 deletions editor/scaffolds/panels/field-edit-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ const default_field_init: {
placeholder: "alice@example.com",
pattern: "[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$",
},
challenge_email: {
type: "challenge_email",
name: "email",
label: "Email",
placeholder: "alice@example.com",
pattern: "[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$",
},
select: {
type: "select",
options: [
Expand Down
95 changes: 95 additions & 0 deletions supabase/migrations/20260208000000_campaign_ciam_auto_tags.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
-- 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)
-- 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,
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;

---------------------------------------------------------------------
-- 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();
Loading
Loading