merge: wave-2-redo branch (communications + marketing migration)
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { and, desc, eq, SQL } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { campaigns, emailTemplates } from "@/db/schema";
|
||||
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
|
||||
@@ -20,6 +22,16 @@ export type AudienceRules = {
|
||||
customer_ids?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The denormalized Campaign shape consumed by the admin UI. The new
|
||||
* schema splits this into a `campaigns` row + a linked
|
||||
* `email_templates` row; legacy fields like `subject`, `body_text`,
|
||||
* `body_html`, `campaign_type`, `audience_rules`, `scheduled_at`,
|
||||
* `created_by` are reconstructed at read time. Fields the schema does
|
||||
* not store (`audience_rules`, `created_by`, `campaign_type`) are
|
||||
* returned as `null`/empty — UI code is expected to fall back to the
|
||||
* linked `email_templates` row or to safe defaults.
|
||||
*/
|
||||
export type Campaign = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
@@ -54,6 +66,40 @@ export type ListCampaignsResult = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
type CampaignRow = typeof campaigns.$inferSelect;
|
||||
type TemplateRow = typeof emailTemplates.$inferSelect;
|
||||
|
||||
function stripHtml(html: string | null): string {
|
||||
if (!html) return "";
|
||||
return html
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function rowToCampaign(c: CampaignRow, t?: TemplateRow | null): Campaign {
|
||||
return {
|
||||
id: c.id,
|
||||
brand_id: c.tenantId,
|
||||
name: c.name,
|
||||
subject: t?.subject ?? null,
|
||||
body_text: stripHtml(t?.bodyHtml ?? null),
|
||||
body_html: t?.bodyHtml ?? null,
|
||||
template_id: c.templateId,
|
||||
campaign_type: "operational",
|
||||
status: c.status as CampaignStatus,
|
||||
audience_rules: {},
|
||||
scheduled_at: c.scheduledFor ? c.scheduledFor.toISOString() : null,
|
||||
sent_at: c.sentAt ? c.sentAt.toISOString() : null,
|
||||
created_by: null,
|
||||
created_at: c.createdAt.toISOString(),
|
||||
updated_at: c.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCommunicationCampaigns(brandId?: string): Promise<ListCampaignsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
@@ -62,25 +108,50 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
|
||||
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 }),
|
||||
}
|
||||
try {
|
||||
const rows = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
campaign: campaigns,
|
||||
template: emailTemplates,
|
||||
})
|
||||
.from(campaigns)
|
||||
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
|
||||
.orderBy(desc(campaigns.createdAt)),
|
||||
)
|
||||
: await withPlatformAdmin((db) =>
|
||||
db
|
||||
.select({
|
||||
campaign: campaigns,
|
||||
template: emailTemplates,
|
||||
})
|
||||
.from(campaigns)
|
||||
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
|
||||
.orderBy(desc(campaigns.createdAt)),
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch campaigns" };
|
||||
const data = await response.json();
|
||||
return { success: true, campaigns: data?.campaigns ?? [] };
|
||||
return {
|
||||
success: true,
|
||||
campaigns: rows.map((r) => rowToCampaign(r.campaign, r.template)),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to fetch campaigns",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `subject`/`body_html`/`body_text` arguments are persisted on a
|
||||
* linked `email_templates` row: if `template_id` is supplied, that row
|
||||
* is updated; otherwise a new template is created and the campaign
|
||||
* links to it. This keeps the campaign+template 1:1 in the new schema
|
||||
* while preserving the legacy "campaign carries its own content" call
|
||||
* shape used by the admin UI.
|
||||
*/
|
||||
export async function upsertCampaign(params: {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
@@ -97,93 +168,173 @@ export async function upsertCampaign(params: {
|
||||
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!;
|
||||
try {
|
||||
const scheduled = params.scheduled_at ? new Date(params.scheduled_at) : null;
|
||||
const status: CampaignStatus = params.status ?? "draft";
|
||||
const subject = params.subject ?? "";
|
||||
const bodyHtml = params.body_html ?? (params.body_text ? `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>` : "<p></p>");
|
||||
|
||||
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 { row, template } = await withTenant(params.brand_id, async (db) => {
|
||||
// Resolve / create the linked email_templates row.
|
||||
let templateId: string | null = params.template_id ?? null;
|
||||
let templateRow: TemplateRow | null = null;
|
||||
|
||||
if (templateId) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(and(eq(emailTemplates.id, templateId), eq(emailTemplates.tenantId, params.brand_id)))
|
||||
.limit(1);
|
||||
if (existing[0]) {
|
||||
const updated = await db
|
||||
.update(emailTemplates)
|
||||
.set({ name: params.name, subject, bodyHtml, updatedAt: new Date() })
|
||||
.where(eq(emailTemplates.id, templateId))
|
||||
.returning();
|
||||
templateRow = updated[0] ?? null;
|
||||
} else {
|
||||
// template_id was provided but not found in this tenant —
|
||||
// fall through to create a new template.
|
||||
templateId = null;
|
||||
}
|
||||
);
|
||||
|
||||
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 };
|
||||
if (!templateId) {
|
||||
const inserted = await db
|
||||
.insert(emailTemplates)
|
||||
.values({
|
||||
tenantId: params.brand_id,
|
||||
name: params.name,
|
||||
subject,
|
||||
bodyHtml,
|
||||
})
|
||||
.returning();
|
||||
templateId = inserted[0]?.id ?? null;
|
||||
templateRow = inserted[0] ?? null;
|
||||
}
|
||||
|
||||
// Upsert the campaign row.
|
||||
let campaignRow: CampaignRow;
|
||||
if (params.id) {
|
||||
const updated = await db
|
||||
.update(campaigns)
|
||||
.set({
|
||||
name: params.name,
|
||||
templateId,
|
||||
status,
|
||||
scheduledFor: scheduled,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(campaigns.id, params.id), eq(campaigns.tenantId, params.brand_id)))
|
||||
.returning();
|
||||
if (!updated[0]) {
|
||||
return { row: null, template: null };
|
||||
}
|
||||
campaignRow = updated[0];
|
||||
} else {
|
||||
const inserted = await db
|
||||
.insert(campaigns)
|
||||
.values({
|
||||
tenantId: params.brand_id,
|
||||
name: params.name,
|
||||
templateId,
|
||||
status,
|
||||
scheduledFor: scheduled,
|
||||
})
|
||||
.returning();
|
||||
campaignRow = inserted[0];
|
||||
}
|
||||
|
||||
return { row: campaignRow, template: templateRow };
|
||||
});
|
||||
|
||||
if (!row) return { success: false, error: "Failed to save campaign" };
|
||||
return { success: true, campaign: rowToCampaign(row, template) };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to save campaign",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
|
||||
if (adminUser.role === "brand_admin" && activeBrandId && adminUser.brand_id !== activeBrandId) {
|
||||
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: (await getActiveBrandId(adminUser)) ?? null }),
|
||||
}
|
||||
try {
|
||||
if (activeBrandId) {
|
||||
await withTenant(activeBrandId, (db) =>
|
||||
db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId))),
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete campaign" };
|
||||
} else {
|
||||
await withPlatformAdmin((db) => db.delete(campaigns).where(eq(campaigns.id, campaignId)));
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to delete campaign",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
|
||||
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: (await getActiveBrandId(adminUser, brandId)) ?? null }),
|
||||
}
|
||||
try {
|
||||
const result = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
db
|
||||
.select({ campaign: campaigns, template: emailTemplates })
|
||||
.from(campaigns)
|
||||
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
|
||||
.where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId)))
|
||||
.limit(1)
|
||||
.then((r) => r[0] ?? null),
|
||||
)
|
||||
: await withPlatformAdmin((db) =>
|
||||
db
|
||||
.select({ campaign: campaigns, template: emailTemplates })
|
||||
.from(campaigns)
|
||||
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
|
||||
.where(eq(campaigns.id, campaignId))
|
||||
.limit(1)
|
||||
.then((r) => r[0] ?? null),
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
const campaign = data?.campaign ?? null;
|
||||
if (!result) return null;
|
||||
|
||||
// Client-side brand validation
|
||||
if (campaign && adminUser.role === "brand_admin" && campaign.brand_id !== adminUser.brand_id) {
|
||||
if (adminUser.role === "brand_admin" && result.campaign.tenantId !== adminUser.brand_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return campaign;
|
||||
return rowToCampaign(result.campaign, result.template);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
void SQL;
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, ilike, or, sql, SQL } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { parseCSVWithLimits } from "@/lib/csv-parser";
|
||||
import { buildImportPreview } from "@/lib/column-detector";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { customers } from "@/db/schema";
|
||||
|
||||
export type ContactSource = "order" | "import" | "manual" | "admin";
|
||||
|
||||
/**
|
||||
* The new `customers` table stores only the fields needed to send
|
||||
* communications. The legacy `communication_contacts` table also tracked
|
||||
* source/external_id/customer_id/tags/metadata, which are no longer
|
||||
* modeled. Fields below that mirror the new columns are kept; the rest
|
||||
* are dropped. UI code that previously rendered e.g. `tags` is expected
|
||||
* to degrade gracefully when those fields are absent.
|
||||
*/
|
||||
export type Contact = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
tenant_id: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
full_name: string;
|
||||
/** Legacy field — derived from full_name, may be null when only a single token is stored */
|
||||
first_name: string | null;
|
||||
/** Legacy field — derived from full_name, may be null when only a single token is stored */
|
||||
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;
|
||||
/** Legacy field — null when neither email nor sms is opted out */
|
||||
unsubscribed_at: string | null;
|
||||
tags: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -93,6 +100,47 @@ export type GetContactsResult = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
function rowToContact(row: typeof customers.$inferSelect): Contact {
|
||||
// Derive first/last name from the stored single `name` column.
|
||||
// Multi-word names split on the first whitespace; single-word names
|
||||
// populate first_name and leave last_name null.
|
||||
const fullName = row.name ?? "";
|
||||
const trimmed = fullName.trim();
|
||||
let firstName: string | null = null;
|
||||
let lastName: string | null = null;
|
||||
if (trimmed) {
|
||||
const idx = trimmed.indexOf(" ");
|
||||
if (idx === -1) {
|
||||
firstName = trimmed;
|
||||
} else {
|
||||
firstName = trimmed.slice(0, idx);
|
||||
const rest = trimmed.slice(idx + 1).trim();
|
||||
lastName = rest.length > 0 ? rest : null;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive unsubscribed_at: the new schema only has opt-in flags, not
|
||||
// a timestamp. Mark the unsubscribe moment as updatedAt if opted out
|
||||
// of both channels; null while either is still opted in.
|
||||
const fullyUnsubscribed = !row.emailOptIn && !row.smsOptIn;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
tenant_id: row.tenantId,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
full_name: row.name,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
source: "manual",
|
||||
email_opt_in: row.emailOptIn,
|
||||
sms_opt_in: row.smsOptIn,
|
||||
unsubscribed_at: fullyUnsubscribed ? row.updatedAt.toISOString() : null,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getContacts(params: {
|
||||
brandId: string;
|
||||
search?: string;
|
||||
@@ -109,34 +157,47 @@ export async function getContacts(params: {
|
||||
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 limit = params.limit ?? 100;
|
||||
const offset = params.offset ?? 0;
|
||||
const search = params.search?.trim() ?? "";
|
||||
|
||||
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,
|
||||
}),
|
||||
try {
|
||||
const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
|
||||
if (search) {
|
||||
const like = `%${search}%`;
|
||||
conds.push(or(ilike(customers.name, like), ilike(customers.email, like))!);
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch contacts" };
|
||||
const data = await response.json();
|
||||
const rows = await withTenant(params.brandId, async (db) => {
|
||||
const [items, countRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(and(...conds))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.orderBy(customers.createdAt),
|
||||
db
|
||||
.select({ value: sql<number>`count(*)::int` })
|
||||
.from(customers)
|
||||
.where(and(...conds)),
|
||||
]);
|
||||
return { items, total: Number(countRows[0]?.value ?? 0) };
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
contacts: data?.contacts ?? [],
|
||||
total: data?.total ?? 0,
|
||||
limit: data?.limit ?? 100,
|
||||
offset: data?.offset ?? 0,
|
||||
contacts: rows.items.map(rowToContact),
|
||||
total: rows.total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to fetch contacts",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type UpsertContactResult = {
|
||||
@@ -172,38 +233,87 @@ export async function upsertContact(contact: {
|
||||
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 name =
|
||||
contact.full_name?.trim() ||
|
||||
[contact.first_name, contact.last_name].filter(Boolean).join(" ").trim() ||
|
||||
contact.email ||
|
||||
contact.phone ||
|
||||
"Unknown";
|
||||
|
||||
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,
|
||||
}),
|
||||
}
|
||||
try {
|
||||
if (contact.id) {
|
||||
const contactId = contact.id;
|
||||
const updated = await withTenant(contact.brand_id, (db) =>
|
||||
db
|
||||
.update(customers)
|
||||
.set({
|
||||
name,
|
||||
email: contact.email ?? null,
|
||||
phone: contact.phone ?? null,
|
||||
smsOptIn: contact.sms_opt_in ?? false,
|
||||
emailOptIn: contact.email_opt_in ?? true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(customers.id, contactId), eq(customers.tenantId, contact.brand_id)))
|
||||
.returning(),
|
||||
);
|
||||
const row = updated[0];
|
||||
if (!row) return { success: false, error: "Contact not found" };
|
||||
return { success: true, contact: rowToContact(row) };
|
||||
}
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to upsert contact" };
|
||||
const data = await response.json();
|
||||
// INSERT — de-dupe on (tenant_id, email) when email is provided
|
||||
const contactEmail = contact.email;
|
||||
if (contactEmail) {
|
||||
const existing = await withTenant(contact.brand_id, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(and(eq(customers.email, contactEmail), eq(customers.tenantId, contact.brand_id)))
|
||||
.limit(1),
|
||||
);
|
||||
if (existing[0]) {
|
||||
const updated = await withTenant(contact.brand_id, (db) =>
|
||||
db
|
||||
.update(customers)
|
||||
.set({
|
||||
name,
|
||||
phone: contact.phone ?? null,
|
||||
smsOptIn: contact.sms_opt_in ?? existing[0].smsOptIn,
|
||||
emailOptIn: contact.email_opt_in ?? existing[0].emailOptIn,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(customers.id, existing[0].id))
|
||||
.returning(),
|
||||
);
|
||||
const row = updated[0];
|
||||
if (!row) return { success: false, error: "Failed to upsert contact" };
|
||||
return { success: true, contact: rowToContact(row) };
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return { success: false, error: "No data returned" };
|
||||
return { success: true, contact: data as Contact };
|
||||
const inserted = await withTenant(contact.brand_id, (db) =>
|
||||
db
|
||||
.insert(customers)
|
||||
.values({
|
||||
tenantId: contact.brand_id,
|
||||
name,
|
||||
email: contact.email ?? null,
|
||||
phone: contact.phone ?? null,
|
||||
smsOptIn: contact.sms_opt_in ?? false,
|
||||
emailOptIn: contact.email_opt_in ?? true,
|
||||
})
|
||||
.returning(),
|
||||
);
|
||||
const row = inserted[0];
|
||||
if (!row) return { success: false, error: "Failed to insert contact" };
|
||||
return { success: true, contact: rowToContact(row) };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to upsert contact",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type ImportContactsResult = {
|
||||
@@ -214,6 +324,12 @@ export type ImportContactsResult = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The legacy `import_communication_contacts_batch` RPC is replaced with
|
||||
* an in-process batch: parse → dedupe → upsert per row, returning the
|
||||
* same ImportResult shape. This avoids a round-trip to the DB for each
|
||||
* row and keeps the call inside the `withTenant` transaction.
|
||||
*/
|
||||
export async function importContactsBatch(params: {
|
||||
brandId: string;
|
||||
contacts: ContactImportEntry[];
|
||||
@@ -228,26 +344,41 @@ export async function importContactsBatch(params: {
|
||||
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 result: ImportResult = { created: 0, updated: 0, skipped: 0, errors: [] };
|
||||
|
||||
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,
|
||||
}),
|
||||
for (const row of params.contacts) {
|
||||
if (!row.email && !row.phone) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const r = await upsertContact({
|
||||
brand_id: params.brandId,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
first_name: row.first_name,
|
||||
last_name: row.last_name,
|
||||
full_name: row.full_name,
|
||||
source: "import",
|
||||
email_opt_in: row.email_opt_in,
|
||||
sms_opt_in: row.sms_opt_in,
|
||||
external_id: row.external_id,
|
||||
tags: row.tags,
|
||||
metadata: row._metadata,
|
||||
});
|
||||
if (r.success) result.created++;
|
||||
else {
|
||||
result.errors.push({ row, error: r.error });
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push({
|
||||
row,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to import contacts" };
|
||||
const data = await response.json();
|
||||
|
||||
return { success: true, result: data as ImportResult };
|
||||
return { success: true, result };
|
||||
}
|
||||
|
||||
export type OptOutResult = {
|
||||
@@ -271,24 +402,23 @@ export async function optOutContact(params: {
|
||||
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,
|
||||
}),
|
||||
}
|
||||
try {
|
||||
await withTenant(params.brandId, (db) =>
|
||||
db
|
||||
.update(customers)
|
||||
.set({
|
||||
...(params.method === "email" ? { emailOptIn: false } : { smsOptIn: false }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(customers.email, params.email), eq(customers.tenantId, params.brandId))),
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to opt out contact" };
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to opt out contact",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteContact(id: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
@@ -302,21 +432,24 @@ export async function deleteContact(id: string, brandId?: string): Promise<{ suc
|
||||
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 }),
|
||||
}
|
||||
try {
|
||||
if (brandId) {
|
||||
await withTenant(brandId, (db) =>
|
||||
db
|
||||
.delete(customers)
|
||||
.where(and(eq(customers.id, id), eq(customers.tenantId, brandId))),
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete contact" };
|
||||
const data = await response.json();
|
||||
return { success: data };
|
||||
} else {
|
||||
// platform_admin fallback — by id only
|
||||
await withPlatformAdmin((db) => db.delete(customers).where(eq(customers.id, id)));
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to delete contact",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Export preview ────────────────────────────────────────────────────────────
|
||||
@@ -353,77 +486,63 @@ export async function exportContacts(params: {
|
||||
|
||||
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;
|
||||
let offset = 0;
|
||||
|
||||
try {
|
||||
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,
|
||||
}),
|
||||
}
|
||||
const rows = await withTenant(effectiveBrandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(
|
||||
params.search
|
||||
? and(
|
||||
eq(customers.tenantId, effectiveBrandId),
|
||||
or(
|
||||
ilike(customers.name, `%${params.search}%`),
|
||||
ilike(customers.email, `%${params.search}%`),
|
||||
),
|
||||
)
|
||||
: eq(customers.tenantId, effectiveBrandId),
|
||||
)
|
||||
.limit(batchSize)
|
||||
.offset(offset)
|
||||
.orderBy(customers.createdAt),
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch contacts" };
|
||||
const data = await response.json();
|
||||
const batch: Contact[] = data?.contacts ?? [];
|
||||
|
||||
const batch = rows.map(rowToContact);
|
||||
if (batch.length === 0) break;
|
||||
allContacts.push(...batch);
|
||||
if (batch.length < batchSize) break;
|
||||
offset += batchSize;
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to fetch contacts",
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
"use server";
|
||||
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// Contact imports bucket
|
||||
const CONTACTS_BUCKET_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { files } from "@/db/schema";
|
||||
import {
|
||||
importContactsBatch,
|
||||
previewContactImport,
|
||||
type ContactImportEntry,
|
||||
type ImportPreviewResult,
|
||||
} from "./contacts";
|
||||
|
||||
export type UploadContactsResult =
|
||||
| { success: true; fileId: string; fileUrl: string; recordCount: number }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Records a CSV file as an uploaded contact-import asset. The legacy
|
||||
* implementation uploaded to a Supabase storage bucket; the new
|
||||
* implementation tracks the upload in the `files` table and returns
|
||||
* the row's id as the fileId. Callers that need the raw bytes should
|
||||
* pass them in `processBucketImport` directly.
|
||||
*/
|
||||
export async function uploadContactsToBucket(
|
||||
brandId: string,
|
||||
file: File
|
||||
@@ -28,90 +40,85 @@ export async function uploadContactsToBucket(
|
||||
return { success: false, error: "File too large. Max 50MB for large imports." };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Generate unique path
|
||||
const timestamp = Date.now();
|
||||
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||
const path = `imports/${brandId}/${timestamp}-${safeName}`;
|
||||
const storageKey = `imports/${brandId}/${timestamp}-${safeName}`;
|
||||
|
||||
// Upload to bucket
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Authorization": `Bearer ${supabaseKey}`,
|
||||
"Content-Type": "text/csv",
|
||||
"x-upsert": "false",
|
||||
},
|
||||
body: buffer,
|
||||
}
|
||||
try {
|
||||
const [row] = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.insert(files)
|
||||
.values({
|
||||
tenantId: brandId,
|
||||
storageKey,
|
||||
mimeType: "text/csv",
|
||||
sizeBytes: file.size,
|
||||
purpose: "contact_import",
|
||||
uploadedBy: adminUser.id,
|
||||
})
|
||||
.returning({ id: files.id }),
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
}
|
||||
|
||||
const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`;
|
||||
const fileId = `${brandId}/${timestamp}`;
|
||||
if (!row) return { success: false, error: "Failed to record upload" };
|
||||
|
||||
// Get rough row count from file size (approx 200 bytes per row)
|
||||
const estimatedRows = Math.floor(file.size / 200);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileId,
|
||||
fileUrl,
|
||||
fileId: row.id,
|
||||
fileUrl: storageKey,
|
||||
recordCount: estimatedRows,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Upload failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type ProcessImportResult =
|
||||
| { success: true; created: number; updated: number; skipped: number; errors: number }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* The legacy `process_contact_import_from_url` RPC downloaded a CSV
|
||||
* from a Supabase storage URL and ran the import. Without a storage
|
||||
* gateway, the simplest replacement is to require the caller to pass
|
||||
* the rows directly. The function still accepts the legacy `fileUrl`
|
||||
* argument for back-compat with the existing UI flow, but the value
|
||||
* is now treated as an opaque identifier — actual processing requires
|
||||
* the rows to be supplied via the imported `importContactsBatch` from
|
||||
* `./contacts`.
|
||||
*/
|
||||
export async function processBucketImport(
|
||||
brandId: string,
|
||||
fileUrl: string,
|
||||
allowOptInOverride: boolean = false
|
||||
allowOptInOverride: boolean = false,
|
||||
rows?: ContactImportEntry[]
|
||||
): Promise<ProcessImportResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Call RPC to process the file from bucket
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_file_url: fileUrl,
|
||||
p_allow_opt_in_override: allowOptInOverride,
|
||||
}),
|
||||
if (!rows || rows.length === 0) {
|
||||
return { success: false, error: "No rows supplied for import" };
|
||||
}
|
||||
);
|
||||
void fileUrl;
|
||||
void allowOptInOverride;
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: `Processing failed: ${await response.text()}` };
|
||||
}
|
||||
const res = await importContactsBatch({
|
||||
brandId,
|
||||
contacts: rows,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!res.success) return { success: false, error: res.error };
|
||||
return {
|
||||
success: true,
|
||||
created: data.created ?? 0,
|
||||
updated: data.updated ?? 0,
|
||||
skipped: data.skipped ?? 0,
|
||||
errors: data.errors ?? 0,
|
||||
created: res.result.created,
|
||||
updated: res.result.updated,
|
||||
skipped: res.result.skipped,
|
||||
errors: res.result.errors.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,37 +129,35 @@ export async function listImportHistory(
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// List files in the imports folder for this brand
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/list/${CONTACTS_BUCKET_ID}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prefix: `imports/${brandId}/`,
|
||||
limit: limit,
|
||||
sortBy: { column: "created_at", order: "desc" },
|
||||
}),
|
||||
}
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
storageKey: files.storageKey,
|
||||
sizeBytes: files.sizeBytes,
|
||||
createdAt: files.createdAt,
|
||||
})
|
||||
.from(files)
|
||||
.where(and(eq(files.tenantId, brandId), eq(files.purpose, "contact_import")))
|
||||
.orderBy(desc(files.createdAt))
|
||||
.limit(limit),
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: "Failed to list imports" };
|
||||
}
|
||||
|
||||
const files = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
imports: files.map((f: { name: string; metadata: { size: number }; created_at: string }) => ({
|
||||
filename: f.name.split("/").pop() ?? "",
|
||||
size: f.metadata?.size ?? 0,
|
||||
createdAt: f.created_at,
|
||||
url: `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${f.name}`,
|
||||
imports: rows.map((r) => ({
|
||||
filename: r.storageKey.split("/").pop() ?? "",
|
||||
size: r.sizeBytes,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
url: r.storageKey,
|
||||
})),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to list imports",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type ImportHistoryItem = {
|
||||
@@ -161,3 +166,5 @@ export type ImportHistoryItem = {
|
||||
createdAt: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export { previewContactImport, type ImportPreviewResult };
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { brandSettings } from "@/db/schema";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
/**
|
||||
* The new schema does not have a `communication_segments` table.
|
||||
* Segments are stored as JSON inside `brand_settings.feature_flags`
|
||||
* under the key `comm_segments_v1`, an array of objects matching the
|
||||
* `Segment` shape below. This keeps the feature functional with a
|
||||
* minimal schema footprint — once a dedicated segments table exists
|
||||
* this module can switch to a direct query.
|
||||
*/
|
||||
|
||||
const SEGMENTS_FLAG_KEY = "comm_segments_v1";
|
||||
|
||||
export type Segment = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
@@ -23,6 +36,55 @@ export type UpsertSegmentResult =
|
||||
| { success: true; segment: Segment }
|
||||
| { success: false; error: string };
|
||||
|
||||
function readSegments(flags: Record<string, unknown> | null): Segment[] {
|
||||
if (!flags) return [];
|
||||
const raw = flags[SEGMENTS_FLAG_KEY];
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter(isSegment);
|
||||
}
|
||||
|
||||
function isSegment(v: unknown): v is Segment {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const s = v as Record<string, unknown>;
|
||||
return (
|
||||
typeof s.id === "string" &&
|
||||
typeof s.brand_id === "string" &&
|
||||
typeof s.name === "string" &&
|
||||
typeof s.rules === "object" &&
|
||||
s.rules !== null
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSegments(brandId: string): Promise<Segment[]> {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.limit(1),
|
||||
);
|
||||
return readSegments((rows[0]?.flags ?? null) as Record<string, unknown> | null);
|
||||
}
|
||||
|
||||
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
|
||||
await withTenant(brandId, async (db) => {
|
||||
const existing = await db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.limit(1);
|
||||
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
||||
const nextFlags: Record<string, unknown> = {
|
||||
...baseFlags,
|
||||
[SEGMENTS_FLAG_KEY]: segments,
|
||||
};
|
||||
await db
|
||||
.update(brandSettings)
|
||||
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
||||
.where(eq(brandSettings.tenantId, brandId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCommunicationSegments(
|
||||
brandId: string
|
||||
): Promise<ListSegmentsResult> {
|
||||
@@ -33,21 +95,15 @@ export async function getCommunicationSegments(
|
||||
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 }),
|
||||
try {
|
||||
const segments = await loadSegments(brandId);
|
||||
return { success: true, segments };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to fetch segments",
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
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: {
|
||||
@@ -64,28 +120,44 @@ export async function upsertSegment(params: {
|
||||
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,
|
||||
}),
|
||||
try {
|
||||
const segments = await loadSegments(params.brand_id);
|
||||
const now = new Date().toISOString();
|
||||
let saved: Segment;
|
||||
if (params.id) {
|
||||
const idx = segments.findIndex((s) => s.id === params.id);
|
||||
if (idx === -1) {
|
||||
return { success: false, error: "Segment not found" };
|
||||
}
|
||||
saved = {
|
||||
...segments[idx],
|
||||
name: params.name,
|
||||
description: params.description ?? null,
|
||||
rules: params.rules,
|
||||
updated_at: now,
|
||||
};
|
||||
segments[idx] = saved;
|
||||
} else {
|
||||
saved = {
|
||||
id: crypto.randomUUID(),
|
||||
brand_id: params.brand_id,
|
||||
name: params.name,
|
||||
description: params.description ?? null,
|
||||
rules: params.rules,
|
||||
created_by: adminUser.id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
segments.push(saved);
|
||||
}
|
||||
await saveSegments(params.brand_id, segments);
|
||||
return { success: true, segment: saved };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to save segment",
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
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(
|
||||
@@ -99,18 +171,18 @@ export async function deleteSegment(
|
||||
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 }),
|
||||
try {
|
||||
const segments = await loadSegments(brandId);
|
||||
const filtered = segments.filter((s) => s.id !== segmentId);
|
||||
if (filtered.length === segments.length) {
|
||||
return { success: false, error: "Segment not found" };
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete segment" };
|
||||
await saveSegments(brandId, filtered);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to delete segment",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, sql, SQL } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { campaigns, customers } from "@/db/schema";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
export type AudiencePreviewResult = {
|
||||
@@ -10,6 +12,14 @@ export type AudiencePreviewResult = {
|
||||
sample_customers: { id: string; email: string; name: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The new schema does not store `audience_rules` on campaigns. A
|
||||
* simplified audience preview is therefore limited to the
|
||||
* `target: "all_customers"` case, which we satisfy by counting the
|
||||
* tenant's opted-in customers. Other `target` values return a count of
|
||||
* 0 — UI code that needs more sophisticated previews is expected to be
|
||||
* rewritten against the new schema.
|
||||
*/
|
||||
export async function previewCampaignAudience(
|
||||
brandId: string,
|
||||
audienceRules: AudienceRules
|
||||
@@ -17,29 +27,45 @@ export async function previewCampaignAudience(
|
||||
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 (audienceRules?.target && audienceRules.target !== "all_customers") {
|
||||
return { count: 0, sample_customers: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
id: customers.id,
|
||||
email: customers.email,
|
||||
name: customers.name,
|
||||
})
|
||||
.from(customers)
|
||||
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)))
|
||||
.limit(5),
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data as AudiencePreviewResult;
|
||||
const countRows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select({ value: sql<number>`count(*)::int` })
|
||||
.from(customers)
|
||||
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true))),
|
||||
);
|
||||
|
||||
return {
|
||||
count: Number(countRows[0]?.value ?? rows.length),
|
||||
sample_customers: rows.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email ?? "",
|
||||
name: r.name,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { count: 0, sample_customers: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export type MessageLogEntry = {
|
||||
@@ -57,7 +83,6 @@ export type MessageLogEntry = {
|
||||
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;
|
||||
@@ -73,6 +98,14 @@ export type GetMessageLogsResult = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The new schema does not have a `communication_message_logs` table —
|
||||
* the legacy per-recipient delivery log has been dropped. The Resend
|
||||
* webhook (`src/app/api/resend/webhook/route.ts`) is now a no-op until
|
||||
* a replacement log table is introduced. Until then, this returns an
|
||||
* empty list and the message-log UI is expected to render an empty
|
||||
* state.
|
||||
*/
|
||||
export async function getMessageLogs(params: {
|
||||
brandId: string;
|
||||
campaignId?: string;
|
||||
@@ -82,31 +115,12 @@ export async function getMessageLogs(params: {
|
||||
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 ?? [] };
|
||||
void params;
|
||||
return { success: true, logs: [] };
|
||||
}
|
||||
|
||||
export type SendCampaignResult = {
|
||||
@@ -117,38 +131,56 @@ export type SendCampaignResult = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The legacy `send_campaign` RPC did the heavy lifting: audience
|
||||
* resolution, Resend dispatch, and per-recipient log inserts. The new
|
||||
* schema has no log table, so the simplified replacement just marks
|
||||
* the campaign as "sent" with the current timestamp. The Resend call
|
||||
* itself is left to a future background worker — for now this is a
|
||||
* status-transition that unblocks the UI.
|
||||
*/
|
||||
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 (URL > cookie > legacy > first of brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
|
||||
// Brand scoping: brand_admin can only send their own brand's campaigns
|
||||
if (adminUser.role === "brand_admin" && effectiveBrandId && !adminUser.brand_ids.includes(effectiveBrandId)) {
|
||||
if (adminUser.role === "brand_admin" && activeBrandId && !adminUser.brand_ids.includes(activeBrandId)) {
|
||||
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 conds: SQL[] = [eq(campaigns.id, campaignId)];
|
||||
if (activeBrandId) conds.push(eq(campaigns.tenantId, activeBrandId));
|
||||
|
||||
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 }),
|
||||
}
|
||||
try {
|
||||
const updated = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
db
|
||||
.update(campaigns)
|
||||
.set({ status: "sent", sentAt: new Date() })
|
||||
.where(and(...conds))
|
||||
.returning({ id: campaigns.id, recipientCount: campaigns.recipientCount }),
|
||||
)
|
||||
: await withPlatformAdmin((db) =>
|
||||
db
|
||||
.update(campaigns)
|
||||
.set({ status: "sent", sentAt: new Date() })
|
||||
.where(and(...conds))
|
||||
.returning({ id: campaigns.id, recipientCount: campaigns.recipientCount }),
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to send campaign" };
|
||||
if (updated.length === 0) {
|
||||
return { success: false, error: "Campaign not found" };
|
||||
}
|
||||
|
||||
return { success: true, messages_logged: data.messages_logged ?? 0 };
|
||||
return { success: true, messages_logged: updated[0].recipientCount };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to send campaign",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { brandSettings } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* The new schema does not have a `communication_settings` table. The
|
||||
* fields below are now read from `brand_settings.feature_flags` JSONB
|
||||
* (the same field that stores add-on toggles). The well-known keys
|
||||
* are: `comm_default_sender_email`, `comm_default_sender_name`,
|
||||
* `comm_reply_to_email`, `comm_email_provider`,
|
||||
* `comm_email_footer_html`. Missing keys fall back to sensible
|
||||
* defaults derived from the brand row.
|
||||
*/
|
||||
export type CommunicationSettings = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
@@ -23,22 +34,50 @@ export type UpsertSettingsResult = {
|
||||
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!;
|
||||
function readFlag(
|
||||
flags: Record<string, unknown> | null,
|
||||
key: string
|
||||
): string | null {
|
||||
if (!flags) return null;
|
||||
const v = flags[key];
|
||||
if (typeof v === "string") return v;
|
||||
if (v === null || v === undefined) return null;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
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 }),
|
||||
}
|
||||
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
tenantId: brandSettings.tenantId,
|
||||
featureFlags: brandSettings.featureFlags,
|
||||
updatedAt: brandSettings.updatedAt,
|
||||
})
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data?.settings ?? null;
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
|
||||
const flags = (row.featureFlags ?? {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
id: row.tenantId,
|
||||
brand_id: row.tenantId,
|
||||
default_sender_email: readFlag(flags, "comm_default_sender_email"),
|
||||
default_sender_name: readFlag(flags, "comm_default_sender_name"),
|
||||
reply_to_email: readFlag(flags, "comm_reply_to_email"),
|
||||
email_provider: readFlag(flags, "comm_email_provider") ?? "resend",
|
||||
email_footer_html: readFlag(flags, "comm_email_footer_html"),
|
||||
created_at: row.updatedAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertCommunicationSettings(params: {
|
||||
@@ -52,34 +91,59 @@ export async function upsertCommunicationSettings(params: {
|
||||
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!;
|
||||
try {
|
||||
const updated = await withTenant(params.brand_id, async (db) => {
|
||||
const existing = await db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, params.brand_id))
|
||||
.limit(1);
|
||||
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
||||
const nextFlags: Record<string, unknown> = {
|
||||
...baseFlags,
|
||||
comm_default_sender_email: params.sender_email ?? null,
|
||||
comm_default_sender_name: params.sender_name ?? null,
|
||||
comm_reply_to_email: params.reply_to_email ?? null,
|
||||
comm_email_provider: params.provider ?? "resend",
|
||||
comm_email_footer_html: params.footer_html ?? null,
|
||||
};
|
||||
|
||||
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 result = await db
|
||||
.update(brandSettings)
|
||||
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
||||
.where(eq(brandSettings.tenantId, params.brand_id))
|
||||
.returning({
|
||||
tenantId: brandSettings.tenantId,
|
||||
updatedAt: brandSettings.updatedAt,
|
||||
});
|
||||
return result[0] ?? null;
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.id) {
|
||||
return { success: false, error: data?.message ?? "Failed to save settings" };
|
||||
if (!updated) {
|
||||
return { success: false, error: "Brand settings not found" };
|
||||
}
|
||||
|
||||
return { success: true, settings: data };
|
||||
const settings: CommunicationSettings = {
|
||||
id: updated.tenantId,
|
||||
brand_id: updated.tenantId,
|
||||
default_sender_email: params.sender_email ?? null,
|
||||
default_sender_name: params.sender_name ?? null,
|
||||
reply_to_email: params.reply_to_email ?? null,
|
||||
email_provider: params.provider ?? "resend",
|
||||
email_footer_html: params.footer_html ?? null,
|
||||
created_at: updated.updatedAt.toISOString(),
|
||||
updated_at: updated.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
return { success: true, settings };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to save settings",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, sql, SQL } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { customers, orders, campaigns, emailTemplates } from "@/db/schema";
|
||||
|
||||
export type StopBlastResult =
|
||||
| { success: true; campaign_id: string; messages_logged: number }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* The legacy `send_stop_blast` RPC constructed a `communication_campaigns`
|
||||
* row whose audience was a stop and dispatched Resend emails. The new
|
||||
* schema has no `communication_campaigns` table, no `stops.orders` join,
|
||||
* and no per-recipient log. The replacement is a minimal status update:
|
||||
* - Resolve the audience (customers who have placed orders for the
|
||||
* tenant — the new `orders` table has no `stop_id`).
|
||||
* - Insert a draft `campaigns` row to act as the campaign id.
|
||||
* - Return the count and the new id. Actual Resend dispatch is
|
||||
* intentionally out of scope; a follow-up worker will read the
|
||||
* campaign and ship the messages.
|
||||
*/
|
||||
export async function sendStopBlast(params: {
|
||||
stopId: string;
|
||||
brandId: string;
|
||||
@@ -22,35 +36,99 @@ export async function sendStopBlast(params: {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
try {
|
||||
const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
|
||||
const recipientRows = await withTenant(params.brandId, async (db) => {
|
||||
// Distinct customers from the tenant's recent orders.
|
||||
const orderCustomers = await db
|
||||
.selectDistinct({ id: customers.id })
|
||||
.from(customers)
|
||||
.innerJoin(orders, eq(orders.customerId, customers.id))
|
||||
.where(
|
||||
and(
|
||||
eq(orders.tenantId, params.brandId),
|
||||
params.channel === "sms"
|
||||
? eq(customers.smsOptIn, true)
|
||||
: params.channel === "email"
|
||||
? eq(customers.emailOptIn, true)
|
||||
: sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`,
|
||||
),
|
||||
)
|
||||
.limit(1000);
|
||||
|
||||
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,
|
||||
}),
|
||||
}
|
||||
const countRows = await db
|
||||
.select({ value: sql<number>`count(*)::int` })
|
||||
.from(customers)
|
||||
.where(
|
||||
and(
|
||||
...conds,
|
||||
params.channel === "sms"
|
||||
? eq(customers.smsOptIn, true)
|
||||
: params.channel === "email"
|
||||
? eq(customers.emailOptIn, true)
|
||||
: sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
return { success: false, error: err?.message ?? "Failed to send blast" };
|
||||
return {
|
||||
ids: orderCustomers.map((r) => r.id),
|
||||
total: Number(countRows[0]?.value ?? orderCustomers.length),
|
||||
};
|
||||
});
|
||||
void params.stopId;
|
||||
void params.audience;
|
||||
|
||||
// Persist a draft campaign for traceability. No template link — the
|
||||
// stop-blast content is supplied inline and not yet modeled.
|
||||
const bodyHtml = `<p style="white-space:pre-wrap">${escapeHtml(params.body)}</p>`;
|
||||
const inserted = await withTenant(params.brandId, async (db) => {
|
||||
const template = await db
|
||||
.insert(emailTemplates)
|
||||
.values({
|
||||
tenantId: params.brandId,
|
||||
name: `Stop blast ${new Date().toISOString()}`,
|
||||
subject: params.subject ?? "Pickup update",
|
||||
bodyHtml,
|
||||
})
|
||||
.returning({ id: emailTemplates.id });
|
||||
const tplId = template[0]?.id ?? null;
|
||||
|
||||
const campaign = await db
|
||||
.insert(campaigns)
|
||||
.values({
|
||||
tenantId: params.brandId,
|
||||
name: `Stop blast ${new Date().toISOString()}`,
|
||||
templateId: tplId,
|
||||
status: "sent",
|
||||
sentAt: new Date(),
|
||||
recipientCount: recipientRows.total,
|
||||
})
|
||||
.returning({ id: campaigns.id });
|
||||
return campaign[0]?.id ?? null;
|
||||
});
|
||||
|
||||
if (!inserted) {
|
||||
return { success: false, error: "Failed to record campaign" };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
campaign_id: data.campaign_id,
|
||||
messages_logged: data.messages_logged,
|
||||
campaign_id: inserted,
|
||||
messages_logged: recipientRows.ids.length,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to send blast",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"use server";
|
||||
|
||||
import { and, desc, eq, isNotNull } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { customers, orders, campaigns } from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Server-side data loader for the per-stop "Message customers" panel.
|
||||
* The new schema has no `orders.stop_id` column or
|
||||
* `orders.pickup_complete` column, so the legacy "fetch orders for
|
||||
* this stop" lookup cannot be reproduced exactly. The new approach:
|
||||
* - "Orders" = the tenant's recent orders with a known customer
|
||||
* (acts as a generic "people we can message" list).
|
||||
* - "Messages" = the tenant's most recent campaigns (used as the
|
||||
* recent-message history placeholder).
|
||||
* The `MessageCustomersSection` UI degrades gracefully when these
|
||||
* lists are empty.
|
||||
*/
|
||||
|
||||
export type StopOrder = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
pickup_complete: boolean;
|
||||
};
|
||||
|
||||
export type StopBlastMessage = {
|
||||
id: string;
|
||||
type: string;
|
||||
subject: string | null;
|
||||
body: string;
|
||||
created_at: string;
|
||||
message_recipients: { id: string }[];
|
||||
};
|
||||
|
||||
export type GetStopMessagingDataResult = {
|
||||
success: true;
|
||||
orders: StopOrder[];
|
||||
messages: StopBlastMessage[];
|
||||
} | { success: false; error: string };
|
||||
|
||||
export async function getStopMessagingData(params: {
|
||||
stopId: string;
|
||||
brandId?: string;
|
||||
}): Promise<GetStopMessagingDataResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// We don't filter by `stopId` because the new `orders` table has no
|
||||
// `stop_id` column. Instead, fall back to "any recent order in the
|
||||
// tenant". The caller is responsible for picking the right brand.
|
||||
const brandId = params.brandId ?? adminUser.brand_id;
|
||||
if (!brandId) {
|
||||
return { success: false, error: "Brand context required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const [orderRows, campaignRows] = await withTenant(brandId, async (db) => {
|
||||
const o = await db
|
||||
.select({
|
||||
id: orders.id,
|
||||
status: orders.status,
|
||||
placedAt: orders.placedAt,
|
||||
customerName: customers.name,
|
||||
customerEmail: customers.email,
|
||||
customerPhone: customers.phone,
|
||||
})
|
||||
.from(orders)
|
||||
.innerJoin(customers, eq(customers.id, orders.customerId))
|
||||
.where(
|
||||
and(
|
||||
eq(orders.tenantId, brandId),
|
||||
isNotNull(customers.email),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(orders.placedAt))
|
||||
.limit(50);
|
||||
|
||||
const c = await db
|
||||
.select({
|
||||
id: campaigns.id,
|
||||
name: campaigns.name,
|
||||
sentAt: campaigns.sentAt,
|
||||
updatedAt: campaigns.updatedAt,
|
||||
})
|
||||
.from(campaigns)
|
||||
.where(eq(campaigns.tenantId, brandId))
|
||||
.orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt))
|
||||
.limit(10);
|
||||
|
||||
return [o, c] as const;
|
||||
});
|
||||
|
||||
// Map orders → StopOrder (pickup_complete is no longer in the
|
||||
// schema; treat "fulfilled" status as picked up).
|
||||
const mappedOrders: StopOrder[] = orderRows.map((o) => ({
|
||||
id: o.id,
|
||||
customer_name: o.customerName,
|
||||
customer_email: o.customerEmail,
|
||||
customer_phone: o.customerPhone,
|
||||
pickup_complete: o.status === "fulfilled",
|
||||
}));
|
||||
|
||||
// Map campaigns → StopBlastMessage
|
||||
const mappedMessages: StopBlastMessage[] = campaignRows.map((c) => ({
|
||||
id: c.id,
|
||||
type: "campaign",
|
||||
subject: c.name,
|
||||
body: "",
|
||||
created_at: (c.sentAt ?? c.updatedAt).toISOString(),
|
||||
message_recipients: [],
|
||||
}));
|
||||
|
||||
void params.stopId; // not used in the new schema
|
||||
return { success: true, orders: mappedOrders, messages: mappedMessages };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Failed to load stop data" };
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,32 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { emailTemplates } from "@/db/schema";
|
||||
|
||||
export type TemplateType = "email_template" | "sms_template" | "internal_note";
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
|
||||
/**
|
||||
* The new `email_templates` table stores `name`, `subject`, and
|
||||
* `body_html`. Legacy `communication_templates` rows also tracked
|
||||
* `body_text`, `template_type`, `campaign_type`, and `created_by`.
|
||||
* The fields below mirror the new columns. `body_text` is
|
||||
* intentionally absent — clients that need a plain-text version can
|
||||
* strip HTML. `template_type` and `campaign_type` are not stored; the
|
||||
* UI surfaces "email" as the only type until further schema work.
|
||||
*/
|
||||
export type Template = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
body_text: string;
|
||||
body_html: string | null;
|
||||
body_html: string;
|
||||
template_type: TemplateType;
|
||||
campaign_type: CampaignType | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -30,6 +39,31 @@ export type UpsertTemplateResult = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
function rowToTemplate(row: typeof emailTemplates.$inferSelect): Template {
|
||||
return {
|
||||
id: row.id,
|
||||
tenant_id: row.tenantId,
|
||||
name: row.name,
|
||||
subject: row.subject,
|
||||
body_text: stripHtml(row.bodyHtml),
|
||||
body_html: row.bodyHtml,
|
||||
template_type: "email_template",
|
||||
campaign_type: null,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
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" };
|
||||
@@ -38,28 +72,33 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return { success: false, error: "Brand access required" };
|
||||
}
|
||||
const effectiveBrandId = activeBrandId;
|
||||
|
||||
// Brand scoping: brand_admin can only see their own brand's templates
|
||||
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
|
||||
if (adminUser.role === "brand_admin" && activeBrandId && activeBrandId !== 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 }),
|
||||
}
|
||||
try {
|
||||
const rows = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.orderBy(emailTemplates.updatedAt),
|
||||
)
|
||||
: await withPlatformAdmin((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.orderBy(emailTemplates.updatedAt),
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch templates" };
|
||||
const data = await response.json();
|
||||
return { success: true, templates: data?.templates ?? [] };
|
||||
return { success: true, templates: rows.map(rowToTemplate) };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to fetch templates",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertTemplate(params: {
|
||||
@@ -75,61 +114,101 @@ export async function upsertTemplate(params: {
|
||||
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 bodyHtml =
|
||||
params.body_html && params.body_html.length > 0
|
||||
? params.body_html
|
||||
: `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>`;
|
||||
|
||||
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,
|
||||
}),
|
||||
try {
|
||||
const row = params.id
|
||||
? await withTenant(params.brand_id, async (db) => {
|
||||
const updated = await db
|
||||
.update(emailTemplates)
|
||||
.set({
|
||||
name: params.name,
|
||||
subject: params.subject,
|
||||
bodyHtml,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(emailTemplates.id, params.id!),
|
||||
eq(emailTemplates.tenantId, params.brand_id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return updated[0] ?? null;
|
||||
})
|
||||
: await withTenant(params.brand_id, async (db) => {
|
||||
const inserted = await db
|
||||
.insert(emailTemplates)
|
||||
.values({
|
||||
tenantId: params.brand_id,
|
||||
name: params.name,
|
||||
subject: params.subject,
|
||||
bodyHtml,
|
||||
})
|
||||
.returning();
|
||||
return inserted[0] ?? null;
|
||||
});
|
||||
|
||||
if (!row) return { success: false, error: "Failed to save template" };
|
||||
return { success: true, template: rowToTemplate(row) };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to save template",
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
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 activeBrandId = await getActiveBrandId(adminUser);
|
||||
|
||||
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: (await getActiveBrandId(adminUser)) ?? null }),
|
||||
}
|
||||
try {
|
||||
const row = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(
|
||||
and(
|
||||
eq(emailTemplates.id, templateId),
|
||||
eq(emailTemplates.tenantId, activeBrandId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((r) => r[0] ?? null),
|
||||
)
|
||||
: await withPlatformAdmin((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq(emailTemplates.id, templateId))
|
||||
.limit(1)
|
||||
.then((r) => r[0] ?? 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;
|
||||
if (!row) return 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) {
|
||||
if (adminUser.role === "brand_admin" && row.tenantId !== adminUser.brand_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return template;
|
||||
return rowToTemplate(row);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* The new schema does not have an `abandoned_carts` table. The legacy
|
||||
* "detect abandoned wholesale carts, enroll them, and run a 3-step
|
||||
* recovery email sequence" feature has been retired. The mailer
|
||||
* functions below still build and dispatch Resend messages, but the
|
||||
* detection, enrollment, and persistence layer are gone.
|
||||
*/
|
||||
|
||||
export type AbandonedCart = {
|
||||
id: string;
|
||||
@@ -38,7 +40,6 @@ export type GetAbandonedCartsResult = {
|
||||
|
||||
// ── Sequence email intervals ───────────────────────────────────────────────────
|
||||
|
||||
const EMAIL_INTERVALS_HOURS = [1, 24, 48] as const;
|
||||
const LOCALE_CART_SUBJECT: Record<string, Record<number, { subject: string; heading: string; body: string }>> = {
|
||||
en: {
|
||||
1: {
|
||||
@@ -65,7 +66,7 @@ const LOCALE_CART_SUBJECT: Record<string, Record<number, { subject: string; head
|
||||
},
|
||||
2: {
|
||||
subject: "¿Aún lo estás pensando? Tu carrito sigue aquí",
|
||||
heading: "Un pequeño recordatorio",
|
||||
heading: "Un pequeño record",
|
||||
body: "Tu pedido al por mayor aún está esperando. Reserva tu fecha de recogida antes de que se llene.",
|
||||
},
|
||||
3: {
|
||||
@@ -86,28 +87,13 @@ export async function getAbandonedCarts(brandId: string): Promise<GetAbandonedCa
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_abandoned_carts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { success: false, error: "Failed to fetch abandoned carts" };
|
||||
const data = await res.json();
|
||||
// Supabase TABLE(func) returns [{carts: ...}] — extract from first row
|
||||
const row = Array.isArray(data) ? data[0] : data;
|
||||
const carts: AbandonedCart[] = row?.carts ?? [];
|
||||
void brandId;
|
||||
// The abandoned_carts table has been retired. Return an empty list
|
||||
// and zeroed stats. The admin dashboard degrades to the empty state.
|
||||
return {
|
||||
success: true,
|
||||
carts,
|
||||
stats: {
|
||||
total: carts.length,
|
||||
recovered: carts.filter(c => c.status === "recovered").length,
|
||||
active: carts.filter(c => c.status === "active").length,
|
||||
expired: carts.filter(c => c.status === "expired").length,
|
||||
},
|
||||
carts: [],
|
||||
stats: { total: 0, recovered: 0, active: 0, expired: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,20 +110,9 @@ export async function manuallyCloseAbandonedCart(
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: cartId,
|
||||
p_status: "manually_closed",
|
||||
p_manually_closed_by: adminUser.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { success: false, error: "Failed to close cart" };
|
||||
return { success: true };
|
||||
void cartId;
|
||||
void brandId;
|
||||
return { success: false, error: "Abandoned-cart persistence has been retired" };
|
||||
}
|
||||
|
||||
// ── Build email HTML ───────────────────────────────────────────────────────────
|
||||
@@ -217,6 +192,7 @@ export async function sendAbandonedCartEmail(
|
||||
adminUrl,
|
||||
});
|
||||
|
||||
void brandId;
|
||||
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
|
||||
if (!RESEND_API_KEY) return { success: false, error: "Resend not configured" };
|
||||
|
||||
@@ -241,25 +217,6 @@ export async function sendAbandonedCartEmail(
|
||||
return { success: false, error: err };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Update cart record
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: cart.id,
|
||||
p_sequence_step: step,
|
||||
p_last_email_sent_at: new Date().toISOString(),
|
||||
p_next_email_at: step < 3 ? new Date(Date.now() + EMAIL_INTERVALS_HOURS[step] * 3600000).toISOString() : null,
|
||||
p_status: step >= 3 ? "expired" : "active",
|
||||
p_expired_at: step >= 3 ? new Date().toISOString() : null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) };
|
||||
@@ -279,32 +236,15 @@ export async function resendAbandonedCartEmail(
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/manual_resend_abandoned_cart_email`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_cart_id: cartId, p_step: 1 }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { success: false, error: "Failed to resend email" };
|
||||
return { success: true };
|
||||
void cartId;
|
||||
void brandId;
|
||||
return { success: false, error: "Abandoned-cart persistence has been retired" };
|
||||
}
|
||||
|
||||
// ── Mark cart as recovered when order is placed ────────────────────────────────
|
||||
|
||||
export async function markCartRecovered(cartId: string, orderId: string): Promise<void> {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: cartId,
|
||||
p_status: "recovered",
|
||||
p_recovered_order_id: orderId,
|
||||
p_recovered_at: new Date().toISOString(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
void cartId;
|
||||
void orderId;
|
||||
// No DB call — abandoned_carts persistence is gone.
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* The new schema does not have a `welcome_sequence` table. The legacy
|
||||
* "enroll a contact in a multi-step onboarding email sequence" feature
|
||||
* has been retired; the mailer functions below still build and send
|
||||
* Resend messages, but the persistence layer is gone. The functions
|
||||
* now return empty data and no-op on updates.
|
||||
*/
|
||||
|
||||
export type WelcomeSequenceEntry = {
|
||||
id: string;
|
||||
@@ -112,27 +114,15 @@ export async function getWelcomeSequence(brandId: string): Promise<GetWelcomeSeq
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_welcome_sequence`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { success: false, error: "Failed to fetch welcome sequence" };
|
||||
const data = await res.json();
|
||||
const row = Array.isArray(data) ? data[0] : data;
|
||||
const entries: WelcomeSequenceEntry[] = row?.entries ?? [];
|
||||
// The welcome_sequence table has been retired; return an empty list
|
||||
// and zeroed stats. The cron API route in
|
||||
// `src/app/api/email-automation/welcome-sequence/route.ts` will see
|
||||
// an empty result and skip the per-brand work.
|
||||
void brandId;
|
||||
return {
|
||||
success: true,
|
||||
entries,
|
||||
stats: {
|
||||
total: entries.length,
|
||||
completed: entries.filter(e => e.status === "completed").length,
|
||||
active: entries.filter(e => e.status === "active").length,
|
||||
unsubscribed: entries.filter(e => e.status === "unsubscribed").length,
|
||||
},
|
||||
entries: [],
|
||||
stats: { total: 0, completed: 0, active: 0, unsubscribed: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -228,26 +218,8 @@ export async function sendWelcomeEmail(
|
||||
return { success: false, error: err };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const isLastEmail = step >= 4;
|
||||
|
||||
// Update sequence entry
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_welcome_sequence`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_id: entry.id,
|
||||
p_sequence_step: step,
|
||||
p_last_email_sent_at: new Date().toISOString(),
|
||||
p_next_email_at: isLastEmail ? null : new Date(Date.now() + 24 * 3600000).toISOString(),
|
||||
p_status: isLastEmail ? "completed" : "active",
|
||||
p_completed_at: isLastEmail ? new Date().toISOString() : null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// No DB to update — the welcome_sequence table is gone. Reporting
|
||||
// success here means the email was dispatched; the cron can move on.
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) };
|
||||
@@ -267,14 +239,10 @@ export async function resendWelcomeEmail(
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/manual_resend_welcome_email`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_entry_id: entryId, p_step: 1 }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return { success: false, error: "Failed to resend email" };
|
||||
return { success: true };
|
||||
void entryId;
|
||||
void brandId;
|
||||
// The welcome_sequence table is gone — there is nothing to look up
|
||||
// and no draft email to redispatch from here. Manual resend is a
|
||||
// no-op until a new persistence layer is added.
|
||||
return { success: false, error: "Welcome sequence persistence has been retired" };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { campaigns } from "@/db/schema";
|
||||
import type { AudienceRules } from "@/actions/communications/campaigns";
|
||||
|
||||
/**
|
||||
* `harvest-reach/campaigns` re-exports the marketing `Campaign` shape
|
||||
* but adds an analytics helper. The data layer is shared with
|
||||
* `actions/communications/campaigns.ts`; this module adapts the
|
||||
* narrower `Campaign` from there to the legacy `harvest-reach` shape
|
||||
* (with `subject`, `body_text`, `body_html`, `campaign_type`,
|
||||
* `audience_rules`, `scheduled_at`, `created_by`). Fields the new
|
||||
* schema does not store are filled with sensible defaults — UI code
|
||||
* that depends on the legacy fields should be updated to read them
|
||||
* from `email_templates` joined by `template_id`.
|
||||
*/
|
||||
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
|
||||
|
||||
@@ -40,9 +54,25 @@ export type CampaignAnalytics = {
|
||||
sent_at: string | null;
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// getHarvestReachCampaigns
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function rowToCampaign(row: typeof campaigns.$inferSelect): Campaign {
|
||||
return {
|
||||
id: row.id,
|
||||
brand_id: row.tenantId,
|
||||
name: row.name,
|
||||
subject: null,
|
||||
body_text: null,
|
||||
body_html: null,
|
||||
template_id: row.templateId,
|
||||
campaign_type: "operational",
|
||||
status: row.status as CampaignStatus,
|
||||
audience_rules: {},
|
||||
scheduled_at: row.scheduledFor ? row.scheduledFor.toISOString() : null,
|
||||
sent_at: row.sentAt ? row.sentAt.toISOString() : null,
|
||||
created_by: null,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getHarvestReachCampaigns(
|
||||
brandId: string
|
||||
@@ -53,27 +83,31 @@ export async function getHarvestReachCampaigns(
|
||||
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_campaigns`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(campaigns)
|
||||
.orderBy(desc(campaigns.createdAt)),
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch campaigns" };
|
||||
const data = await response.json();
|
||||
return { success: true, campaigns: data?.campaigns ?? [] };
|
||||
return { success: true, campaigns: rows.map(rowToCampaign) };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to fetch campaigns",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// getCampaignAnalytics
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The legacy `get_campaign_analytics` RPC computed aggregate engagement
|
||||
* metrics from the per-recipient `communication_message_logs` table.
|
||||
* That table is gone in the new schema. The replacement returns a
|
||||
* zeros-and-rate object keyed by campaign id, with the only real
|
||||
* number being the campaign's `recipient_count`. The UI is expected
|
||||
* to render "no analytics available" until a new log table is
|
||||
* introduced.
|
||||
*/
|
||||
export async function getCampaignAnalytics(
|
||||
brandId: string,
|
||||
campaignId?: string
|
||||
@@ -82,21 +116,38 @@ export async function getCampaignAnalytics(
|
||||
if (!adminUser) return [];
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||
|
||||
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_campaign_analytics`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_campaign_id: campaignId ?? null,
|
||||
}),
|
||||
}
|
||||
try {
|
||||
const rows = campaignId
|
||||
? await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(campaigns)
|
||||
.where(eq(campaigns.id, campaignId)),
|
||||
)
|
||||
: await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(campaigns)
|
||||
.orderBy(desc(campaigns.createdAt)),
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return (await response.json()) as CampaignAnalytics[];
|
||||
return rows.map((r) => ({
|
||||
campaign_id: r.id,
|
||||
campaign_name: r.name,
|
||||
total_sent: r.recipientCount,
|
||||
total_delivered: 0,
|
||||
total_opened: 0,
|
||||
total_clicked: 0,
|
||||
total_bounced: 0,
|
||||
delivered_rate: 0,
|
||||
open_rate: 0,
|
||||
click_rate: 0,
|
||||
bounce_rate: 0,
|
||||
sent_at: r.sentAt ? r.sentAt.toISOString() : null,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
void withPlatformAdmin;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { products } from "@/db/schema";
|
||||
|
||||
export type ProductOption = {
|
||||
id: string;
|
||||
@@ -10,6 +12,13 @@ export type ProductOption = {
|
||||
price: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The legacy `get_products_for_segment_picker` RPC returned a
|
||||
* `(id, name, type, price)` denormalized view. The new `products`
|
||||
* table does not have a `type` column, so every row is reported as
|
||||
* "product" — UI code that previously bucketed items by `type` will
|
||||
* see a single bucket until the schema gains a `type` column.
|
||||
*/
|
||||
export async function getProductsForSegmentPicker(
|
||||
brandId: string
|
||||
): Promise<ProductOption[]> {
|
||||
@@ -17,18 +26,25 @@ export async function getProductsForSegmentPicker(
|
||||
if (!adminUser) return [];
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||
|
||||
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_products_for_segment_picker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
id: products.id,
|
||||
name: products.name,
|
||||
priceCents: products.priceCents,
|
||||
active: products.active,
|
||||
})
|
||||
.from(products)
|
||||
.where(and(eq(products.tenantId, brandId), eq(products.active, true))),
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return (await response.json()) as ProductOption[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
type: "product",
|
||||
price: r.priceCents / 100,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, ilike, or, SQL, sql } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { customers, brandSettings } from "@/db/schema";
|
||||
import type { AudienceRules } from "@/actions/communications/campaigns";
|
||||
|
||||
export type SegmentFilterType =
|
||||
@@ -37,6 +39,8 @@ export type SegmentRuleV2 = {
|
||||
|
||||
// AudienceRules is imported from @/actions/communications/campaigns
|
||||
|
||||
const SEGMENTS_FLAG_KEY = "comm_segments_v1";
|
||||
|
||||
export type Segment = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
@@ -61,9 +65,41 @@ export type PreviewResult = {
|
||||
sample_customers: CustomerSample[];
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// getHarvestReachSegments
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function isSegmentList(v: unknown): v is Segment[] {
|
||||
return Array.isArray(v) && v.every((s) => s && typeof s === "object" && "rules" in s);
|
||||
}
|
||||
|
||||
async function loadSegments(brandId: string): Promise<Segment[]> {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.limit(1),
|
||||
);
|
||||
const raw = (rows[0]?.flags ?? {}) as Record<string, unknown>;
|
||||
const list = raw[SEGMENTS_FLAG_KEY];
|
||||
return isSegmentList(list) ? list : [];
|
||||
}
|
||||
|
||||
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
|
||||
await withTenant(brandId, async (db) => {
|
||||
const existing = await db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.limit(1);
|
||||
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
||||
const nextFlags: Record<string, unknown> = {
|
||||
...baseFlags,
|
||||
[SEGMENTS_FLAG_KEY]: segments,
|
||||
};
|
||||
await db
|
||||
.update(brandSettings)
|
||||
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
||||
.where(eq(brandSettings.tenantId, brandId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function getHarvestReachSegments(
|
||||
brandId: string
|
||||
@@ -75,27 +111,17 @@ export async function getHarvestReachSegments(
|
||||
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 }),
|
||||
try {
|
||||
const segments = await loadSegments(brandId);
|
||||
return { success: true, segments };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to fetch segments",
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch segments" };
|
||||
const data = await response.json();
|
||||
return { success: true, segments: data?.segments ?? [] };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// upsertHarvestReachSegment
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertHarvestReachSegment(params: {
|
||||
id?: string;
|
||||
brand_id: string;
|
||||
@@ -110,34 +136,44 @@ export async function upsertHarvestReachSegment(params: {
|
||||
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,
|
||||
}),
|
||||
try {
|
||||
const segments = await loadSegments(params.brand_id);
|
||||
const now = new Date().toISOString();
|
||||
let saved: Segment;
|
||||
if (params.id) {
|
||||
const idx = segments.findIndex((s) => s.id === params.id);
|
||||
if (idx === -1) return { success: false, error: "Segment not found" };
|
||||
saved = {
|
||||
...segments[idx],
|
||||
name: params.name,
|
||||
description: params.description ?? null,
|
||||
rules: params.rules,
|
||||
updated_at: now,
|
||||
};
|
||||
segments[idx] = saved;
|
||||
} else {
|
||||
saved = {
|
||||
id: crypto.randomUUID(),
|
||||
brand_id: params.brand_id,
|
||||
name: params.name,
|
||||
description: params.description ?? null,
|
||||
rules: params.rules,
|
||||
created_by: adminUser.id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
segments.push(saved);
|
||||
}
|
||||
await saveSegments(params.brand_id, segments);
|
||||
return { success: true, segment: saved };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to save segment",
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to save segment" };
|
||||
const data = await response.json();
|
||||
return { success: true, segment: data };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// deleteHarvestReachSegment
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteHarvestReachSegment(
|
||||
segmentId: string,
|
||||
brandId: string
|
||||
@@ -149,26 +185,30 @@ export async function deleteHarvestReachSegment(
|
||||
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 }),
|
||||
try {
|
||||
const segments = await loadSegments(brandId);
|
||||
const filtered = segments.filter((s) => s.id !== segmentId);
|
||||
if (filtered.length === segments.length) {
|
||||
return { success: false, error: "Segment not found" };
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete segment" };
|
||||
await saveSegments(brandId, filtered);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to delete segment",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// previewSegmentWithCustomers
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The legacy `preview_campaign_audience` RPC evaluated a (combinator +
|
||||
* filter[]) rule tree against a join of customers, orders, products,
|
||||
* etc. The new schema has only `customers` and `orders`; the new
|
||||
* preview therefore counts the tenant's opted-in customers. The
|
||||
* `SegmentRuleV2` shape is preserved for round-tripping but no longer
|
||||
* influences the count.
|
||||
*/
|
||||
export async function previewSegmentWithCustomers(
|
||||
brandId: string,
|
||||
rules: SegmentRuleV2
|
||||
@@ -179,23 +219,43 @@ export async function previewSegmentWithCustomers(
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return null;
|
||||
}
|
||||
void rules;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
const conds: SQL[] = [eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)];
|
||||
|
||||
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: rules,
|
||||
}),
|
||||
try {
|
||||
const [samples, counts] = await withTenant(brandId, async (db) => {
|
||||
const sample = await db
|
||||
.select({
|
||||
id: customers.id,
|
||||
email: customers.email,
|
||||
name: customers.name,
|
||||
phone: customers.phone,
|
||||
})
|
||||
.from(customers)
|
||||
.where(and(...conds))
|
||||
.limit(5);
|
||||
const c = await db
|
||||
.select({ value: sql<number>`count(*)::int` })
|
||||
.from(customers)
|
||||
.where(and(...conds));
|
||||
return [sample, c] as const;
|
||||
});
|
||||
|
||||
return {
|
||||
count: Number(counts[0]?.value ?? 0),
|
||||
sample_customers: samples.map((s) => ({
|
||||
id: s.id,
|
||||
email: s.email ?? "",
|
||||
name: s.name,
|
||||
tags: [],
|
||||
phone: s.phone,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { count: 0, sample_customers: [] };
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data as PreviewResult;
|
||||
}
|
||||
|
||||
void or;
|
||||
void ilike;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { stops } from "@/db/schema";
|
||||
|
||||
export type StopOption = {
|
||||
id: string;
|
||||
@@ -15,6 +17,31 @@ export type StopOption = {
|
||||
is_past: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The legacy `get_stops_for_segment_picker` RPC returned a denormalized
|
||||
* `(id, city, state, date, time, location, zip, ...)` row. The new
|
||||
* `stops` table only stores `name`, `address`, and a JSONB `schedule`
|
||||
* array — the structured city/state/zip columns are gone. The
|
||||
* replacement returns one option per stop, deriving display fields
|
||||
* from `address` and the schedule. `city`, `state`, `zip`, `date`,
|
||||
* `time` are best-effort extracted from the address string; missing
|
||||
* values fall back to "—".
|
||||
*/
|
||||
function parseAddress(address: string): { city: string; state: string; zip: string } {
|
||||
// Best-effort: "123 Main St, City, ST 12345"
|
||||
const parts = address.split(",").map((s) => s.trim());
|
||||
const stateZip = parts[parts.length - 1] ?? "";
|
||||
const stateZipMatch = stateZip.match(/^([A-Z]{2})\s+(\d{5}(?:-\d{4})?)$/);
|
||||
if (stateZipMatch) {
|
||||
return {
|
||||
city: parts.length >= 2 ? parts[parts.length - 2] : "",
|
||||
state: stateZipMatch[1],
|
||||
zip: stateZipMatch[2],
|
||||
};
|
||||
}
|
||||
return { city: parts[1] ?? "", state: "", zip: "" };
|
||||
}
|
||||
|
||||
export async function getStopsForSegmentPicker(
|
||||
brandId: string,
|
||||
stopId?: string
|
||||
@@ -23,21 +50,46 @@ export async function getStopsForSegmentPicker(
|
||||
if (!adminUser) return [];
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
|
||||
|
||||
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_stops_for_segment_picker`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_stop_id: stopId ?? null,
|
||||
}),
|
||||
}
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
id: stops.id,
|
||||
name: stops.name,
|
||||
address: stops.address,
|
||||
schedule: stops.schedule,
|
||||
})
|
||||
.from(stops)
|
||||
.where(
|
||||
stopId
|
||||
? and(eq(stops.tenantId, brandId), eq(stops.id, stopId))
|
||||
: eq(stops.tenantId, brandId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return (await response.json()) as StopOption[];
|
||||
const now = Date.now();
|
||||
return rows.map((r) => {
|
||||
const { city, state, zip } = parseAddress(r.address);
|
||||
const sched = Array.isArray(r.schedule) ? r.schedule : [];
|
||||
const first = sched[0] as { date?: string; time?: string } | undefined;
|
||||
const dateStr = first?.date ?? "";
|
||||
const timeStr = first?.time ?? "";
|
||||
const ts = dateStr ? new Date(dateStr).getTime() : NaN;
|
||||
const isUpcoming = Number.isFinite(ts) ? ts >= now : false;
|
||||
const isPast = Number.isFinite(ts) ? ts < now : false;
|
||||
return {
|
||||
id: r.id,
|
||||
city,
|
||||
state,
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
location: r.name,
|
||||
zip,
|
||||
is_upcoming: isUpcoming,
|
||||
is_past: isPast,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { sendAbandonedCartEmail, type AbandonedCart } from "@/actions/email-automation/abandoned-cart";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
import {
|
||||
getAbandonedCarts,
|
||||
sendAbandonedCartEmail,
|
||||
type AbandonedCart,
|
||||
} from "@/actions/email-automation/abandoned-cart";
|
||||
|
||||
const BRAND_IDS = [
|
||||
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" },
|
||||
{ id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct" },
|
||||
];
|
||||
|
||||
const EMAIL_INTERVALS_HOURS = [1, 24, 48] as const;
|
||||
|
||||
async function processAbandonedCartsForBrand(brandId: string, brandName: string): Promise<{ sent: number; failed: number; skipped: number }> {
|
||||
let sent = 0, failed = 0, skipped = 0;
|
||||
void brandName;
|
||||
|
||||
// 1. Detect newly abandoned wholesale carts
|
||||
const detectRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/detect_abandoned_wholesale_carts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!detectRes.ok) {
|
||||
return { sent: 0, failed: 0, skipped: 0 };
|
||||
}
|
||||
const detected: Array<{ order_id: string; customer_id: string; contact_email: string; contact_name: string; cart_snapshot: unknown }> = await detectRes.json();
|
||||
|
||||
// 2. Enroll new abandoned carts
|
||||
for (const row of detected) {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/enroll_abandoned_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_customer_id: row.customer_id,
|
||||
p_contact_email: row.contact_email,
|
||||
p_contact_name: row.contact_name,
|
||||
p_cart_snapshot: row.cart_snapshot,
|
||||
p_brand_name: brandName,
|
||||
p_locale: "en",
|
||||
p_next_email_at: new Date(Date.now() + EMAIL_INTERVALS_HOURS[0] * 3600000).toISOString(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 1+2. Detection and enrollment are gone with the abandoned_carts
|
||||
// table. Skipped silently.
|
||||
// 3. Fetch active carts ready for next email
|
||||
const activeRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_active_abandoned_carts`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!activeRes.ok) {
|
||||
const result = await getAbandonedCarts(brandId);
|
||||
if (!result.success) {
|
||||
return { sent, failed, skipped };
|
||||
}
|
||||
const activeData = await activeRes.json();
|
||||
const row = Array.isArray(activeData) ? activeData[0] : activeData;
|
||||
const activeCarts: AbandonedCart[] = row?.carts ?? [];
|
||||
const activeCarts: AbandonedCart[] = result.carts;
|
||||
|
||||
// 4. Send emails
|
||||
for (const cart of activeCarts) {
|
||||
const nextStep = cart.sequence_step + 1;
|
||||
if (nextStep > 3) { skipped++; continue; }
|
||||
const result = await sendAbandonedCartEmail(cart, nextStep, brandId);
|
||||
if (result.success) {
|
||||
const r = await sendAbandonedCartEmail(cart, nextStep, brandId);
|
||||
if (r.success) {
|
||||
sent++;
|
||||
} else {
|
||||
failed++;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { sendWelcomeEmail, type WelcomeSequenceEntry } from "@/actions/email-automation/welcome-sequence";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
import {
|
||||
getWelcomeSequence,
|
||||
sendWelcomeEmail,
|
||||
type WelcomeSequenceEntry,
|
||||
} from "@/actions/email-automation/welcome-sequence";
|
||||
|
||||
const BRAND_IDS = [
|
||||
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" },
|
||||
@@ -13,28 +13,19 @@ const BRAND_IDS = [
|
||||
async function processWelcomeSequencesForBrand(brandId: string): Promise<{ sent: number; failed: number; skipped: number }> {
|
||||
let sent = 0, failed = 0, skipped = 0;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_active_welcome_sequence`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const result = await getWelcomeSequence(brandId);
|
||||
if (!result.success) {
|
||||
return { sent: 0, failed: 0, skipped: 0 };
|
||||
}
|
||||
const data = await res.json();
|
||||
const row = Array.isArray(data) ? data[0] : data;
|
||||
const entries: WelcomeSequenceEntry[] = row?.entries ?? [];
|
||||
const entries: WelcomeSequenceEntry[] = result.entries;
|
||||
|
||||
for (const entry of entries) {
|
||||
const nextStep = entry.sequence_step + 1;
|
||||
if (nextStep > 4) { skipped++; continue; }
|
||||
if (entry.status === "unsubscribed" || entry.status === "bounced") { skipped++; continue; }
|
||||
|
||||
const result = await sendWelcomeEmail(entry, nextStep);
|
||||
if (result.success) {
|
||||
const r = await sendWelcomeEmail(entry, nextStep);
|
||||
if (r.success) {
|
||||
sent++;
|
||||
} else {
|
||||
failed++;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// Resend webhook events: email.delivered, email.opened, email.clicked,
|
||||
// email.bounced, email.failed, email.unsubscribed
|
||||
@@ -48,78 +47,14 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { type, data } = event;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Look up by customer_email + subject (event_id is not populated by send_campaign)
|
||||
// Filter to recent logs (last 7 days) to avoid stale matches
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
const findRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/communication_message_logs?` +
|
||||
`customer_email=eq.${encodeURIComponent(data.to)}` +
|
||||
`&subject=eq.${encodeURIComponent(data.subject ?? "")}` +
|
||||
`&delivery_method=eq.email` +
|
||||
`&created_at=gt.${sevenDaysAgo.toISOString()}` +
|
||||
`&order=created_at.desc` +
|
||||
`&limit=5&select=id,brand_id`,
|
||||
{
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
if (!findRes.ok) {
|
||||
return NextResponse.json({ error: "Failed to look up message" }, { status: 500 });
|
||||
}
|
||||
|
||||
const logs: { id: string; brand_id: string }[] = await findRes.json();
|
||||
if (logs.length === 0) {
|
||||
// No matching log found — silently acknowledge to avoid Resend retries
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
// Update the most recent matching log
|
||||
const log = logs[0];
|
||||
const updates: Record<string, string | undefined> = {};
|
||||
|
||||
switch (type) {
|
||||
case "email.delivered":
|
||||
updates.delivered_at = data.delivered_at ?? new Date().toISOString();
|
||||
updates.status = "delivered";
|
||||
break;
|
||||
case "email.opened":
|
||||
updates.opened_at = data.opened_at ?? new Date().toISOString();
|
||||
break;
|
||||
case "email.clicked":
|
||||
updates.clicked_at = data.clicked_at ?? new Date().toISOString();
|
||||
break;
|
||||
case "email.bounced":
|
||||
case "email.failed":
|
||||
updates.bounced_at = data.bounced_at ?? new Date().toISOString();
|
||||
updates.bounce_reason = data.bounce_reason ?? undefined;
|
||||
updates.status = "bounced";
|
||||
break;
|
||||
case "email.unsubscribed":
|
||||
updates.status = "unsubscribed";
|
||||
break;
|
||||
default:
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const patchRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/communication_message_logs?id=eq.${log.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=minimal" },
|
||||
body: JSON.stringify(updates),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchRes.ok) {
|
||||
return NextResponse.json({ error: "Failed to update log" }, { status: 500 });
|
||||
}
|
||||
// The new schema does not have a `communication_message_logs` table
|
||||
// — the per-recipient delivery log has been retired. We still
|
||||
// acknowledge the event so Resend does not retry, but there is
|
||||
// nothing to update. When a new log table is added, this is the
|
||||
// place to look up by `customer_email + subject` (the event_id is
|
||||
// never populated by `send_campaign`) and write delivered/opened/
|
||||
// clicked/bounced timestamps.
|
||||
void event;
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -2,23 +2,11 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { sendStopBlast } from "@/actions/communications/stop-blast";
|
||||
|
||||
type Order = {
|
||||
id: string;
|
||||
customer_name: string;
|
||||
customer_email: string | null;
|
||||
customer_phone: string | null;
|
||||
pickup_complete: boolean;
|
||||
};
|
||||
|
||||
type BlastMessage = {
|
||||
id: string;
|
||||
type: string;
|
||||
subject: string | null;
|
||||
body: string;
|
||||
created_at: string;
|
||||
message_recipients: { id: string }[];
|
||||
};
|
||||
import {
|
||||
getStopMessagingData,
|
||||
type StopOrder,
|
||||
type StopBlastMessage,
|
||||
} from "@/actions/communications/stop-messaging";
|
||||
|
||||
type MessageCustomersSectionProps = {
|
||||
stopId: string;
|
||||
@@ -29,8 +17,8 @@ export default function MessageCustomersSection({
|
||||
stopId,
|
||||
brandId,
|
||||
}: MessageCustomersSectionProps) {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [messages, setMessages] = useState<BlastMessage[]>([]);
|
||||
const [orders, setOrders] = useState<StopOrder[]>([]);
|
||||
const [messages, setMessages] = useState<StopBlastMessage[]>([]);
|
||||
const [loadingOrders, setLoadingOrders] = useState(true);
|
||||
const [loadingMessages, setLoadingMessages] = useState(true);
|
||||
|
||||
@@ -43,64 +31,27 @@ export default function MessageCustomersSection({
|
||||
const [confirm, setConfirm] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
fetchMessages();
|
||||
}, []);
|
||||
|
||||
async function fetchOrders() {
|
||||
setLoadingOrders(true);
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?stop_id=eq.${stopId}&select=customer_name,customer_email,customer_phone,pickup_complete`,
|
||||
{
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
},
|
||||
let cancelled = false;
|
||||
getStopMessagingData({ stopId, brandId })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success) {
|
||||
setOrders(res.orders);
|
||||
setMessages(res.messages);
|
||||
} else {
|
||||
setError(res.error);
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (res.ok) setOrders(data);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
|
||||
async function fetchMessages() {
|
||||
// Legacy messages
|
||||
const [msgRes, campaignRes] = await Promise.all([
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/messages?stop_id=eq.${stopId}&select=*,message_recipients(id)&order=created_at.desc&limit=10`,
|
||||
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
|
||||
),
|
||||
// Campaign-based blast logs for this stop
|
||||
brandId
|
||||
? fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/communication_campaigns?brand_id=eq.${brandId}&audience_rules->>'stop_id'=eq.${stopId}&order=created_at.desc&limit=5`,
|
||||
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
|
||||
)
|
||||
: Promise.resolve({ ok: false, json: async () => [] } as Response),
|
||||
]);
|
||||
|
||||
const legacyData = msgRes.ok ? await msgRes.json() : [];
|
||||
const campaignData = campaignRes.ok ? await campaignRes.json() : [];
|
||||
|
||||
// Normalize campaign logs into BlastMessage shape
|
||||
const campaignMessages: BlastMessage[] = (campaignData as Array<{
|
||||
id: string; subject: string | null; body_text: string | null;
|
||||
sent_at: string; created_at: string; status: string;
|
||||
}>).map((c) => ({
|
||||
id: c.id,
|
||||
type: "campaign",
|
||||
subject: c.subject,
|
||||
body: c.body_text ?? "",
|
||||
created_at: c.sent_at,
|
||||
message_recipients: [],
|
||||
}));
|
||||
|
||||
// Interleave: legacy first, then campaign-based
|
||||
const merged = [...legacyData, ...campaignMessages].sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
setMessages(merged.slice(0, 10));
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [stopId, brandId]);
|
||||
|
||||
const allOrders = orders;
|
||||
const pendingOrders = orders.filter((o) => !o.pickup_complete);
|
||||
@@ -153,7 +104,12 @@ export default function MessageCustomersSection({
|
||||
setConfirm(`Blast sent — ${result.messages_logged} message${result.messages_logged !== 1 ? "s" : ""} logged via campaign.`);
|
||||
setBody("");
|
||||
setSubject("");
|
||||
fetchMessages();
|
||||
|
||||
// Refresh the message log
|
||||
setLoadingMessages(true);
|
||||
const res = await getStopMessagingData({ stopId, brandId });
|
||||
setLoadingMessages(false);
|
||||
if (res.success) setMessages(res.messages);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user