Files
eventify_command_center/src/lib/actions/ads.ts

389 lines
15 KiB
TypeScript

'use server';
// Sponsored Ads — Server Actions (localStorage-persisted mock)
import type {
Campaign, CampaignWithEvents, CampaignFormData,
CampaignReport, CampaignStatus, PlacementDailyStats,
} from '@/lib/types/ads';
import type { SurfaceKey } from '@/lib/types/ad-control';
import { MOCK_CAMPAIGNS, MOCK_DAILY_STATS, MOCK_TRACKING_EVENTS } from '@/features/ad-control/data/mockAdsData';
import { MOCK_PICKER_EVENTS, MOCK_SURFACES } from '@/features/ad-control/data/mockAdData';
import { computeDailyStats, getTrackingEvents, recordImpression, recordClick } from '@/lib/ads/tracking';
const CAMPAIGNS_KEY = 'ad_campaigns';
const CAMPAIGN_AUDIT_KEY = 'ad_campaign_audit';
// ===== Persistence =====
function getCampaignStore(): Campaign[] {
if (typeof window === 'undefined') return MOCK_CAMPAIGNS;
try {
const raw = localStorage.getItem(CAMPAIGNS_KEY);
return raw ? JSON.parse(raw) : MOCK_CAMPAIGNS;
} catch { return MOCK_CAMPAIGNS; }
}
function saveCampaignStore(campaigns: Campaign[]) {
if (typeof window !== 'undefined') {
localStorage.setItem(CAMPAIGNS_KEY, JSON.stringify(campaigns));
}
}
function logCampaignAudit(campaignId: string, action: string, details?: Record<string, any>) {
if (typeof window === 'undefined') return;
try {
const entries = JSON.parse(localStorage.getItem(CAMPAIGN_AUDIT_KEY) || '[]');
entries.push({
id: `ca-${Date.now()}`,
campaignId,
actorId: 'admin-1',
action,
details: details || null,
createdAt: new Date().toISOString(),
});
localStorage.setItem(CAMPAIGN_AUDIT_KEY, JSON.stringify(entries.slice(-500)));
} catch { }
}
// ===== Resolve Events =====
function resolveEvents(campaign: Campaign): CampaignWithEvents {
const events = MOCK_PICKER_EVENTS.filter(e => campaign.eventIds.includes(e.id));
return { ...campaign, events };
}
// ===== Auto-end expired campaigns =====
function autoEndCampaigns(campaigns: Campaign[]): Campaign[] {
const now = new Date().toISOString();
return campaigns.map(c => {
if (c.status === 'ACTIVE' && (c.endAt < now || c.spent >= c.totalBudget)) {
return { ...c, status: 'ENDED' as CampaignStatus, updatedAt: now };
}
return c;
});
}
// ===== QUERIES =====
export async function getCampaigns(
status?: CampaignStatus | 'ALL',
): Promise<{ success: boolean; data: CampaignWithEvents[] }> {
let campaigns = autoEndCampaigns(getCampaignStore());
saveCampaignStore(campaigns);
if (status && status !== 'ALL') {
campaigns = campaigns.filter(c => c.status === status);
}
// Sort: IN_REVIEW first, then ACTIVE, then by updatedAt desc
const statusOrder: Record<string, number> = { IN_REVIEW: 0, ACTIVE: 1, PAUSED: 2, DRAFT: 3, ENDED: 4, REJECTED: 5 };
campaigns.sort((a, b) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9) || b.updatedAt.localeCompare(a.updatedAt));
return { success: true, data: campaigns.map(resolveEvents) };
}
export async function getCampaign(id: string): Promise<{ success: boolean; data?: CampaignWithEvents; message: string }> {
const campaigns = getCampaignStore();
const campaign = campaigns.find(c => c.id === id);
if (!campaign) return { success: false, message: 'Campaign not found' };
return { success: true, data: resolveEvents(campaign), message: 'OK' };
}
// ===== MUTATIONS =====
export async function createCampaign(
data: CampaignFormData,
): Promise<{ success: boolean; message: string; data?: Campaign }> {
const campaigns = getCampaignStore();
const now = new Date().toISOString();
const id = `camp-${Date.now().toString(36)}`;
const newCampaign: Campaign = {
id,
partnerId: `partner-${data.partnerName.toLowerCase().replace(/\s+/g, '-').slice(0, 10)}`,
partnerName: data.partnerName,
name: data.name,
objective: data.objective,
status: 'DRAFT',
startAt: data.startAt,
endAt: data.endAt,
billingModel: data.billingModel,
totalBudget: data.totalBudget,
dailyCap: data.dailyCap,
spent: 0,
targeting: data.targeting,
surfaceKeys: data.surfaceKeys,
eventIds: data.eventIds,
frequencyCap: data.frequencyCap,
approvedBy: null,
rejectedReason: null,
createdBy: 'admin-1',
createdAt: now,
updatedAt: now,
};
campaigns.push(newCampaign);
saveCampaignStore(campaigns);
logCampaignAudit(id, 'CREATED', { name: data.name });
return { success: true, message: 'Campaign created as draft', data: newCampaign };
}
export async function updateCampaign(
id: string,
patch: Partial<CampaignFormData>,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
const campaign = campaigns[idx];
if (campaign.status !== 'DRAFT' && campaign.status !== 'PAUSED') {
return { success: false, message: 'Can only edit draft or paused campaigns' };
}
const updated: Campaign = {
...campaign,
...patch,
updatedAt: new Date().toISOString(),
} as Campaign;
campaigns[idx] = updated;
saveCampaignStore(campaigns);
logCampaignAudit(id, 'UPDATED', patch);
return { success: true, message: 'Campaign updated' };
}
export async function submitCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'DRAFT') return { success: false, message: 'Only draft campaigns can be submitted' };
// Validation checks
const c = campaigns[idx];
if (c.eventIds.length === 0) return { success: false, message: 'At least one event is required' };
if (c.surfaceKeys.length === 0) return { success: false, message: 'At least one surface is required' };
if (c.totalBudget <= 0) return { success: false, message: 'Budget must be positive' };
if (!c.startAt || !c.endAt) return { success: false, message: 'Schedule dates are required' };
campaigns[idx] = { ...c, status: 'IN_REVIEW', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'SUBMITTED');
return { success: true, message: 'Campaign submitted for review' };
}
export async function approveCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'IN_REVIEW') return { success: false, message: 'Campaign is not in review' };
// Eligibility checks
const c = campaigns[idx];
for (const eventId of c.eventIds) {
const event = MOCK_PICKER_EVENTS.find(e => e.id === eventId);
if (!event) return { success: false, message: `Event ${eventId} not found` };
if (event.approvalStatus !== 'APPROVED') return { success: false, message: `Event "${event.title}" is not approved` };
if (new Date(event.endDate) < new Date()) return { success: false, message: `Event "${event.title}" has ended` };
}
campaigns[idx] = { ...c, status: 'ACTIVE', approvedBy: 'admin-1', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'APPROVED');
return { success: true, message: 'Campaign approved and activated' };
}
export async function rejectCampaign(
id: string,
reason: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'IN_REVIEW') return { success: false, message: 'Campaign is not in review' };
campaigns[idx] = { ...campaigns[idx], status: 'REJECTED', rejectedReason: reason, updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'REJECTED', { reason });
return { success: true, message: 'Campaign rejected' };
}
export async function pauseCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'ACTIVE') return { success: false, message: 'Only active campaigns can be paused' };
campaigns[idx] = { ...campaigns[idx], status: 'PAUSED', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'PAUSED');
return { success: true, message: 'Campaign paused' };
}
export async function resumeCampaign(
id: string,
): Promise<{ success: boolean; message: string }> {
const campaigns = getCampaignStore();
const idx = campaigns.findIndex(c => c.id === id);
if (idx === -1) return { success: false, message: 'Campaign not found' };
if (campaigns[idx].status !== 'PAUSED') return { success: false, message: 'Only paused campaigns can be resumed' };
campaigns[idx] = { ...campaigns[idx], status: 'ACTIVE', updatedAt: new Date().toISOString() };
saveCampaignStore(campaigns);
logCampaignAudit(id, 'RESUMED');
return { success: true, message: 'Campaign resumed' };
}
// ===== REPORTING =====
export async function getCampaignReport(
id: string,
): Promise<{ success: boolean; data?: CampaignReport; message: string }> {
const campaigns = getCampaignStore();
const campaign = campaigns.find(c => c.id === id);
if (!campaign) return { success: false, message: 'Campaign not found' };
// Get or compute daily stats
let dailyStats: PlacementDailyStats[];
if (campaign.id === 'camp-001') {
// Use pre-computed mock data for demo campaign
dailyStats = MOCK_DAILY_STATS;
} else {
dailyStats = computeDailyStats(
campaign.id,
campaign.billingModel,
campaign.totalBudget / (campaign.billingModel === 'CPM' ? 1000 : campaign.billingModel === 'CPC' ? 500 : 1),
);
}
// Totals
const totalImpressions = dailyStats.reduce((s, d) => s + d.impressions, 0);
const totalClicks = dailyStats.reduce((s, d) => s + d.clicks, 0);
const totalSpend = dailyStats.reduce((s, d) => s + d.spend, 0);
// By surface
const surfaceMap = new Map<string, { impressions: number; clicks: number; spend: number }>();
for (const stat of dailyStats) {
const existing = surfaceMap.get(stat.surfaceKey) || { impressions: 0, clicks: 0, spend: 0 };
existing.impressions += stat.impressions;
existing.clicks += stat.clicks;
existing.spend += stat.spend;
surfaceMap.set(stat.surfaceKey, existing);
}
const bySurface = Array.from(surfaceMap.entries()).map(([surfaceKey, data]) => {
const surface = MOCK_SURFACES.find(s => s.key === surfaceKey);
return {
surfaceKey: surfaceKey as SurfaceKey,
surfaceName: surface?.name || surfaceKey,
impressions: data.impressions,
clicks: data.clicks,
ctr: data.impressions > 0 ? Number((data.clicks / data.impressions).toFixed(4)) : 0,
spend: Number(data.spend.toFixed(2)),
};
});
const report: CampaignReport = {
campaign: resolveEvents(campaign),
totals: {
impressions: totalImpressions,
clicks: totalClicks,
ctr: totalImpressions > 0 ? Number((totalClicks / totalImpressions).toFixed(4)) : 0,
spend: Number(totalSpend.toFixed(2)),
remaining: Number((campaign.totalBudget - totalSpend).toFixed(2)),
},
dailyStats,
bySurface,
};
return { success: true, data: report, message: 'OK' };
}
// ===== CSV EXPORT =====
export async function exportCampaignCSV(id: string): Promise<{ success: boolean; csv?: string; message: string }> {
const res = await getCampaignReport(id);
if (!res.success || !res.data) return { success: false, message: res.message };
const { campaign, totals, dailyStats, bySurface } = res.data;
const lines: string[] = [
`Campaign Report: ${campaign.name}`,
`Partner: ${campaign.partnerName}`,
`Status: ${campaign.status}`,
`Period: ${campaign.startAt.slice(0, 10)} to ${campaign.endAt.slice(0, 10)}`,
`Billing: ${campaign.billingModel}`,
`Budget: ₹${campaign.totalBudget.toLocaleString()}`,
`Spent: ₹${totals.spend.toLocaleString()}`,
``,
`Date,Surface,Impressions,Clicks,CTR,Spend`,
...dailyStats.map(d =>
`${d.date},${d.surfaceKey},${d.impressions},${d.clicks},${(d.ctr * 100).toFixed(2)}%,₹${d.spend.toFixed(2)}`
),
``,
`Surface Summary`,
`Surface,Impressions,Clicks,CTR,Spend`,
...bySurface.map(s =>
`${s.surfaceName},${s.impressions},${s.clicks},${(s.ctr * 100).toFixed(2)}%,₹${s.spend.toFixed(2)}`
),
``,
`Totals,${totals.impressions},${totals.clicks},${(totals.ctr * 100).toFixed(2)}%,₹${totals.spend.toFixed(2)}`,
];
return { success: true, csv: lines.join('\n'), message: 'OK' };
}
// ===== DASHBOARD STATS =====
export async function getSponsoredStats(): Promise<{
success: boolean;
data: {
activeCampaigns: number;
todaySpend: number;
impressions24h: number;
clicks24h: number;
ctr24h: number;
};
}> {
const campaigns = autoEndCampaigns(getCampaignStore());
const activeCampaigns = campaigns.filter(c => c.status === 'ACTIVE').length;
// Last 24h stats from tracking events
const cutoff24h = new Date(Date.now() - 86400000).toISOString();
// For pre-seeded data, use mock stats; for real-time, use tracking
let impressions24h = 0;
let clicks24h = 0;
let todaySpend = 0;
// Check real tracking events first
const allEvents = getTrackingEvents();
const recent = allEvents.filter(e => e.timestamp > cutoff24h);
if (recent.length > 0) {
impressions24h = recent.filter(e => e.type === 'IMPRESSION').length;
clicks24h = recent.filter(e => e.type === 'CLICK').length;
todaySpend = Number(((impressions24h / 1000) * 250).toFixed(2)); // ₹250 CPM assumed
} else {
// Fallback to mock daily stats (last day)
const lastDayStats = MOCK_DAILY_STATS.filter(d => d.date === '2026-02-09');
impressions24h = lastDayStats.reduce((s, d) => s + d.impressions, 0);
clicks24h = lastDayStats.reduce((s, d) => s + d.clicks, 0);
todaySpend = lastDayStats.reduce((s, d) => s + d.spend, 0);
}
const ctr24h = impressions24h > 0 ? Number((clicks24h / impressions24h).toFixed(4)) : 0;
return {
success: true,
data: { activeCampaigns, todaySpend, impressions24h, clicks24h, ctr24h },
};
}