157 lines
4.6 KiB
TypeScript
157 lines
4.6 KiB
TypeScript
"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";
|
|
import { getSession } from "@/lib/auth";
|
|
|
|
/**
|
|
* `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(),
|
|
};
|
|
}
|
|
|
|
async function getHarvestReachCampaigns(
|
|
brandId: string
|
|
): Promise<{ success: true; campaigns: Campaign[] } | { success: false; error: string }> {
|
|
|
|
await getSession(); 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<CampaignAnalytics[]> {
|
|
|
|
await getSession(); 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;
|