"use server"; import { desc, eq } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; import { withBrand, 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"; export type Campaign = { id: string; brand_id: string; name: string; subject: string | null; body_text: string | null; body_html: string | null; template_id: string | null; campaign_type: CampaignType; status: CampaignStatus; audience_rules: AudienceRules; scheduled_at: string | null; sent_at: string | null; created_by: string | null; created_at: string; updated_at: string; }; export type CampaignAnalytics = { campaign_id: string; campaign_name: string; total_sent: number; total_delivered: number; total_opened: number; total_clicked: number; total_bounced: number; delivered_rate: number; open_rate: number; click_rate: number; bounce_rate: number; sent_at: string | null; }; function rowToCampaign(row: typeof campaigns.$inferSelect): Campaign { return { id: row.id, brand_id: row.brandId, 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 ): Promise<{ success: true; campaigns: Campaign[] } | { success: false; error: string }> { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { return { success: false, error: "Not authorized" }; } try { const rows = await withBrand(brandId, (db) => db .select() .from(campaigns) .orderBy(desc(campaigns.createdAt)), ); return { success: true, campaigns: rows.map(rowToCampaign) }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : "Failed to fetch campaigns", }; } } /** * 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 ): Promise { const adminUser = await getAdminUser(); if (!adminUser) return []; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return []; try { const rows = campaignId ? await withBrand(brandId, (db) => db .select() .from(campaigns) .where(eq(campaigns.id, campaignId)), ) : await withBrand(brandId, (db) => db .select() .from(campaigns) .orderBy(desc(campaigns.createdAt)), ); 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;