916ad39176
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
262 lines
7.2 KiB
TypeScript
262 lines
7.2 KiB
TypeScript
"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<Segment[]> {
|
|
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<string, unknown>;
|
|
const list = raw[SEGMENTS_FLAG_KEY];
|
|
return isSegmentList(list) ? list : [];
|
|
}
|
|
|
|
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
|
|
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<string, unknown>;
|
|
const nextFlags: Record<string, unknown> = {
|
|
...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<PreviewResult | null> {
|
|
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<number>`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;
|