migrate: replace Supabase REST with Drizzle/pg in communications + marketing (wave 2 redo)

This commit is contained in:
2026-06-07 03:58:26 +00:00
parent 99a3d66636
commit 3ad2a48fc3
19 changed files with 1738 additions and 1089 deletions
+91 -59
View File
@@ -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!;
if (audienceRules?.target && audienceRules.target !== "all_customers") {
return { count: 0, sample_customers: [] };
}
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 ?? {},
}),
}
);
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 }),
);
if (updated.length === 0) {
return { success: false, error: "Campaign not found" };
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Failed to send campaign" };
return { success: true, messages_logged: updated[0].recipientCount };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to send campaign",
};
}
return { success: true, messages_logged: data.messages_logged ?? 0 };
}
}