"use server"; import { and, eq, ilike, or, SQL, sql } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; import { withBrand } from "@/db/client"; import { customers, brandSettings } from "@/db/schema"; import type { AudienceRules } from "@/actions/communications/campaigns"; export type SegmentFilterType = | "all_customers" | "stop" | "upcoming_stop" | "product" | "zip_code" | "customer_history" | "tags"; export type SegmentFilterParams = { stop_id?: string; date_from?: string; date_to?: string; zip_codes?: string[]; city?: string; order_history?: "all" | "first_order" | "repeat"; days_back?: number; product_id?: string; tags?: string[]; }; export type SegmentFilter = { type: SegmentFilterType; params: SegmentFilterParams; }; export type SegmentRuleV2 = { combinator: "AND" | "OR"; filters: SegmentFilter[]; }; // AudienceRules is imported from @/actions/communications/campaigns const SEGMENTS_FLAG_KEY = "comm_segments_v1"; export type Segment = { id: string; brand_id: string; name: string; description: string | null; rules: SegmentRuleV2 | AudienceRules; created_by: string | null; created_at: string; updated_at: string; }; export type CustomerSample = { id: string; email: string; fullName: string | null; tags: string[]; phone: string | null; }; export type PreviewResult = { count: number; sample_customers: CustomerSample[]; }; function isSegmentList(v: unknown): v is Segment[] { return Array.isArray(v) && v.every((s) => s && typeof s === "object" && "rules" in s); } async function loadSegments(brandId: string): Promise { const rows = await withBrand(brandId, (db) => db .select({ flags: brandSettings.featureFlags }) .from(brandSettings) .where(eq(brandSettings.brandId, brandId)) .limit(1), ); const raw = (rows[0]?.flags ?? {}) as Record; const list = raw[SEGMENTS_FLAG_KEY]; return isSegmentList(list) ? list : []; } async function saveSegments(brandId: string, segments: Segment[]): Promise { await withBrand(brandId, async (db) => { const existing = await db .select({ flags: brandSettings.featureFlags }) .from(brandSettings) .where(eq(brandSettings.brandId, brandId)) .limit(1); const baseFlags = (existing[0]?.flags ?? {}) as Record; const nextFlags: Record = { ...baseFlags, [SEGMENTS_FLAG_KEY]: segments, }; await db .update(brandSettings) .set({ featureFlags: nextFlags, updatedAt: new Date() }) .where(eq(brandSettings.brandId, brandId)); }); } export async function getHarvestReachSegments( brandId: string ): Promise<{ success: true; segments: Segment[] } | { 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 segments = await loadSegments(brandId); return { success: true, segments }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : "Failed to fetch segments", }; } } export async function upsertHarvestReachSegment(params: { id?: string; brand_id: string; name: string; description?: string; rules: SegmentRuleV2; }): Promise<{ success: true; segment: Segment } | { success: false; error: string }> { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) { return { success: false, error: "Not authorized" }; } try { const segments = await loadSegments(params.brand_id); const now = new Date().toISOString(); let saved: Segment; if (params.id) { const idx = segments.findIndex((s) => s.id === params.id); if (idx === -1) return { success: false, error: "Segment not found" }; saved = { ...segments[idx], name: params.name, description: params.description ?? null, rules: params.rules, updated_at: now, }; segments[idx] = saved; } else { saved = { id: crypto.randomUUID(), brand_id: params.brand_id, name: params.name, description: params.description ?? null, rules: params.rules, created_by: adminUser.id, created_at: now, updated_at: now, }; segments.push(saved); } await saveSegments(params.brand_id, segments); return { success: true, segment: saved }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : "Failed to save segment", }; } } export async function deleteHarvestReachSegment( segmentId: string, brandId: string ): Promise<{ success: boolean; 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 segments = await loadSegments(brandId); const filtered = segments.filter((s) => s.id !== segmentId); if (filtered.length === segments.length) { return { success: false, error: "Segment not found" }; } await saveSegments(brandId, filtered); return { success: true }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : "Failed to delete segment", }; } } /** * The legacy `preview_campaign_audience` RPC evaluated a (combinator + * filter[]) rule tree against a join of customers, orders, products, * etc. The new schema has only `customers` and `orders`; the new * preview therefore counts the tenant's opted-in customers. The * `SegmentRuleV2` shape is preserved for round-tripping but no longer * influences the count. */ export async function previewSegmentWithCustomers( brandId: string, rules: SegmentRuleV2 ): Promise { const adminUser = await getAdminUser(); if (!adminUser) return null; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { return null; } void rules; const conds: SQL[] = [eq(customers.brandId, brandId), eq(customers.emailOptIn, true)]; try { const [samples, counts] = await withBrand(brandId, async (db) => { const sample = await db .select({ id: customers.id, email: customers.email, fullName: customers.fullName, phone: customers.phone, }) .from(customers) .where(and(...conds)) .limit(5); const c = await db .select({ value: sql`count(*)::int` }) .from(customers) .where(and(...conds)); return [sample, c] as const; }); return { count: Number(counts[0]?.value ?? 0), sample_customers: samples.map((s) => ({ id: s.id, email: s.email ?? "", fullName: s.fullName, tags: [], phone: s.phone, })), }; } catch { return { count: 0, sample_customers: [] }; } } void or; void ilike;