migrate: replace Supabase REST with Drizzle/pg in communications + marketing (wave 2 redo)
This commit is contained in:
@@ -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 }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch campaigns" };
|
||||
const data = await response.json();
|
||||
return { success: true, campaigns: data?.campaigns ?? [] };
|
||||
try {
|
||||
const rows = await withTenant(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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 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!;
|
||||
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)),
|
||||
);
|
||||
|
||||
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,
|
||||
}),
|
||||
}
|
||||
);
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) return [];
|
||||
return (await response.json()) as CampaignAnalytics[];
|
||||
}
|
||||
void withPlatformAdmin;
|
||||
|
||||
Reference in New Issue
Block a user