Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
|
||||
|
||||
export type AudienceRules = {
|
||||
target?: "stop" | "zip_code" | "customer_history" | "product" | "customer_ids" | "all_customers";
|
||||
stop_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
zip_codes?: string[];
|
||||
city?: string;
|
||||
order_history?: "all" | "first_order" | "repeat";
|
||||
days_back?: number;
|
||||
product_id?: string;
|
||||
customer_ids?: string[];
|
||||
};
|
||||
|
||||
export type Campaign = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject: string | null;
|
||||
body_text: string | null;
|
||||
body_html: string | null;
|
||||
template_id: string | null;
|
||||
campaign_type: CampaignType;
|
||||
status: CampaignStatus;
|
||||
audience_rules: AudienceRules;
|
||||
scheduled_at: string | null;
|
||||
sent_at: string | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UpsertCampaignResult = {
|
||||
success: true;
|
||||
campaign: Campaign;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type ListCampaignsResult = {
|
||||
success: true;
|
||||
campaigns: Campaign[];
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function getCommunicationCampaigns(brandId?: string): Promise<ListCampaignsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch campaigns" };
|
||||
const data = await response.json();
|
||||
return { success: true, campaigns: data?.campaigns ?? [] };
|
||||
}
|
||||
|
||||
export async function upsertCampaign(params: {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject?: string;
|
||||
body_text?: string;
|
||||
body_html?: string;
|
||||
template_id?: string;
|
||||
campaign_type: CampaignType;
|
||||
status?: CampaignStatus;
|
||||
audience_rules?: AudienceRules;
|
||||
scheduled_at?: string;
|
||||
}): Promise<UpsertCampaignResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Brand scoping: brand_admin can only modify their own brand's campaigns
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
||||
return { success: false, error: "Not authorized to operate on this brand's campaigns" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_communication_campaign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: params.id ?? null,
|
||||
p_brand_id: params.brand_id,
|
||||
p_name: params.name,
|
||||
p_subject: params.subject ?? null,
|
||||
p_body_text: params.body_text ?? null,
|
||||
p_body_html: params.body_html ?? null,
|
||||
p_template_id: params.template_id ?? null,
|
||||
p_campaign_type: params.campaign_type,
|
||||
p_status: params.status ?? "draft",
|
||||
p_audience_rules: params.audience_rules ?? {},
|
||||
p_scheduled_at: params.scheduled_at ?? null,
|
||||
p_created_by: adminUser.user_id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.id) {
|
||||
return { success: false, error: data?.message ?? "Failed to save campaign" };
|
||||
}
|
||||
|
||||
return { success: true, campaign: data };
|
||||
}
|
||||
|
||||
export async function deleteCampaign(campaignId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Brand scoping: brand_admin can only delete their own brand's campaigns
|
||||
if (adminUser.role === "brand_admin" && brandId && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized to delete this brand's campaigns" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_communication_campaign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: adminUser.brand_id ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete campaign" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getCampaignById(campaignId: string, brandId?: string): Promise<Campaign | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_campaign_by_id`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: brandId ?? adminUser.brand_id ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
const campaign = data?.campaign ?? null;
|
||||
|
||||
// Client-side brand validation
|
||||
if (campaign && adminUser.role === "brand_admin" && campaign.brand_id !== adminUser.brand_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return campaign;
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { parseCSVWithLimits } from "@/lib/csv-parser";
|
||||
import { buildImportPreview } from "@/lib/column-detector";
|
||||
|
||||
export type ContactSource = "order" | "import" | "manual" | "admin";
|
||||
|
||||
export type Contact = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
full_name: string | null;
|
||||
source: ContactSource;
|
||||
external_id: string | null;
|
||||
customer_id: string | null;
|
||||
email_opt_in: boolean;
|
||||
sms_opt_in: boolean;
|
||||
email_opt_in_at: string | null;
|
||||
sms_opt_in_at: string | null;
|
||||
unsubscribed_at: string | null;
|
||||
tags: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ContactImportEntry = {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
full_name?: string;
|
||||
tags?: string[];
|
||||
email_opt_in?: boolean;
|
||||
sms_opt_in?: boolean;
|
||||
external_id?: string;
|
||||
/** Ignored columns from the CSV, stored in metadata on import */
|
||||
_metadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ImportResult = {
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
errors: { row: ContactImportEntry; error: string }[];
|
||||
};
|
||||
|
||||
// ── Import preview types (column detection) ────────────────────────────────
|
||||
|
||||
export type ImportField =
|
||||
| "email"
|
||||
| "phone"
|
||||
| "first_name"
|
||||
| "last_name"
|
||||
| "full_name"
|
||||
| "tags"
|
||||
| "email_opt_in"
|
||||
| "sms_opt_in"
|
||||
| "external_id"
|
||||
| null;
|
||||
|
||||
export type ColumnMapping = {
|
||||
csvColumn: string;
|
||||
field: ImportField;
|
||||
sampleValues: string[];
|
||||
};
|
||||
|
||||
export type ImportPreviewResult = {
|
||||
mappings: ColumnMapping[];
|
||||
totalRows: number;
|
||||
validRows: number;
|
||||
skippedRows: number;
|
||||
duplicateRows: number;
|
||||
skippedReasons: { rowIndex: number; reason: string }[];
|
||||
sampleRows: ContactImportEntry[];
|
||||
ignoredColumns: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type GetContactsResult = {
|
||||
success: true;
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function getContacts(params: {
|
||||
brandId: string;
|
||||
search?: string;
|
||||
source?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<GetContactsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
adminUser.brand_id !== params.brandId
|
||||
) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_contacts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_search: params.search ?? null,
|
||||
p_source: params.source ?? null,
|
||||
p_limit: params.limit ?? 100,
|
||||
p_offset: params.offset ?? 0,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch contacts" };
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
contacts: data?.contacts ?? [],
|
||||
total: data?.total ?? 0,
|
||||
limit: data?.limit ?? 100,
|
||||
offset: data?.offset ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type UpsertContactResult = {
|
||||
success: true;
|
||||
contact: Contact;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function upsertContact(contact: {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
full_name?: string;
|
||||
source: ContactSource;
|
||||
external_id?: string;
|
||||
customer_id?: string;
|
||||
email_opt_in?: boolean;
|
||||
sms_opt_in?: boolean;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<UpsertContactResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
adminUser.brand_id !== contact.brand_id
|
||||
) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_communication_contact`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: contact.id ?? null,
|
||||
p_brand_id: contact.brand_id,
|
||||
p_email: contact.email ?? null,
|
||||
p_phone: contact.phone ?? null,
|
||||
p_first_name: contact.first_name ?? null,
|
||||
p_last_name: contact.last_name ?? null,
|
||||
p_full_name: contact.full_name ?? null,
|
||||
p_source: contact.source,
|
||||
p_external_id: contact.external_id ?? null,
|
||||
p_customer_id: contact.customer_id ?? null,
|
||||
p_email_opt_in: contact.email_opt_in ?? null,
|
||||
p_sms_opt_in: contact.sms_opt_in ?? null,
|
||||
p_tags: contact.tags ?? null,
|
||||
p_metadata: contact.metadata ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to upsert contact" };
|
||||
const data = await response.json();
|
||||
|
||||
if (!data) return { success: false, error: "No data returned" };
|
||||
return { success: true, contact: data as Contact };
|
||||
}
|
||||
|
||||
export type ImportContactsResult = {
|
||||
success: true;
|
||||
result: ImportResult;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function importContactsBatch(params: {
|
||||
brandId: string;
|
||||
contacts: ContactImportEntry[];
|
||||
allowOptInOverride?: boolean;
|
||||
}): Promise<ImportContactsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
adminUser.brand_id !== params.brandId
|
||||
) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/import_communication_contacts_batch`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_contacts: params.contacts,
|
||||
p_allow_opt_in_override: params.allowOptInOverride ?? false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to import contacts" };
|
||||
const data = await response.json();
|
||||
|
||||
return { success: true, result: data as ImportResult };
|
||||
}
|
||||
|
||||
export type OptOutResult = {
|
||||
success: true;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function optOutContact(params: {
|
||||
email: string;
|
||||
brandId: string;
|
||||
method: "email" | "sms";
|
||||
}): Promise<OptOutResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
adminUser.brand_id !== params.brandId
|
||||
) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/opt_out_contact`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_email: params.email,
|
||||
p_brand_id: params.brandId,
|
||||
p_method: params.method,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to opt out contact" };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteContact(id: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
adminUser.role === "brand_admin" &&
|
||||
brandId &&
|
||||
adminUser.brand_id !== brandId
|
||||
) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_communication_contact`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_id: id }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete contact" };
|
||||
const data = await response.json();
|
||||
return { success: data };
|
||||
}
|
||||
|
||||
// ── Export preview ────────────────────────────────────────────────────────────
|
||||
|
||||
export type ExportContactsResult = {
|
||||
success: true;
|
||||
csv: string;
|
||||
filename: string;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
function escapeCSVValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const str = String(value);
|
||||
if (str.includes('"') || str.includes(",") || str.includes("\n") || str.includes("\r")) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export async function exportContacts(params: {
|
||||
brandId: string;
|
||||
brandSlug?: string;
|
||||
search?: string;
|
||||
source?: string;
|
||||
}): Promise<ExportContactsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const effectiveBrandId =
|
||||
adminUser.role === "brand_admin" ? adminUser.brand_id! : params.brandId;
|
||||
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand context" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const allContacts: Contact[] = [];
|
||||
let offset = 0;
|
||||
const batchSize = 1000;
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_contacts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: effectiveBrandId,
|
||||
p_search: params.search ?? null,
|
||||
p_source: params.source ?? null,
|
||||
p_limit: batchSize,
|
||||
p_offset: offset,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch contacts" };
|
||||
const data = await response.json();
|
||||
const batch: Contact[] = data?.contacts ?? [];
|
||||
|
||||
if (batch.length === 0) break;
|
||||
allContacts.push(...batch);
|
||||
if (batch.length < batchSize) break;
|
||||
offset += batchSize;
|
||||
}
|
||||
|
||||
const headers = [
|
||||
"email",
|
||||
"phone",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"full_name",
|
||||
"source",
|
||||
"email_opt_in",
|
||||
"sms_opt_in",
|
||||
"unsubscribed_at",
|
||||
"tags",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"customer_id",
|
||||
"metadata.last_order_id",
|
||||
"metadata.last_order_at",
|
||||
"metadata.stop_id",
|
||||
"metadata.imported_raw",
|
||||
];
|
||||
|
||||
const rows = allContacts.map((c) => [
|
||||
escapeCSVValue(c.email),
|
||||
escapeCSVValue(c.phone),
|
||||
escapeCSVValue(c.first_name),
|
||||
escapeCSVValue(c.last_name),
|
||||
escapeCSVValue(c.full_name),
|
||||
escapeCSVValue(c.source),
|
||||
c.email_opt_in ? "TRUE" : "FALSE",
|
||||
c.sms_opt_in ? "TRUE" : "FALSE",
|
||||
escapeCSVValue(c.unsubscribed_at),
|
||||
escapeCSVValue(c.tags?.join(";")),
|
||||
escapeCSVValue(c.created_at),
|
||||
escapeCSVValue(c.updated_at),
|
||||
escapeCSVValue(c.customer_id),
|
||||
escapeCSVValue((c.metadata as Record<string, unknown>)?.last_order_id ?? ""),
|
||||
escapeCSVValue((c.metadata as Record<string, unknown>)?.last_order_at ?? ""),
|
||||
escapeCSVValue((c.metadata as Record<string, unknown>)?.stop_id ?? ""),
|
||||
escapeCSVValue((c.metadata as Record<string, unknown>)?.imported_raw ?? ""),
|
||||
]);
|
||||
|
||||
const brandSlug = params.brandSlug ?? "contacts";
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
const filename = `${brandSlug}-${dateStr}.csv`;
|
||||
|
||||
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\r\n");
|
||||
|
||||
return { success: true, csv, filename };
|
||||
}
|
||||
|
||||
// ── Import preview ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function previewContactImport(
|
||||
csvText: string
|
||||
): Promise<{ success: true; preview: ImportPreviewResult } | { success: false; error: string }> {
|
||||
try {
|
||||
const { csv, totalRows, warnings } = parseCSVWithLimits(csvText);
|
||||
|
||||
if (warnings.length > 0 && totalRows === 0) {
|
||||
return { success: false, error: warnings[0] };
|
||||
}
|
||||
|
||||
const preview = buildImportPreview(csv.headers, csv.rows);
|
||||
preview.warnings = warnings;
|
||||
|
||||
return { success: true, preview };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Parse error" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
export type Segment = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rules: AudienceRules;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ListSegmentsResult =
|
||||
| { success: true; segments: Segment[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type UpsertSegmentResult =
|
||||
| { success: true; segment: Segment }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function getCommunicationSegments(
|
||||
brandId: string
|
||||
): Promise<ListSegmentsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_segments`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch segments" };
|
||||
const data = await response.json();
|
||||
return { success: true, segments: data?.segments ?? [] };
|
||||
}
|
||||
|
||||
export async function upsertSegment(params: {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
rules: AudienceRules;
|
||||
}): Promise<UpsertSegmentResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: params.id ?? null,
|
||||
p_brand_id: params.brand_id,
|
||||
p_name: params.name,
|
||||
p_description: params.description ?? null,
|
||||
p_rules: params.rules,
|
||||
p_created_by: adminUser.user_id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to save segment" };
|
||||
const data = await response.json();
|
||||
return { success: true, segment: data };
|
||||
}
|
||||
|
||||
export async function deleteSegment(
|
||||
segmentId: string,
|
||||
brandId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_communication_segment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_segment_id: segmentId, p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete segment" };
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
export type AudiencePreviewResult = {
|
||||
count: number;
|
||||
sample_customers: { id: string; email: string; name: string }[];
|
||||
};
|
||||
|
||||
export async function previewCampaignAudience(
|
||||
brandId: string,
|
||||
audienceRules: AudienceRules
|
||||
): Promise<AudiencePreviewResult | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
// Brand scoping: brand_admin can only preview their own brand
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_audience_rules: audienceRules ?? {},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data as AudiencePreviewResult;
|
||||
}
|
||||
|
||||
export type MessageLogEntry = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
campaign_id: string | null;
|
||||
customer_id: string | null;
|
||||
customer_email: string | null;
|
||||
delivery_method: string;
|
||||
subject: string | null;
|
||||
body_preview: string | null;
|
||||
status: string;
|
||||
sent_at: string | null;
|
||||
error_message: string | null;
|
||||
event_type: string | null;
|
||||
event_id: string | null;
|
||||
created_at: string;
|
||||
// Analytics columns (populated by Resend webhook)
|
||||
delivered_at: string | null;
|
||||
opened_at: string | null;
|
||||
clicked_at: string | null;
|
||||
bounced_at: string | null;
|
||||
bounce_reason: string | null;
|
||||
};
|
||||
|
||||
export type GetMessageLogsResult = {
|
||||
success: true;
|
||||
logs: MessageLogEntry[];
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function getMessageLogs(params: {
|
||||
brandId: string;
|
||||
campaignId?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}): Promise<GetMessageLogsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Brand scoping: brand_admin can only view their own brand's logs
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
|
||||
return { success: false, error: "Not authorized to view these logs" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_message_logs`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_campaign_id: params.campaignId ?? null,
|
||||
p_status: params.status ?? null,
|
||||
p_limit: params.limit ?? 100,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch logs" };
|
||||
const data = await response.json();
|
||||
return { success: true, logs: data?.logs ?? [] };
|
||||
}
|
||||
|
||||
export type SendCampaignResult = {
|
||||
success: true;
|
||||
messages_logged: number;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function sendCampaign(campaignId: string, brandId?: string): Promise<SendCampaignResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Resolve brand from campaign or parameter
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id;
|
||||
|
||||
// Brand scoping: brand_admin can only send their own brand's campaigns
|
||||
if (adminUser.role === "brand_admin" && effectiveBrandId && adminUser.brand_id !== effectiveBrandId) {
|
||||
return { success: false, error: "Not authorized to send this brand's campaigns" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/send_campaign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: effectiveBrandId }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to send campaign" };
|
||||
}
|
||||
|
||||
return { success: true, messages_logged: data.messages_logged ?? 0 };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type CommunicationSettings = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
default_sender_email: string | null;
|
||||
default_sender_name: string | null;
|
||||
reply_to_email: string | null;
|
||||
email_provider: string;
|
||||
email_footer_html: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UpsertSettingsResult = {
|
||||
success: true;
|
||||
settings: CommunicationSettings;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data?.settings ?? null;
|
||||
}
|
||||
|
||||
export async function upsertCommunicationSettings(params: {
|
||||
brand_id: string;
|
||||
sender_email?: string;
|
||||
sender_name?: string;
|
||||
reply_to_email?: string;
|
||||
provider?: string;
|
||||
footer_html?: string;
|
||||
}): Promise<UpsertSettingsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Brand scoping: brand_admin can only modify their own brand's settings
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
|
||||
return { success: false, error: "Not authorized to modify this brand's communication settings" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_communication_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brand_id,
|
||||
p_sender_email: params.sender_email ?? null,
|
||||
p_sender_name: params.sender_name ?? null,
|
||||
p_reply_to_email: params.reply_to_email ?? null,
|
||||
p_provider: params.provider ?? "resend",
|
||||
p_footer_html: params.footer_html ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.id) {
|
||||
return { success: false, error: data?.message ?? "Failed to save settings" };
|
||||
}
|
||||
|
||||
return { success: true, settings: data };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type StopBlastResult =
|
||||
| { success: true; campaign_id: string; messages_logged: number }
|
||||
| { success: false; error: string };
|
||||
|
||||
export async function sendStopBlast(params: {
|
||||
stopId: string;
|
||||
brandId: string;
|
||||
channel: "sms" | "email" | "both";
|
||||
subject?: string;
|
||||
body: string;
|
||||
audience: "all" | "pending" | "picked_up";
|
||||
}): Promise<StopBlastResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/send_stop_blast`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_stop_id: params.stopId,
|
||||
p_brand_id: params.brandId,
|
||||
p_channel: params.channel,
|
||||
p_subject: params.subject ?? null,
|
||||
p_body: params.body,
|
||||
p_audience: params.audience,
|
||||
p_created_by: adminUser.user_id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
return { success: false, error: err?.message ?? "Failed to send blast" };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
campaign_id: data.campaign_id,
|
||||
messages_logged: data.messages_logged,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
export type TemplateType = "email_template" | "sms_template" | "internal_note";
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
|
||||
export type Template = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
body_text: string;
|
||||
body_html: string | null;
|
||||
template_type: TemplateType;
|
||||
campaign_type: CampaignType | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UpsertTemplateResult = {
|
||||
success: true;
|
||||
template: Template;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function getCommunicationTemplates(brandId?: string): Promise<{ success: true; templates: Template[] } | { success: false; error: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
|
||||
|
||||
// Brand scoping: brand_admin can only see their own brand's templates
|
||||
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
|
||||
return { success: true, templates: [] };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch templates" };
|
||||
const data = await response.json();
|
||||
return { success: true, templates: data?.templates ?? [] };
|
||||
}
|
||||
|
||||
export async function upsertTemplate(params: {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
body_text: string;
|
||||
body_html?: string;
|
||||
template_type: TemplateType;
|
||||
campaign_type?: CampaignType;
|
||||
}): Promise<UpsertTemplateResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_communication_template`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: params.id ?? null,
|
||||
p_brand_id: params.brand_id,
|
||||
p_name: params.name,
|
||||
p_subject: params.subject,
|
||||
p_body_text: params.body_text,
|
||||
p_body_html: params.body_html ?? null,
|
||||
p_template_type: params.template_type,
|
||||
p_campaign_type: params.campaign_type ?? null,
|
||||
p_created_by: adminUser.user_id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.id) {
|
||||
return { success: false, error: data?.message ?? "Failed to save template" };
|
||||
}
|
||||
|
||||
return { success: true, template: data };
|
||||
}
|
||||
|
||||
export async function getTemplateById(templateId: string): Promise<Template | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
const templates: Template[] = data?.templates ?? [];
|
||||
const template = templates.find((t) => t.id === templateId) ?? null;
|
||||
|
||||
// Brand scoping: brand_admin can only see their own brand's templates
|
||||
if (template && adminUser.role === "brand_admin" && template.brand_id !== adminUser.brand_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
Reference in New Issue
Block a user