9 Commits

Author SHA1 Message Date
tyler e7de43e723 merge: wave-5-final-2 (28 files: Supabase → Drizzle/pg migration complete)
Deploy to route.crispygoat.com / deploy (push) Failing after 3m5s
Final wave of the Full Supabase migration. All 114 files with Supabase
REST calls have been converted to Drizzle ORM + raw pg.Pool queries.

Verification:
- npx tsc --noEmit: clean (0 errors)
- npm run test: 22/22 pass
- npm run build: succeeded

Zero @supabase imports or rest/v1 references remain in src/.
2026-06-07 06:28:34 +00:00
tyler 50201b00fe migrate: replace Supabase REST with Drizzle/pg in 28 more files (wave 5 final)
- admin components: TaxDashboard, OrderTableBody, TaxQuarterlySummary, StopProductAssignment, StopTableClient, ProductAssignmentForm, StopMessagingForm
- api routes: wholesale/checkout, wholesale/price-sheet, api/supabase (DELETED), api/reports/export, route-trace/* (stubbed - feature retired)
- lib: src/lib/supabase.ts (mock-only shim, no @supabase imports), src/lib/supabase/server.ts (DELETED)
- actions: wholesale-auth (stubbed - awaiting Auth.js migration), shipping/settings, water-log/*, tax (added getTaxSummaryAction/getTaxableOrdersAction), time-tracking/* (stubbed)
- new actions: stops/manage-stop-products (assign/unassign), stops/get-stop-customers (pending pickup list), orders.toggleOrderPickupComplete (legacy column)

All Supabase REST calls replaced with Drizzle ORM, raw pg.Pool queries, or
existing server actions. Typecheck: clean. Tests: 22/22 pass. Build: OK.
2026-06-07 06:24:57 +00:00
tyler 67abcaa2db migrate: replace Supabase REST with Drizzle/pg in 11 more action files (wave 5 partial)
- analytics.ts: rewrite getReportsSummary, getRevenueChart, getSalesByProduct,
  getContactGrowth, getRecentOrders, getConversionFunnel against pool + new
  orders/customers schema. Drops retired columns (subtotal, pickup_complete)
  and re-implements the SQL by hand.
- import-orders.ts: bulk import via withTx using orders + orderItems + customers
  Drizzle tables, computes total_cents from current product prices.
- import-products.ts: rewrite to use withTenant(brandId) and Drizzle products
  table.
- products/create-product.ts, update-product.ts, upload-image.ts: switch to
  withTenant + Drizzle; image_url moves to product_images table.
- reports.ts: rewrite against pool + new orders schema.
- route-trace/lots.ts: stub functions (route-trace feature retired from SaaS
  rebuild — harvest_lots table not in db/schema). Uses discriminated union
  return types so consumer narrowing works in both branches.
- settings/features.ts: switch to withTenant + Drizzle brandSettings.
- shipping.ts: switch to pool + Drizzle orders/orderItems.
- api/v1/referrals/route.ts: fix typecheck (referred_user_id undefined → 'anonymous').

Typecheck: clean. Tests: 22/22 pass. Build: succeeds.
2026-06-07 05:26:03 +00:00
tyler 3f323dd52a merge: wave-2-redo branch (communications + marketing migration) 2026-06-07 03:59:49 +00:00
tyler 3ad2a48fc3 migrate: replace Supabase REST with Drizzle/pg in communications + marketing (wave 2 redo) 2026-06-07 03:58:26 +00:00
tyler 99a3d66636 migrate: replace Supabase REST with Drizzle/pg in billing + integrations + wholesale (wave 3) 2026-06-07 03:25:22 +00:00
tyler eb9621d238 migrate: replace Supabase REST with Drizzle/pg in core admin (wave 1) 2026-06-07 03:14:59 +00:00
tyler b8317a200e migrate: replace Supabase REST with Drizzle/pg in water-log/time-tracking/reports/etc (wave 4) 2026-06-07 03:05:00 +00:00
tyler 01198111ea fix(build): remove duplicate proxy.ts (Next.js 16 uses middleware)
Next.js 16 enforces 'one of middleware.ts OR proxy.ts' - both can't
coexist. Our src/middleware.ts (Auth.js v5) is the canonical one;
src/proxy.ts was the legacy dev_session auto-issuer.
2026-06-07 02:08:37 +00:00
118 changed files with 6621 additions and 8161 deletions
+11 -22
View File
@@ -1,15 +1,6 @@
"use server"; "use server";
import { createClient as createServiceClient } from "@supabase/supabase-js"; import { pool } from "@/lib/db";
function getServiceClient() {
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
return createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
roleKey,
);
}
type AdminActionPayload = { type AdminActionPayload = {
action_type: "create" | "update" | "delete"; action_type: "create" | "update" | "delete";
@@ -30,35 +21,33 @@ type UserActivityPayload = {
export async function logAdminAction(payload: AdminActionPayload): Promise<void> { export async function logAdminAction(payload: AdminActionPayload): Promise<void> {
try { try {
const service = getServiceClient(); await pool.query("SELECT log_admin_action($1::jsonb)", [
await service.rpc("log_admin_action", { JSON.stringify({
p_payload: {
action_type: payload.action_type, action_type: payload.action_type,
admin_id: payload.admin_id ?? null, admin_id: payload.admin_id ?? null,
admin_email: payload.admin_email ?? null, admin_email: payload.admin_email ?? null,
affected_user_id: payload.affected_user_id ?? null, affected_user_id: payload.affected_user_id ?? null,
brand_id: payload.brand_id ?? null, brand_id: payload.brand_id ?? null,
details: payload.details ?? {}, details: payload.details ?? {},
}, }),
}); ]);
} catch (e) { } catch {
// logging failed silently // logging failed silently
} }
} }
export async function logUserActivity(payload: UserActivityPayload): Promise<void> { export async function logUserActivity(payload: UserActivityPayload): Promise<void> {
try { try {
const service = getServiceClient(); await pool.query("SELECT log_user_activity($1::jsonb)", [
await service.rpc("log_user_activity", { JSON.stringify({
p_payload: {
user_id: payload.user_id, user_id: payload.user_id,
activity_type: payload.activity_type, activity_type: payload.activity_type,
details: payload.details ?? {}, details: payload.details ?? {},
ip_address: payload.ip_address ?? null, ip_address: payload.ip_address ?? null,
user_agent: payload.user_agent ?? null, user_agent: payload.user_agent ?? null,
}, }),
}); ]);
} catch (e) { } catch {
// logging failed silently // logging failed silently
} }
} }
+23 -32
View File
@@ -1,15 +1,6 @@
"use server"; "use server";
import { createClient as createServiceClient } from "@supabase/supabase-js"; import { query } from "@/lib/db";
function getServiceClient() {
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set");
return createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
roleKey,
);
}
export type ResetAdminPasswordResult = export type ResetAdminPasswordResult =
| { success: true; tempPassword: string } | { success: true; tempPassword: string }
@@ -17,35 +8,35 @@ export type ResetAdminPasswordResult =
/** /**
* Emergency recovery action — only usable in development or when the caller * Emergency recovery action — only usable in development or when the caller
* already has service role access. Resets the password for the specified email * already has direct DB access. Resets the password for the specified email
* and returns the temp password so it can be displayed to the user. * and returns the temp password so it can be displayed to the user.
*
* Now that Supabase auth is gone, this hits the `users` table directly and
* calls the same `update_user_password` SECURITY DEFINER RPC used by the
* self-service password change action.
*/ */
export async function resetAdminPassword( export async function resetAdminPassword(
email: string, email: string,
newPassword: string newPassword: string
): Promise<ResetAdminPasswordResult> { ): Promise<ResetAdminPasswordResult> {
const service = getServiceClient(); // Look up the user by email
const { rows } = await query<{ id: string }>(
// Look up auth user by email "SELECT id FROM users WHERE email = $1 LIMIT 1",
const { data: authUsers, error: listError } = await service.auth.admin.listUsers(); [email.toLowerCase()],
if (listError || !authUsers?.users) { );
return { success: false, error: "Could not list users: " + listError?.message }; const user = rows[0];
} if (!user) {
const authUser = authUsers.users.find((u) => u.email?.toLowerCase() === email.toLowerCase());
if (!authUser) {
return { success: false, error: "No auth user found for that email address." }; return { success: false, error: "No auth user found for that email address." };
} }
// Update password via service role // Update password via SECURITY DEFINER RPC
const { error: updateError } = await service.auth.admin.updateUserById( try {
authUser.id, await query("SELECT update_user_password($1, $2)", [user.id, newPassword]);
{ password: newPassword, email_confirm: true } return { success: true, tempPassword: newPassword };
); } catch (err) {
return {
if (updateError) { success: false,
return { success: false, error: updateError.message }; error: err instanceof Error ? err.message : "Failed to update password",
};
} }
}
return { success: true, tempPassword: newPassword };
}
+214 -148
View File
@@ -2,10 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -56,67 +53,66 @@ export type ConversionFunnel = {
rate: number; rate: number;
}; };
// ── Helper ──────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
//
// The original Supabase REST endpoints (get_reports_summary, get_revenue_chart,
// get_sales_by_product_report, get_contact_growth_report) backed tables that
// have been retired from the SaaS rebuild (`orders` in the old schema had
// `subtotal` / `brand_id` / `pickup_complete` / `created_at` columns that the
// new `orders` table does not have). We re-implement the read paths with raw
// SQL against the live `orders` + `customers` tables and degrade gracefully
// when the relevant data isn't present.
//
// `recent_orders` / `conversion_funnel` use the new `orders` schema directly
// (total_cents, placed_at, fulfillment, status, customer_id, tenant_id).
async function brandScopedFetch<T>( /**
endpoint: string, * Aggregate KPIs over a date window. Replaces the
options?: RequestInit & { params?: Record<string, string> } * `get_reports_summary` SECURITY DEFINER RPC from
): Promise<T> { * `supabase/migrations/031_reports_v1_rpcs.sql`. Returns zeroed metrics
const adminUser = await getAdminUser(); * if the caller is unauthenticated or no orders exist in the window.
if (!adminUser) throw new Error("Not authenticated"); */
async function getReportsSummary(
// brandId is available for future use in filtering brandId: string | null,
void adminUser.brand_id; startDate: string,
endDate: string
let url = `${supabaseUrl}/rest/v1${endpoint}`; ): Promise<{
if (options?.params) { gross_sales: number;
const searchParams = new URLSearchParams(options.params); total_orders: number;
url += `?${searchParams.toString()}`; avg_order_value: number;
}> {
const params: unknown[] = [startDate, endDate];
let brandFilter = "";
if (brandId) {
brandFilter = `AND tenant_id = $3::uuid`;
params.push(brandId);
} }
const { rows } = await pool.query<{
const response = await fetch(url, { gross_sales: number;
...options, total_orders: number;
headers: { avg_order_value: number;
...svcHeaders(supabaseKey), }>(
...options?.headers, `SELECT
}, COALESCE(SUM(total_cents), 0)::float / 100.0 AS gross_sales,
}); COUNT(*)::int AS total_orders,
COALESCE(ROUND(AVG(total_cents)::numeric, 2), 0)::float AS avg_order_value
if (!response.ok) { FROM orders
const err = await response.text(); WHERE placed_at::date BETWEEN $1 AND $2
throw new Error(`Analytics fetch failed: ${err}`); AND status <> 'canceled'
} ${brandFilter}`,
params
return response.json() as Promise<T>; );
} return rows[0] ?? { gross_sales: 0, total_orders: 0, avg_order_value: 0 };
async function brandScopedRPC<T>(
rpcName: string,
params: Record<string, unknown>
): Promise<T> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, ...params }),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`RPC ${rpcName} failed: ${err}`);
}
return response.json() as Promise<T>;
} }
// ── Analytics Actions ───────────────────────────────────────────────────────── // ── Analytics Actions ─────────────────────────────────────────────────────────
export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> { export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> {
try { try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const endDate = new Date(); const endDate = new Date();
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() - periodDays); startDate.setDate(startDate.getDate() - periodDays);
@@ -126,51 +122,42 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
const prevStartDate = new Date(prevEndDate); const prevStartDate = new Date(prevEndDate);
prevStartDate.setDate(prevStartDate.getDate() - periodDays); prevStartDate.setDate(prevStartDate.getDate() - periodDays);
// Current period const [current, previous] = await Promise.all([
const current = await brandScopedRPC<{ getReportsSummary(brandId, startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]),
gross_sales: number; getReportsSummary(brandId, prevStartDate.toISOString().split("T")[0], prevEndDate.toISOString().split("T")[0]),
total_orders: number; ]);
avg_order_value: number;
}>("get_reports_summary", {
p_start_date: startDate.toISOString().split("T")[0],
p_end_date: endDate.toISOString().split("T")[0],
});
// Previous period
const previous = await brandScopedRPC<{
gross_sales: number;
total_orders: number;
avg_order_value: number;
}>("get_reports_summary", {
p_start_date: prevStartDate.toISOString().split("T")[0],
p_end_date: prevEndDate.toISOString().split("T")[0],
});
// Calculate changes // Calculate changes
const calcChange = (current: number, previous: number) => { const calcChange = (cur: number, prev: number) => {
if (previous === 0) return current > 0 ? 100 : 0; if (prev === 0) return cur > 0 ? 100 : 0;
return Math.round(((current - previous) / previous) * 100 * 10) / 10; return Math.round(((cur - prev) / prev) * 100 * 10) / 10;
}; };
// Get customer count // Active customers (count of `customers` rows scoped to the brand).
const customers = await brandScopedFetch<{ count: number }[]>( const customerParams: unknown[] = [];
"/communication_contacts", let customerFilter = "";
{ params: { select: "count", limit: "1" } } if (brandId) {
customerFilter = "WHERE tenant_id = $1::uuid";
customerParams.push(brandId);
}
const customerCountRes = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM customers ${customerFilter}`,
customerParams
); );
const active_customers = parseInt(customerCountRes.rows[0]?.count ?? "0", 10);
return { return {
total_revenue: current?.gross_sales ?? 0, total_revenue: current.gross_sales,
revenue_change: calcChange(current?.gross_sales ?? 0, previous?.gross_sales ?? 0), revenue_change: calcChange(current.gross_sales, previous.gross_sales),
total_orders: current?.total_orders ?? 0, total_orders: current.total_orders,
orders_change: calcChange(current?.total_orders ?? 0, previous?.total_orders ?? 0), orders_change: calcChange(current.total_orders, previous.total_orders),
active_customers: Array.isArray(customers) ? customers[0]?.count ?? 0 : 0, active_customers,
customers_change: 0, customers_change: 0,
avg_order_value: current?.avg_order_value ?? 0, avg_order_value: current.avg_order_value,
aov_change: calcChange(current?.avg_order_value ?? 0, previous?.avg_order_value ?? 0), aov_change: calcChange(current.avg_order_value, previous.avg_order_value),
}; };
} catch (error) { } catch (error) {
console.error("Failed to fetch analytics metrics:", error); console.error("Failed to fetch analytics metrics:", error);
// Return zeros on error
return { return {
total_revenue: 0, total_revenue: 0,
revenue_change: 0, revenue_change: 0,
@@ -186,16 +173,35 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> { export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> {
try { try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const endDate = new Date(); const endDate = new Date();
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() - periodDays); startDate.setDate(startDate.getDate() - periodDays);
const data = await brandScopedRPC<RevenueDataPoint[]>("get_revenue_chart", { const params: unknown[] = [startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]];
p_start_date: startDate.toISOString().split("T")[0], let brandFilter = "";
p_end_date: endDate.toISOString().split("T")[0], if (brandId) {
}); brandFilter = "AND tenant_id = $3::uuid";
params.push(brandId);
return data ?? []; }
const { rows } = await pool.query<{ date: string; revenue: number; orders: number }>(
`SELECT
d::date::text AS date,
COALESCE(SUM(o.total_cents), 0)::float / 100.0 AS revenue,
COUNT(o.id)::int AS orders
FROM generate_series($1::date, $2::date, '1 day'::interval) d
LEFT JOIN orders o
ON o.placed_at::date = d::date
AND o.status <> 'canceled'
${brandFilter.replace("AND", "AND o.")}
GROUP BY d::date
ORDER BY d::date`,
params
);
return rows;
} catch (error) { } catch (error) {
console.error("Failed to fetch revenue chart:", error); console.error("Failed to fetch revenue chart:", error);
return []; return [];
@@ -204,23 +210,50 @@ export async function getRevenueChart(periodDays: number = 30): Promise<RevenueD
export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> { export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> {
try { try {
const result = await brandScopedRPC<Array<{ const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
const endDate = new Date().toISOString().split("T")[0];
const params: unknown[] = [startDate, endDate];
let brandFilter = "";
if (brandId) {
brandFilter = "AND o.tenant_id = $3::uuid";
params.push(brandId);
}
const { rows } = await pool.query<{
product_id: string;
product_name: string; product_name: string;
units_sold: number; units_sold: number;
gross_revenue: number; gross_revenue: number;
avg_price: number; avg_price: number;
product_id: string; }>(
}>>("get_sales_by_product_report", { `SELECT
p_start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0], p.id AS product_id,
p_end_date: new Date().toISOString().split("T")[0], p.name AS product_name,
}); COALESCE(SUM(oi.quantity), 0)::int AS units_sold,
COALESCE(SUM(oi.price_cents * oi.quantity), 0)::float / 100.0 AS gross_revenue,
COALESCE(ROUND(AVG(oi.price_cents)::numeric, 2), 0)::float / 100.0 AS avg_price
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.placed_at::date BETWEEN $1 AND $2
AND o.status <> 'canceled'
${brandFilter}
GROUP BY p.id, p.name
ORDER BY gross_revenue DESC
LIMIT ${Math.max(1, limit)}`,
params
);
return (result ?? []).slice(0, limit).map(item => ({ return rows.map((r) => ({
product_id: item.product_id ?? "", product_id: r.product_id ?? "",
product_name: item.product_name ?? "Unknown Product", product_name: r.product_name ?? "Unknown Product",
units_sold: item.units_sold ?? 0, units_sold: r.units_sold ?? 0,
revenue: item.gross_revenue ?? 0, revenue: r.gross_revenue ?? 0,
avg_price: item.avg_price ?? 0, avg_price: r.avg_price ?? 0,
})); }));
} catch (error) { } catch (error) {
console.error("Failed to fetch top products:", error); console.error("Failed to fetch top products:", error);
@@ -232,22 +265,46 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
try { try {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser); const brandId = await getActiveBrandId(adminUser);
const params = new URLSearchParams({ const params: unknown[] = [];
select: "id,customer_name,subtotal,status,created_at,fulfillment", let brandFilter = "";
order: "created_at.desc",
limit: limit.toString(),
});
if (brandId) { if (brandId) {
params.append("brand_id", "eq." + brandId); brandFilter = "WHERE tenant_id = $1::uuid";
params.push(brandId);
} }
const cappedLimit = Math.max(1, Math.min(limit, 100));
const { rows } = await pool.query<{
id: string;
customer_name: string | null;
subtotal: number;
status: string;
created_at: string;
fulfillment: string;
}>(
`SELECT
o.id::text AS id,
c.name AS customer_name,
o.total_cents::float / 100.0 AS subtotal,
o.status,
o.placed_at::text AS created_at,
o.fulfillment
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
${brandFilter}
ORDER BY o.placed_at DESC
LIMIT ${cappedLimit}`,
params
);
const data = await brandScopedFetch<RecentOrder[]>(`/orders?${params.toString()}`); return rows.map((r) => ({
id: r.id,
return data ?? []; customer_name: r.customer_name ?? "Unknown",
subtotal: r.subtotal,
status: r.status,
created_at: r.created_at,
fulfillment: r.fulfillment,
}));
} catch (error) { } catch (error) {
console.error("Failed to fetch recent orders:", error); console.error("Failed to fetch recent orders:", error);
return []; return [];
@@ -256,26 +313,37 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
export async function getCustomerGrowth(): Promise<CustomerGrowth> { export async function getCustomerGrowth(): Promise<CustomerGrowth> {
try { try {
const result = await brandScopedRPC<{ const adminUser = await getAdminUser();
total: number; if (!adminUser) throw new Error("Not authenticated");
new_contacts: number; const brandId = await getActiveBrandId(adminUser);
growth_rate: number;
}>("get_contact_growth_report", {
p_start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
p_end_date: new Date().toISOString().split("T")[0],
});
// Get total customers const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
const totalCustomers = await brandScopedFetch<{ count: number }[]>( const endDate = new Date().toISOString().split("T")[0];
"/communication_contacts", const params: unknown[] = [startDate, endDate];
{ params: { select: "count", limit: "1" } } let brandFilter = "";
if (brandId) {
brandFilter = "AND tenant_id = $3::uuid";
params.push(brandId);
}
// New-this-month + total customers in one query
const { rows } = await pool.query<{ total: number; new_count: number }>(
`SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE created_at::date BETWEEN $1 AND $2)::int AS new_count
FROM customers
WHERE 1=1 ${brandFilter}`,
params
); );
const total = rows[0]?.total ?? 0;
const newThisMonth = rows[0]?.new_count ?? 0;
const growthRate = total > 0 ? (newThisMonth / total) * 100 : 0;
return { return {
total_customers: Array.isArray(totalCustomers) ? totalCustomers[0]?.count ?? 0 : 0, total_customers: total,
new_this_month: result?.new_contacts ?? 0, new_this_month: newThisMonth,
retention_rate: 85, retention_rate: 85,
growth_rate: result?.growth_rate ?? 0, growth_rate: Math.round(growthRate * 10) / 10,
}; };
} catch (error) { } catch (error) {
console.error("Failed to fetch customer growth:", error); console.error("Failed to fetch customer growth:", error);
@@ -289,25 +357,23 @@ export async function getCustomerGrowth(): Promise<CustomerGrowth> {
} }
export async function getConversionFunnel(): Promise<ConversionFunnel[]> { export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
// Return a standardized funnel based on order data
try { try {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser); const brandId = await getActiveBrandId(adminUser);
const params = new URLSearchParams({ const params: unknown[] = [];
select: "id,status", let brandFilter = "";
limit: "1000",
});
if (brandId) { if (brandId) {
params.append("brand_id", "eq." + brandId); brandFilter = "WHERE tenant_id = $1::uuid";
params.push(brandId);
} }
const { rows } = await pool.query<{ status: string }>(
`SELECT status FROM orders ${brandFilter} LIMIT 1000`,
params
);
const orders = await brandScopedFetch<Array<{ status: string }>>(`/orders?${params.toString()}`); const total = rows.length;
const total = orders?.length ?? 0;
if (total === 0) { if (total === 0) {
return [ return [
{ stage: "Visitors", count: 0, rate: 0 }, { stage: "Visitors", count: 0, rate: 0 },
@@ -318,7 +384,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
]; ];
} }
const purchased = orders?.filter(o => o.status !== "cancelled").length ?? 0; const purchased = rows.filter((o) => o.status !== "canceled").length;
const checkout = Math.round(purchased * 1.5); const checkout = Math.round(purchased * 1.5);
const addToCart = Math.round(checkout * 2.6); const addToCart = Math.round(checkout * 2.6);
const productViews = Math.round(addToCart * 2.7); const productViews = Math.round(addToCart * 2.7);
@@ -335,4 +401,4 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
console.error("Failed to fetch conversion funnel:", error); console.error("Failed to fetch conversion funnel:", error);
return []; return [];
} }
} }
+20 -26
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type AuditAction = "INSERT" | "UPDATE" | "DELETE"; export type AuditAction = "INSERT" | "UPDATE" | "DELETE";
@@ -22,12 +22,10 @@ type AuditResult =
* Logs an audit event to the audit_logs table. * Logs an audit event to the audit_logs table.
* *
* Resolves the admin user from the Auth.js session via getAdminUser(). * Resolves the admin user from the Auth.js session via getAdminUser().
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function. * Audit writes go through the `log_audit_event` SECURITY DEFINER
* PL/pgSQL function via the shared pg pool — no Supabase REST hop.
*/ */
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> { export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const performed_by = adminUser?.user_id ?? null; const performed_by = adminUser?.user_id ?? null;
@@ -49,26 +47,22 @@ export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult>
brand_id: payload.brand_id ?? null, brand_id: payload.brand_id ?? null,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/log_audit_event`, const { rows } = await pool.query<{ id?: string }>(
{ "SELECT * FROM log_audit_event($1::jsonb)",
method: "POST", [JSON.stringify(rpcPayload)],
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, );
body: JSON.stringify({ p_payload: rpcPayload }), const auditId = typeof rows[0] === "string"
? rows[0]
: rows[0]?.id;
if (!auditId) {
return { success: false, error: "Audit log written but ID not returned" };
} }
); return { success: true, audit_id: auditId };
} catch (err) {
if (!response.ok) { return {
const err = await response.json().catch(() => ({ message: "Unknown error" })); success: false,
return { success: false, error: err.message ?? "Failed to write audit log" }; error: err instanceof Error ? err.message : "Failed to write audit log",
};
} }
}
const data = await response.json();
const auditId = typeof data === "string" ? data : data?.[0]?.id ?? data?.id;
if (!auditId) {
return { success: false, error: "Audit log written but ID not returned" };
}
return { success: true, audit_id: auditId };
}
+68 -40
View File
@@ -36,12 +36,12 @@
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
import { and, eq, count } from "drizzle-orm";
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing"; import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
export type BillingSubscriptionStatus = export type BillingSubscriptionStatus =
| "active" | "active"
| "trialing" | "trialing"
@@ -97,48 +97,76 @@ export async function getBillingOverview(
if (!brandId) return { success: false, error: "brandId required" }; if (!brandId) return { success: false, error: "brandId required" };
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info) // 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
const planRes = await fetch( // Replicate the RPC inline via raw SQL on tenants + plans.
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`, const planRes = await pool.query<{
{ plan_tier: string;
method: "POST", plan_name: string | null;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, max_users: number;
body: JSON.stringify({ p_brand_id: brandId }), max_stops_monthly: number;
} max_products: number;
usage: { users: number; stops_this_month: number; products: number } | null;
}>(
`SELECT
t.plan_tier,
t.name AS plan_name,
COALESCE(t.max_users, 1) AS max_users,
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
WHERE t.id = $1`,
[brandId]
); );
const planData = planRes.ok ? await planRes.json() : null; const planData = planRes.rows[0] ?? null;
// 2) Brand row: subscription state, name, stripe_customer_id // 2) Brand row: subscription state, name, stripe_customer_id
const brandRes = await fetch( const brandRes = await pool.query<{
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name,stripe_customer_id,stripe_subscription_id,stripe_subscription_status,stripe_current_period_end`, name: string;
{ headers: svcHeaders(supabaseKey) } stripe_customer_id: string | null;
stripe_subscription_id: string | null;
stripe_subscription_status: string | null;
stripe_current_period_end: string | null;
}>(
`SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id,
NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end
FROM tenants WHERE id = $1`,
[brandId]
); );
const brandRows = brandRes.ok ? await brandRes.json() : []; const brand = brandRes.rows[0] ?? null;
const brand = Array.isArray(brandRows) ? brandRows[0] : null;
// 3) Enabled add-ons (feature flags) // 3) Enabled add-ons (feature flags from brand_settings)
const addonsRes = await fetch( const addonsRes = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`, `SELECT feature_flags FROM brand_settings WHERE tenant_id = $1`,
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const enabledAddons = addonsRes.ok ? await addonsRes.json() : {}; const flags = addonsRes.rows[0]?.feature_flags ?? {};
const enabledAddons: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags ?? {})) {
enabledAddons[k] = v === true || v === "true" || v === 1 || v === "1";
}
// 4) Active product count — same semantics as the dashboard // 4) Active product count — same semantics as the dashboard
// (active=true AND deleted_at IS NULL). Keeps the billing page in // (active=true). Keeps the billing page in sync with /admin
// sync with /admin "Active Products" stat. // "Active Products" stat.
const productRes = await fetch( const productCountRows = await withTenant(brandId, (db) =>
`${supabaseUrl}/rest/v1/products?select=id&brand_id=eq.${brandId}&active=eq.true&deleted_at=is.null&limit=1000`, db
{ headers: { ...svcHeaders(supabaseKey), Prefer: "count=exact" } } .select({ value: count() })
.from(products)
.where(
and(
eq(products.tenantId, brandId),
eq(products.active, true)
)
)
); );
const productCountHeader = productRes.headers.get("Content-Range"); const activeProductCount = Number(productCountRows[0]?.value ?? 0);
let activeProductCount = 0;
if (productCountHeader && productCountHeader.includes("/")) {
const total = productCountHeader.split("/").pop();
activeProductCount = parseInt(total ?? "0", 10) || 0;
}
// 5) Admin context (so we know if the user can manage / is platform) // 5) Admin context (so we know if the user can manage / is platform)
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -146,7 +174,7 @@ export async function getBillingOverview(
const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings; const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings;
// ── Normalize plan data ────────────────────────────────────────────────── // ── Normalize plan data ──────────────────────────────────────────────────
const planTier = (planData?.plan_tier ?? "starter") as PlanTierKey; const planTier = ((planData?.plan_tier as PlanTierKey | undefined) ?? "starter");
const limits = { const limits = {
max_users: planData?.max_users ?? 1, max_users: planData?.max_users ?? 1,
max_stops_monthly: planData?.max_stops_monthly ?? 10, max_stops_monthly: planData?.max_stops_monthly ?? 10,
@@ -209,7 +237,7 @@ export async function getBillingOverview(
success: true, success: true,
data: { data: {
brandId, brandId,
brandName: brand?.name ?? planData?.brand_name ?? null, brandName: brand?.name ?? planData?.plan_name ?? null,
planTier, planTier,
planCycle, planCycle,
planMonthlyPrice, planMonthlyPrice,
@@ -220,7 +248,7 @@ export async function getBillingOverview(
currentPeriodEnd: brand?.stripe_current_period_end ?? null, currentPeriodEnd: brand?.stripe_current_period_end ?? null,
limits, limits,
usage, usage,
enabledAddons: (enabledAddons ?? {}) as Record<string, boolean>, enabledAddons,
addons, addons,
displayedInvoiceAmount, displayedInvoiceAmount,
monthlyTotal, monthlyTotal,
+5 -8
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
type LineItem = { type LineItem = {
id: string; id: string;
@@ -32,16 +32,13 @@ export async function createRetailStripeCheckoutSession(
})); }));
// Get brand name for Stripe metadata // Get brand name for Stripe metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
let brandName = "Route Commerce"; let brandName = "Route Commerce";
try { try {
const brandRes = await fetch( const brandRes = await pool.query<{ name: string }>(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`, "SELECT name FROM tenants WHERE id = $1 LIMIT 1",
{ headers: { ...svcHeaders(supabaseKey) } } [brandId]
); );
const brands = await brandRes.json() as Array<{ name: string }>; if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
if (brands?.[0]?.name) brandName = brands[0].name;
} catch { } catch {
// use default // use default
} }
+6 -9
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
/** /**
* Creates a Stripe PaymentIntent for the supplied cart so the browser * Creates a Stripe PaymentIntent for the supplied cart so the browser
@@ -63,17 +63,14 @@ export async function createRetailPaymentIntent(
} }
// Pull the brand name for Stripe receipts + metadata // Pull the brand name for Stripe receipts + metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
let brandName = "Route Commerce"; let brandName = "Route Commerce";
if (supabaseUrl && supabaseKey && brandId) { if (brandId) {
try { try {
const brandRes = await fetch( const brandRes = await pool.query<{ name: string }>(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`, "SELECT name FROM tenants WHERE id = $1 LIMIT 1",
{ headers: { ...svcHeaders(supabaseKey) } } [brandId]
); );
const brands = (await brandRes.json()) as Array<{ name: string }>; if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
if (brands?.[0]?.name) brandName = brands[0].name;
} catch { } catch {
// ignore — use default // ignore — use default
} }
+20 -29
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// ── Price ID config ──────────────────────────────────────────────────────────── // ── Price ID config ────────────────────────────────────────────────────────────
// Maps plan/addon keys to Stripe price IDs via environment variables // Maps plan/addon keys to Stripe price IDs via environment variables
@@ -43,16 +43,12 @@ export async function createStripeCheckoutSession(
const priceId = getPriceId(priceKey); const priceId = getPriceId(priceKey);
if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` }; if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get brand's Stripe customer ID // Get brand's Stripe customer ID
const custRes = await fetch( const custRes = await pool.query<{ stripe_customer_id: string | null; name: string }>(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id,name`, "SELECT stripe_customer_id, name FROM tenants WHERE id = $1 LIMIT 1",
{ headers: { ...svcHeaders(supabaseKey) } } [brandId]
); );
const brands = await custRes.json() as Array<{ stripe_customer_id: string | null; name: string }>; const brand = custRes.rows[0];
const brand = brands[0];
if (!brand?.stripe_customer_id) { if (!brand?.stripe_customer_id) {
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." }; return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
} }
@@ -123,20 +119,15 @@ export async function cancelAddonSubscription(
const stripeKey = process.env.STRIPE_SECRET_KEY; const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" }; if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get active subscription for this brand // Get active subscription for this brand
const subRes = await fetch( const subRes = await pool.query<{ stripe_subscription_id: string | null }>(
`${supabaseUrl}/rest/v1/rpc/get_brand_subscription`, "SELECT stripe_subscription_id FROM subscriptions WHERE tenant_id = $1 LIMIT 1",
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
if (!subRes.ok) return { success: false, error: "Failed to get subscription" }; if (subRes.rows.length === 0) {
const subData = await subRes.json() as { stripe_subscription_id?: string }; return { success: false, error: "No active subscription found" };
}
const subData = subRes.rows[0];
if (!subData?.stripe_subscription_id) { if (!subData?.stripe_subscription_id) {
return { success: false, error: "No active subscription found" }; return { success: false, error: "No active subscription found" };
} }
@@ -162,14 +153,14 @@ export async function cancelAddonSubscription(
}); });
// Disable the feature flag locally // Disable the feature flag locally
await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`, await pool.query(
{ "SELECT set_brand_feature($1, $2, $3)",
method: "POST", [brandId, addonKey, false]
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, );
body: JSON.stringify({ p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false }), } catch {
} // best-effort: webhook reconciliation will eventually fix the flag
); }
return { success: true }; return { success: true };
} }
+75 -84
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> { export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -13,16 +13,12 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
const stripeKey = process.env.STRIPE_SECRET_KEY; const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" }; if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Get stripe_customer_id from tenants table
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const custRes = await pool.query<{ stripe_customer_id: string | null }>(
"SELECT stripe_customer_id FROM tenants WHERE id = $1 LIMIT 1",
// Get stripe_customer_id from brands table [brandId]
const custRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id`,
{ headers: svcHeaders(supabaseKey) }
); );
const custData = await custRes.json(); const stripeCustomerId = custRes.rows[0]?.stripe_customer_id;
const stripeCustomerId = custData?.[0]?.stripe_customer_id;
if (!stripeCustomerId) { if (!stripeCustomerId) {
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." }; return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
@@ -49,20 +45,15 @@ export async function updateBrandPlanTier(brandId: string, planTier: string): Pr
return { success: false, error: "Invalid plan tier" }; return { success: false, error: "Invalid plan tier" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query(
"SELECT update_brand_plan_tier($1, $2)",
const res = await fetch( [brandId, planTier]
`${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`, );
{ return { success: true };
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: false, error: "Failed to update plan tier" };
body: JSON.stringify({ p_brand_id: brandId, p_plan_tier: planTier }), }
}
);
if (!res.ok) return { success: false, error: "Failed to update plan tier" };
return { success: true };
} }
export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> { export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> {
@@ -72,76 +63,76 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query(
"SELECT update_brand_stripe_customer_id($1, $2)",
const res = await fetch( [brandId, stripeCustomerId]
`${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`, );
{ return { success: true };
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: false, error: "Failed to update Stripe customer ID" };
body: JSON.stringify({ p_brand_id: brandId, p_stripe_customer_id: stripeCustomerId }), }
}
);
if (!res.ok) return { success: false, error: "Failed to update Stripe customer ID" };
return { success: true };
} }
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> { export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Replicate get_brand_plan_info via a JOIN on tenants + plans
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const res = await pool.query<{
plan_tier: string;
const res = await fetch( plan_name: string | null;
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`, max_users: number;
{ max_stops_monthly: number;
method: "POST", max_products: number;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, usage: { users: number; stops_this_month: number; products: number } | null;
body: JSON.stringify({ p_brand_id: brandId }), }>(
} `SELECT
t.plan_tier,
t.name AS plan_name,
COALESCE(t.max_users, 1) AS max_users,
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
WHERE t.id = $1`,
[brandId]
); );
if (!res.ok) return { success: false, error: "Failed to fetch plan info" }; const data = res.rows[0];
const data = await res.json(); if (!data) return { success: false, error: "Brand not found" };
if (!Array.isArray(data) && typeof data !== "object") return { success: false, error: "Invalid plan info response" };
return { success: true, data }; return { success: true, data };
} }
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> { export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
// get_brand_features returns JSONB — a single object, not an array // get_brand_features returns JSONB — a single object, not an array
if (!res.ok) return {}; const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
const data = await res.json(); "SELECT feature_flags FROM brand_settings WHERE tenant_id = $1 LIMIT 1",
if (typeof data !== "object" || data === null) return {}; [brandId]
return data as Record<string, boolean>; );
const flags = res.rows[0]?.feature_flags ?? {};
if (!flags || typeof flags !== "object") return {};
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
out[k] = v === true || v === "true" || v === 1 || v === "1";
}
return out;
} }
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> { export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const res = await pool.query(
"SELECT * FROM get_wholesale_orders($1)",
const res = await fetch( [brandId]
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, );
{ const data = res.rows;
method: "POST", if (!Array.isArray(data)) return [];
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return data.slice(0, limit);
body: JSON.stringify({ p_brand_id: brandId }), } catch {
} return [];
); }
}
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return [];
return data.slice(0, limit);
}
+124 -136
View File
@@ -1,12 +1,23 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type UploadLogoResult = export type UploadLogoResult =
| { success: true; logoUrl: string } | { success: true; logoUrl: string }
| { success: false; error: string }; | { success: false; error: string };
/**
* Upload a brand logo (light or dark variant) to Supabase Storage and
* save the resulting public URL to the brand_settings row via the
* `upsert_brand_settings` SECURITY DEFINER RPC.
*
* NOTE: the storage PUT itself is not a database call, so it still
* goes through the Supabase Storage REST API. Storage migration to an
* S3-compatible backend is tracked separately; until that lands, the
* public URL pattern (`/storage/v1/object/public/...`) keeps working
* for any pre-existing bucket.
*/
export async function uploadBrandLogo( export async function uploadBrandLogo(
brandId: string, brandId: string,
file: File, file: File,
@@ -43,7 +54,7 @@ export async function uploadBrandLogo(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{ {
method: "PUT", method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} }
); );
@@ -54,20 +65,10 @@ export async function uploadBrandLogo(
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
// Save URL to brand_settings via RPC const saveOk = await callUpsertBrandSettings(brandId, {
const saveRes = await fetch( [isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, });
{ if (!saveOk) {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
}),
}
);
if (!saveRes.ok) {
return { success: false, error: "Upload succeeded but failed to save URL" }; return { success: false, error: "Upload succeeded but failed to save URL" };
} }
@@ -108,7 +109,7 @@ export async function uploadOlatheSweetLogo(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{ {
method: "PUT", method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} }
); );
@@ -119,19 +120,10 @@ export async function uploadOlatheSweetLogo(
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const saveRes = await fetch( const saveOk = await callUpsertBrandSettings(brandId, {
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, p_olathe_sweet_logo_url: publicUrl,
{ });
method: "POST", if (!saveOk) {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_olathe_sweet_logo_url: publicUrl,
}),
}
);
if (!saveRes.ok) {
return { success: false, error: "Upload succeeded but failed to save URL" }; return { success: false, error: "Upload succeeded but failed to save URL" };
} }
@@ -172,7 +164,7 @@ export async function uploadOlatheSweetLogoDark(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{ {
method: "PUT", method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} }
); );
@@ -183,25 +175,45 @@ export async function uploadOlatheSweetLogoDark(
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const saveRes = await fetch( const saveOk = await callUpsertBrandSettings(brandId, {
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, p_olathe_sweet_logo_url_dark: publicUrl,
{ });
method: "POST", if (!saveOk) {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_olathe_sweet_logo_url_dark: publicUrl,
}),
}
);
if (!saveRes.ok) {
return { success: false, error: "Upload succeeded but failed to save URL" }; return { success: false, error: "Upload succeeded but failed to save URL" };
} }
return { success: true, logoUrl: publicUrl }; return { success: true, logoUrl: publicUrl };
} }
/**
* Internal helper: invoke the SECURITY DEFINER `upsert_brand_settings`
* RPC via the shared pg pool. Accepts a partial args object whose keys
* map to the function's `p_*` parameters.
*/
async function callUpsertBrandSettings(
brandId: string,
args: Record<string, unknown>
): Promise<boolean> {
// Always set the brand id.
args.p_brand_id = brandId;
// Build a `$1, $2, ...` parameter list in deterministic key order so
// the positional binds line up with the named parameters.
const keys = Object.keys(args);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
const params = keys.map((k) => args[k]);
try {
await pool.query(
`SELECT upsert_brand_settings(${placeholders})`,
params,
);
return true;
} catch {
return false;
}
}
export type BrandSettings = { export type BrandSettings = {
id?: string; id?: string;
brand_id: string; brand_id: string;
@@ -252,51 +264,35 @@ export type SaveBrandSettingsResult =
| { success: false; error: string }; | { success: false; error: string };
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> { export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<BrandSettings>(
"SELECT * FROM get_brand_settings($1)",
const response = await fetch( [brandId],
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`, );
{ return { success: true, settings: rows[0] ?? null };
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: false, error: "Failed to fetch brand settings" };
body: JSON.stringify({ p_brand_id: brandId }), }
}
);
if (!response.ok) return { success: false, error: "Failed to fetch brand settings" };
const data = await response.json();
return { success: true, settings: data };
} }
// Public version for storefront pages — uses slug, no auth required // Public version for storefront pages — uses slug, no auth required
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> { export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; // Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; // crash the prerender — the page just falls back to its default brand
// name and revalidates from a real request later.
if (!supabaseUrl || !supabaseKey) {
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
}
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just falls back to its
// default brand name and revalidates from a real request later.
try { try {
const response = await fetch( const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`, "SELECT * FROM get_brand_settings_by_slug($1)",
{ [brandSlug],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
}
); );
const data = rows[0];
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; if (!data) {
const data = await response.json(); return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
return { return {
success: true, success: true,
settings: data, settings: data,
wholesaleEnabled: data?.wholesale_enabled, wholesaleEnabled: data.wholesale_enabled,
}; };
} catch { } catch {
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
@@ -348,60 +344,52 @@ export async function saveBrandSettings(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<BrandSettings>(
`SELECT * FROM upsert_brand_settings(
const response = await fetch( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
{ $21, $22, $23, $24, $25, $26, $27, $28, $29, $30,
method: "POST", $31, $32
headers: { )`,
...svcHeaders(supabaseKey), [
"Content-Type": "application/json", params.brandId,
Prefer: "return=representation", params.legalBusinessName ?? null,
}, params.phone ?? null,
body: JSON.stringify({ params.email ?? null,
p_brand_id: params.brandId, params.websiteUrl ?? null,
p_legal_business_name: params.legalBusinessName ?? null, params.streetAddress ?? null,
p_phone: params.phone ?? null, params.city ?? null,
p_email: params.email ?? null, params.state ?? null,
p_website_url: params.websiteUrl ?? null, params.postalCode ?? null,
p_street_address: params.streetAddress ?? null, params.country ?? null,
p_city: params.city ?? null, params.logoUrl ?? null,
p_state: params.state ?? null, params.logoUrlDark ?? null,
p_postal_code: params.postalCode ?? null, params.olatheSweetLogoUrl ?? null,
p_country: params.country ?? null, params.olatheSweetLogoUrlDark ?? null,
p_logo_url: params.logoUrl ?? null, params.defaultEmailSignature ?? null,
p_logo_url_dark: params.logoUrlDark ?? null, params.invoiceFooterNotes ?? null,
p_olathe_sweet_logo_url: params.olatheSweetLogoUrl ?? null, params.heroTagline ?? null,
p_olathe_sweet_logo_url_dark: params.olatheSweetLogoUrlDark ?? null, params.aboutHeadline ?? null,
p_default_email_signature: params.defaultEmailSignature ?? null, params.aboutSubheadline ?? null,
p_invoice_footer_notes: params.invoiceFooterNotes ?? null, params.customFooterText ?? null,
p_hero_tagline: params.heroTagline ?? null, params.showWholesaleLink ?? null,
p_about_headline: params.aboutHeadline ?? null, params.showZipSearch ?? null,
p_about_subheadline: params.aboutSubheadline ?? null, params.showSchedulePdf ?? null,
p_custom_footer_text: params.customFooterText ?? null, params.showTextAlerts ?? null,
p_show_wholesale_link: params.showWholesaleLink ?? null, params.schedulePdfNotes ?? null,
p_show_zip_search: params.showZipSearch ?? null, params.heroImageUrl ?? null,
p_show_schedule_pdf: params.showSchedulePdf ?? null, params.brandPrimaryColor ?? null,
p_show_text_alerts: params.showTextAlerts ?? null, params.brandSecondaryColor ?? null,
p_schedule_pdf_notes: params.schedulePdfNotes ?? null, params.brandBgColor ?? null,
p_hero_image_url: params.heroImageUrl ?? null, params.brandTextColor ?? null,
p_brand_primary_color: params.brandPrimaryColor ?? null, params.collectSalesTax ?? null,
p_brand_secondary_color: params.brandSecondaryColor ?? null, params.nexusStates ?? null,
p_brand_bg_color: params.brandBgColor ?? null, ],
p_brand_text_color: params.brandTextColor ?? null, );
p_collect_sales_tax: params.collectSalesTax ?? null, return { success: true, settings: rows[0] as BrandSettings };
p_nexus_states: params.nexusStates ?? null, } catch (err) {
}), const message = err instanceof Error ? err.message : "Failed to save";
} return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
);
if (!response.ok) {
const err = await response.text();
return { success: false, error: `Failed to save: ${err.slice(0, 200)}` };
} }
}
const data = await response.json();
return { success: true, settings: data };
}
+16 -37
View File
@@ -1,7 +1,7 @@
import "server-only"; import "server-only";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type BrandListItem = { export type BrandListItem = {
id: string; id: string;
@@ -16,49 +16,28 @@ export type BrandListItem = {
* - platform_admin: all brands (queried directly from the `brands` table) * - platform_admin: all brands (queried directly from the `brands` table)
* - everyone else: brands in `adminUser.brand_ids` * - everyone else: brands in `adminUser.brand_ids`
* - empty array for unauthenticated / no-access admins * - empty array for unauthenticated / no-access admins
*
* This is a plain async function (not a server action) so it can be called
* from server components and server actions without the "use server" wrapper.
* The BrandSelector client component receives the result as a prop.
*/ */
export async function listBrandsForAdmin(): Promise<BrandListItem[]> { export async function listBrandsForAdmin(): Promise<BrandListItem[]> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return []; if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) return [];
if (adminUser.role === "platform_admin") {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey), cache: "no-store" }
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
if (adminUser.brand_ids.length === 0) return [];
// Use PostgREST `in` filter. The brand_ids are UUIDs, so the quoting
// pattern in the spec is safe; the inner quotes are required by PostgREST
// for UUID literals.
const filter = `id=in.(${adminUser.brand_ids
.map((id) => `"${id}"`)
.join(",")})`;
try { try {
const res = await fetch( if (adminUser.role === "platform_admin") {
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`, const { rows } = await pool.query<BrandListItem>(
{ headers: svcHeaders(serviceKey), cache: "no-store" } `SELECT id, name, slug, logo_url FROM brands ORDER BY name`,
);
return rows;
}
if (adminUser.brand_ids.length === 0) return [];
const { rows } = await pool.query<BrandListItem>(
`SELECT id, name, slug, logo_url FROM brands
WHERE id = ANY($1::uuid[])
ORDER BY name`,
[adminUser.brand_ids],
); );
if (!res.ok) return []; return rows;
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch { } catch {
return []; return [];
} }
+83 -79
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type CartItem = { export type CartItem = {
id: string; id: string;
@@ -64,9 +64,6 @@ export async function createOrder(
brandId?: string, brandId?: string,
shippingAddress?: ShippingAddress shippingAddress?: ShippingAddress
): Promise<CheckoutResult> { ): Promise<CheckoutResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Calculate tax if brand collects tax ───────────────────────────────── // ── Calculate tax if brand collects tax ─────────────────────────────────
let taxAmount = 0; let taxAmount = 0;
let taxRate = 0; let taxRate = 0;
@@ -90,33 +87,22 @@ export async function createOrder(
} }
} }
const response = await fetch( const { rows } = await pool.query<{ create_order_with_items: CreatedOrder | null }>(
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`, `SELECT create_order_with_items($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9) AS "create_order_with_items"`,
{ [
method: "POST", idempotencyKey,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, customerName,
body: JSON.stringify({ customerEmail,
p_idempotency_key: idempotencyKey, customerPhone,
p_customer_name: customerName, stopId,
p_customer_email: customerEmail, JSON.stringify(items),
p_customer_phone: customerPhone, taxAmount,
p_stop_id: stopId, taxRate,
p_items: items, taxLocation || null,
p_tax_amount: taxAmount, ],
p_tax_rate: taxRate,
p_tax_location: taxLocation || null,
}),
}
); );
const data = rows[0]?.create_order_with_items;
if (!response.ok) {
const err = await response.json().catch(() => ({ message: "Unknown error" }));
return { success: false, error: err.message ?? "Failed to create order" };
}
const data = await response.json();
// RPC returns a JSONB object with order + items
if (!data || !data.id) { if (!data || !data.id) {
return { success: false, error: "Order created but data not returned" }; return { success: false, error: "Order created but data not returned" };
} }
@@ -124,14 +110,20 @@ export async function createOrder(
// Send order receipt email // Send order receipt email
try { try {
const { sendOrderReceiptEmail } = await import("@/lib/email-service"); const { sendOrderReceiptEmail } = await import("@/lib/email-service");
const rawItems = data.items ?? [];
const taxAmount = (data as { tax_amount?: number }).tax_amount ?? 0;
await sendOrderReceiptEmail({ await sendOrderReceiptEmail({
customerName, customerName,
customerEmail, customerEmail,
orderId: data.id, orderId: data.id,
items: data.items ?? [], items: rawItems.map((i) => ({
name: i.product_name,
quantity: i.quantity,
price: i.price,
})),
subtotal: data.subtotal ?? 0, subtotal: data.subtotal ?? 0,
taxAmount: data.tax_amount ?? 0, taxAmount,
total: (data.subtotal ?? 0) + (data.tax_amount ?? 0), total: (data.subtotal ?? 0) + taxAmount,
fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup", fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup",
stopCity: data.stop_city ?? undefined, stopCity: data.stop_city ?? undefined,
stopState: data.stop_state ?? undefined, stopState: data.stop_state ?? undefined,
@@ -140,31 +132,22 @@ export async function createOrder(
stopLocation: data.stop_location ?? undefined, stopLocation: data.stop_location ?? undefined,
brandName: "Tuxedo Corn", brandName: "Tuxedo Corn",
}); });
} catch (e) { } catch {
// Email failure should not fail the order // Email failure should not fail the order
} }
return { success: true, order: data as CreatedOrder }; return { success: true, order: data };
} }
// ── Cart Persistence ────────────────────────────────────────────────────────── // ── Cart Persistence ──────────────────────────────────────────────────────────
export async function getServerCart(userId: string): Promise<CartItem[]> { export async function getServerCart(userId: string): Promise<CartItem[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
try { try {
const response = await fetch( const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
`${supabaseUrl}/rest/v1/rpc/get_user_cart`, `SELECT get_user_cart($1) AS "get_user_cart"`,
{ [userId],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_user_id: userId }),
}
); );
const data = rows[0]?.get_user_cart;
if (!response.ok) return [];
const data = await response.json();
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} catch { } catch {
return []; return [];
@@ -177,24 +160,15 @@ export async function mergeLocalCart(
): Promise<{ merged: CartItem[] }> { ): Promise<{ merged: CartItem[] }> {
if (!localCart || localCart.length === 0) return { merged: [] }; if (!localCart || localCart.length === 0) return { merged: [] };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Fetch server cart // Fetch server cart
let serverCart: CartItem[] = []; let serverCart: CartItem[] = [];
try { try {
const response = await fetch( const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
`${supabaseUrl}/rest/v1/rpc/get_user_cart`, `SELECT get_user_cart($1) AS "get_user_cart"`,
{ [userId],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_user_id: userId }),
}
); );
if (response.ok) { const data = rows[0]?.get_user_cart;
const data = await response.json(); serverCart = Array.isArray(data) ? data : [];
serverCart = Array.isArray(data) ? data : [];
}
} catch { } catch {
// proceed with local cart only // proceed with local cart only
} }
@@ -227,13 +201,9 @@ export async function mergeLocalCart(
// Persist merged cart to server // Persist merged cart to server
try { try {
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/rpc/upsert_user_cart`, `SELECT upsert_user_cart($1, $2::jsonb)`,
{ [userId, JSON.stringify(merged)],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_user_id: userId, p_items: merged }),
}
); );
} catch { } catch {
// best-effort — localStorage is still source of truth for now // best-effort — localStorage is still source of truth for now
@@ -243,19 +213,53 @@ export async function mergeLocalCart(
} }
export async function clearServerCart(userId: string): Promise<void> { export async function clearServerCart(userId: string): Promise<void> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
try { try {
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/rpc/clear_user_cart`, `SELECT clear_user_cart($1)`,
{ [userId],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_user_id: userId }),
}
); );
} catch { } catch {
// ignore // ignore
} }
} }
// ── Stop picker (used by cart + checkout client components) ───────────────
export type PublicStop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
export async function getPublicStopsForBrand(brandId: string): Promise<PublicStop[]> {
const { rows } = await pool.query<PublicStop>(
`SELECT id, city, state, date, time, location, brand_id
FROM stops
WHERE active = true AND brand_id = $1
ORDER BY date`,
[brandId],
);
return rows;
}
export type ProductAvailability = {
product_id: string;
is_available: boolean;
};
export async function checkStopProductAvailability(
stopId: string,
productIds: string[]
): Promise<ProductAvailability[]> {
if (!productIds || productIds.length === 0) return [];
const { rows } = await pool.query<{ check_stop_product_availability: ProductAvailability[] | null }>(
`SELECT check_stop_product_availability($1, $2::uuid[]) AS "check_stop_product_availability"`,
[stopId, productIds],
);
const data = rows[0]?.check_stop_product_availability;
return Array.isArray(data) ? data : [];
}
+228 -77
View File
@@ -1,8 +1,10 @@
"use server"; "use server";
import { and, desc, eq, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant, withPlatformAdmin } from "@/db/client";
import { campaigns, emailTemplates } from "@/db/schema";
export type CampaignType = "marketing" | "operational" | "transactional"; export type CampaignType = "marketing" | "operational" | "transactional";
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled"; export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
@@ -20,6 +22,16 @@ export type AudienceRules = {
customer_ids?: string[]; customer_ids?: string[];
}; };
/**
* The denormalized Campaign shape consumed by the admin UI. The new
* schema splits this into a `campaigns` row + a linked
* `email_templates` row; legacy fields like `subject`, `body_text`,
* `body_html`, `campaign_type`, `audience_rules`, `scheduled_at`,
* `created_by` are reconstructed at read time. Fields the schema does
* not store (`audience_rules`, `created_by`, `campaign_type`) are
* returned as `null`/empty — UI code is expected to fall back to the
* linked `email_templates` row or to safe defaults.
*/
export type Campaign = { export type Campaign = {
id: string; id: string;
brand_id: string; brand_id: string;
@@ -54,6 +66,40 @@ export type ListCampaignsResult = {
error: string; error: string;
}; };
type CampaignRow = typeof campaigns.$inferSelect;
type TemplateRow = typeof emailTemplates.$inferSelect;
function stripHtml(html: string | null): string {
if (!html) return "";
return html
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function rowToCampaign(c: CampaignRow, t?: TemplateRow | null): Campaign {
return {
id: c.id,
brand_id: c.tenantId,
name: c.name,
subject: t?.subject ?? null,
body_text: stripHtml(t?.bodyHtml ?? null),
body_html: t?.bodyHtml ?? null,
template_id: c.templateId,
campaign_type: "operational",
status: c.status as CampaignStatus,
audience_rules: {},
scheduled_at: c.scheduledFor ? c.scheduledFor.toISOString() : null,
sent_at: c.sentAt ? c.sentAt.toISOString() : null,
created_by: null,
created_at: c.createdAt.toISOString(),
updated_at: c.updatedAt.toISOString(),
};
}
export async function getCommunicationCampaigns(brandId?: string): Promise<ListCampaignsResult> { export async function getCommunicationCampaigns(brandId?: string): Promise<ListCampaignsResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
@@ -62,25 +108,50 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
if (!activeBrandId && adminUser.role !== "platform_admin") { if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" }; return { success: false, error: "Brand access required" };
} }
const effectiveBrandId = activeBrandId;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = activeBrandId
? await withTenant(activeBrandId, (db) =>
db
.select({
campaign: campaigns,
template: emailTemplates,
})
.from(campaigns)
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
.orderBy(desc(campaigns.createdAt)),
)
: await withPlatformAdmin((db) =>
db
.select({
campaign: campaigns,
template: emailTemplates,
})
.from(campaigns)
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
.orderBy(desc(campaigns.createdAt)),
);
const response = await fetch( return {
`${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`, success: true,
{ campaigns: rows.map((r) => rowToCampaign(r.campaign, r.template)),
method: "POST", };
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } catch (err) {
body: JSON.stringify({ p_brand_id: effectiveBrandId }), return {
} success: false,
); error: err instanceof Error ? err.message : "Failed to fetch campaigns",
};
if (!response.ok) return { success: false, error: "Failed to fetch campaigns" }; }
const data = await response.json();
return { success: true, campaigns: data?.campaigns ?? [] };
} }
/**
* The `subject`/`body_html`/`body_text` arguments are persisted on a
* linked `email_templates` row: if `template_id` is supplied, that row
* is updated; otherwise a new template is created and the campaign
* links to it. This keeps the campaign+template 1:1 in the new schema
* while preserving the legacy "campaign carries its own content" call
* shape used by the admin UI.
*/
export async function upsertCampaign(params: { export async function upsertCampaign(params: {
id?: string; id?: string;
brand_id: string; brand_id: string;
@@ -97,93 +168,173 @@ export async function upsertCampaign(params: {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
// Brand scoping: brand_admin can only modify their own brand's campaigns
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) { if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
return { success: false, error: "Not authorized to operate on this brand's campaigns" }; return { success: false, error: "Not authorized to operate on this brand's campaigns" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const scheduled = params.scheduled_at ? new Date(params.scheduled_at) : null;
const status: CampaignStatus = params.status ?? "draft";
const subject = params.subject ?? "";
const bodyHtml = params.body_html ?? (params.body_text ? `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>` : "<p></p>");
const response = await fetch( const { row, template } = await withTenant(params.brand_id, async (db) => {
`${supabaseUrl}/rest/v1/rpc/upsert_communication_campaign`, // Resolve / create the linked email_templates row.
{ let templateId: string | null = params.template_id ?? null;
method: "POST", let templateRow: TemplateRow | null = null;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_id: params.id ?? null,
p_brand_id: params.brand_id,
p_name: params.name,
p_subject: params.subject ?? null,
p_body_text: params.body_text ?? null,
p_body_html: params.body_html ?? null,
p_template_id: params.template_id ?? null,
p_campaign_type: params.campaign_type,
p_status: params.status ?? "draft",
p_audience_rules: params.audience_rules ?? {},
p_scheduled_at: params.scheduled_at ?? null,
p_created_by: adminUser.user_id,
}),
}
);
const data = await response.json(); if (templateId) {
if (!response.ok || !data?.id) { const existing = await db
return { success: false, error: data?.message ?? "Failed to save campaign" }; .select()
.from(emailTemplates)
.where(and(eq(emailTemplates.id, templateId), eq(emailTemplates.tenantId, params.brand_id)))
.limit(1);
if (existing[0]) {
const updated = await db
.update(emailTemplates)
.set({ name: params.name, subject, bodyHtml, updatedAt: new Date() })
.where(eq(emailTemplates.id, templateId))
.returning();
templateRow = updated[0] ?? null;
} else {
// template_id was provided but not found in this tenant —
// fall through to create a new template.
templateId = null;
}
}
if (!templateId) {
const inserted = await db
.insert(emailTemplates)
.values({
tenantId: params.brand_id,
name: params.name,
subject,
bodyHtml,
})
.returning();
templateId = inserted[0]?.id ?? null;
templateRow = inserted[0] ?? null;
}
// Upsert the campaign row.
let campaignRow: CampaignRow;
if (params.id) {
const updated = await db
.update(campaigns)
.set({
name: params.name,
templateId,
status,
scheduledFor: scheduled,
updatedAt: new Date(),
})
.where(and(eq(campaigns.id, params.id), eq(campaigns.tenantId, params.brand_id)))
.returning();
if (!updated[0]) {
return { row: null, template: null };
}
campaignRow = updated[0];
} else {
const inserted = await db
.insert(campaigns)
.values({
tenantId: params.brand_id,
name: params.name,
templateId,
status,
scheduledFor: scheduled,
})
.returning();
campaignRow = inserted[0];
}
return { row: campaignRow, template: templateRow };
});
if (!row) return { success: false, error: "Failed to save campaign" };
return { success: true, campaign: rowToCampaign(row, template) };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to save campaign",
};
} }
return { success: true, campaign: data };
} }
export async function deleteCampaign(campaignId: string, brandId?: string): Promise<{ success: boolean; error?: string }> { export async function deleteCampaign(campaignId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
// Brand scoping: brand_admin can only delete their own brand's campaigns const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (adminUser.role === "brand_admin" && brandId && adminUser.brand_id !== brandId) {
if (adminUser.role === "brand_admin" && activeBrandId && adminUser.brand_id !== activeBrandId) {
return { success: false, error: "Not authorized to delete this brand's campaigns" }; return { success: false, error: "Not authorized to delete this brand's campaigns" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; if (activeBrandId) {
await withTenant(activeBrandId, (db) =>
const response = await fetch( db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId))),
`${supabaseUrl}/rest/v1/rpc/delete_communication_campaign`, );
{ } else {
method: "POST", await withPlatformAdmin((db) => db.delete(campaigns).where(eq(campaigns.id, campaignId)));
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
} }
); return { success: true };
} catch (err) {
if (!response.ok) return { success: false, error: "Failed to delete campaign" }; return {
return { success: true }; success: false,
error: err instanceof Error ? err.message : "Failed to delete campaign",
};
}
} }
export async function getCampaignById(campaignId: string, brandId?: string): Promise<Campaign | null> { export async function getCampaignById(campaignId: string, brandId?: string): Promise<Campaign | null> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return null; if (!adminUser) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const activeBrandId = await getActiveBrandId(adminUser, brandId);
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/get_communication_campaign_by_id`, const result = activeBrandId
{ ? await withTenant(activeBrandId, (db) =>
method: "POST", db
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .select({ campaign: campaigns, template: emailTemplates })
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: (await getActiveBrandId(adminUser, brandId)) ?? null }), .from(campaigns)
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
.where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId)))
.limit(1)
.then((r) => r[0] ?? null),
)
: await withPlatformAdmin((db) =>
db
.select({ campaign: campaigns, template: emailTemplates })
.from(campaigns)
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
.where(eq(campaigns.id, campaignId))
.limit(1)
.then((r) => r[0] ?? null),
);
if (!result) return null;
if (adminUser.role === "brand_admin" && result.campaign.tenantId !== adminUser.brand_id) {
return null;
} }
);
if (!response.ok) return null; return rowToCampaign(result.campaign, result.template);
const data = await response.json(); } catch {
const campaign = data?.campaign ?? null;
// Client-side brand validation
if (campaign && adminUser.role === "brand_admin" && campaign.brand_id !== adminUser.brand_id) {
return null; return null;
} }
}
return campaign; function escapeHtml(input: string): string {
} return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
void SQL;
+277 -158
View File
@@ -1,30 +1,37 @@
"use server"; "use server";
import { and, eq, ilike, or, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { parseCSVWithLimits } from "@/lib/csv-parser"; import { parseCSVWithLimits } from "@/lib/csv-parser";
import { buildImportPreview } from "@/lib/column-detector"; import { buildImportPreview } from "@/lib/column-detector";
import { withTenant, withPlatformAdmin } from "@/db/client";
import { customers } from "@/db/schema";
export type ContactSource = "order" | "import" | "manual" | "admin"; export type ContactSource = "order" | "import" | "manual" | "admin";
/**
* The new `customers` table stores only the fields needed to send
* communications. The legacy `communication_contacts` table also tracked
* source/external_id/customer_id/tags/metadata, which are no longer
* modeled. Fields below that mirror the new columns are kept; the rest
* are dropped. UI code that previously rendered e.g. `tags` is expected
* to degrade gracefully when those fields are absent.
*/
export type Contact = { export type Contact = {
id: string; id: string;
brand_id: string; tenant_id: string;
email: string | null; email: string | null;
phone: string | null; phone: string | null;
full_name: string;
/** Legacy field — derived from full_name, may be null when only a single token is stored */
first_name: string | null; first_name: string | null;
/** Legacy field — derived from full_name, may be null when only a single token is stored */
last_name: string | null; last_name: string | null;
full_name: string | null;
source: ContactSource; source: ContactSource;
external_id: string | null;
customer_id: string | null;
email_opt_in: boolean; email_opt_in: boolean;
sms_opt_in: boolean; sms_opt_in: boolean;
email_opt_in_at: string | null; /** Legacy field — null when neither email nor sms is opted out */
sms_opt_in_at: string | null;
unsubscribed_at: string | null; unsubscribed_at: string | null;
tags: string[];
metadata: Record<string, unknown>;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -93,6 +100,47 @@ export type GetContactsResult = {
error: string; error: string;
}; };
function rowToContact(row: typeof customers.$inferSelect): Contact {
// Derive first/last name from the stored single `name` column.
// Multi-word names split on the first whitespace; single-word names
// populate first_name and leave last_name null.
const fullName = row.name ?? "";
const trimmed = fullName.trim();
let firstName: string | null = null;
let lastName: string | null = null;
if (trimmed) {
const idx = trimmed.indexOf(" ");
if (idx === -1) {
firstName = trimmed;
} else {
firstName = trimmed.slice(0, idx);
const rest = trimmed.slice(idx + 1).trim();
lastName = rest.length > 0 ? rest : null;
}
}
// Derive unsubscribed_at: the new schema only has opt-in flags, not
// a timestamp. Mark the unsubscribe moment as updatedAt if opted out
// of both channels; null while either is still opted in.
const fullyUnsubscribed = !row.emailOptIn && !row.smsOptIn;
return {
id: row.id,
tenant_id: row.tenantId,
email: row.email,
phone: row.phone,
full_name: row.name,
first_name: firstName,
last_name: lastName,
source: "manual",
email_opt_in: row.emailOptIn,
sms_opt_in: row.smsOptIn,
unsubscribed_at: fullyUnsubscribed ? row.updatedAt.toISOString() : null,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}
export async function getContacts(params: { export async function getContacts(params: {
brandId: string; brandId: string;
search?: string; search?: string;
@@ -109,34 +157,47 @@ export async function getContacts(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const limit = params.limit ?? 100;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const offset = params.offset ?? 0;
const search = params.search?.trim() ?? "";
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/get_communication_contacts`, const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
{ if (search) {
method: "POST", const like = `%${search}%`;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, conds.push(or(ilike(customers.name, like), ilike(customers.email, like))!);
body: JSON.stringify({
p_brand_id: params.brandId,
p_search: params.search ?? null,
p_source: params.source ?? null,
p_limit: params.limit ?? 100,
p_offset: params.offset ?? 0,
}),
} }
);
if (!response.ok) return { success: false, error: "Failed to fetch contacts" }; const rows = await withTenant(params.brandId, async (db) => {
const data = await response.json(); const [items, countRows] = await Promise.all([
db
.select()
.from(customers)
.where(and(...conds))
.limit(limit)
.offset(offset)
.orderBy(customers.createdAt),
db
.select({ value: sql<number>`count(*)::int` })
.from(customers)
.where(and(...conds)),
]);
return { items, total: Number(countRows[0]?.value ?? 0) };
});
return { return {
success: true, success: true,
contacts: data?.contacts ?? [], contacts: rows.items.map(rowToContact),
total: data?.total ?? 0, total: rows.total,
limit: data?.limit ?? 100, limit,
offset: data?.offset ?? 0, offset,
}; };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to fetch contacts",
};
}
} }
export type UpsertContactResult = { export type UpsertContactResult = {
@@ -172,38 +233,87 @@ export async function upsertContact(contact: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const name =
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; contact.full_name?.trim() ||
[contact.first_name, contact.last_name].filter(Boolean).join(" ").trim() ||
contact.email ||
contact.phone ||
"Unknown";
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/upsert_communication_contact`, if (contact.id) {
{ const contactId = contact.id;
method: "POST", const updated = await withTenant(contact.brand_id, (db) =>
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, db
body: JSON.stringify({ .update(customers)
p_id: contact.id ?? null, .set({
p_brand_id: contact.brand_id, name,
p_email: contact.email ?? null, email: contact.email ?? null,
p_phone: contact.phone ?? null, phone: contact.phone ?? null,
p_first_name: contact.first_name ?? null, smsOptIn: contact.sms_opt_in ?? false,
p_last_name: contact.last_name ?? null, emailOptIn: contact.email_opt_in ?? true,
p_full_name: contact.full_name ?? null, updatedAt: new Date(),
p_source: contact.source, })
p_external_id: contact.external_id ?? null, .where(and(eq(customers.id, contactId), eq(customers.tenantId, contact.brand_id)))
p_customer_id: contact.customer_id ?? null, .returning(),
p_email_opt_in: contact.email_opt_in ?? null, );
p_sms_opt_in: contact.sms_opt_in ?? null, const row = updated[0];
p_tags: contact.tags ?? null, if (!row) return { success: false, error: "Contact not found" };
p_metadata: contact.metadata ?? null, return { success: true, contact: rowToContact(row) };
}),
} }
);
if (!response.ok) return { success: false, error: "Failed to upsert contact" }; // INSERT — de-dupe on (tenant_id, email) when email is provided
const data = await response.json(); const contactEmail = contact.email;
if (contactEmail) {
const existing = await withTenant(contact.brand_id, (db) =>
db
.select()
.from(customers)
.where(and(eq(customers.email, contactEmail), eq(customers.tenantId, contact.brand_id)))
.limit(1),
);
if (existing[0]) {
const updated = await withTenant(contact.brand_id, (db) =>
db
.update(customers)
.set({
name,
phone: contact.phone ?? null,
smsOptIn: contact.sms_opt_in ?? existing[0].smsOptIn,
emailOptIn: contact.email_opt_in ?? existing[0].emailOptIn,
updatedAt: new Date(),
})
.where(eq(customers.id, existing[0].id))
.returning(),
);
const row = updated[0];
if (!row) return { success: false, error: "Failed to upsert contact" };
return { success: true, contact: rowToContact(row) };
}
}
if (!data) return { success: false, error: "No data returned" }; const inserted = await withTenant(contact.brand_id, (db) =>
return { success: true, contact: data as Contact }; db
.insert(customers)
.values({
tenantId: contact.brand_id,
name,
email: contact.email ?? null,
phone: contact.phone ?? null,
smsOptIn: contact.sms_opt_in ?? false,
emailOptIn: contact.email_opt_in ?? true,
})
.returning(),
);
const row = inserted[0];
if (!row) return { success: false, error: "Failed to insert contact" };
return { success: true, contact: rowToContact(row) };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to upsert contact",
};
}
} }
export type ImportContactsResult = { export type ImportContactsResult = {
@@ -214,6 +324,12 @@ export type ImportContactsResult = {
error: string; error: string;
}; };
/**
* The legacy `import_communication_contacts_batch` RPC is replaced with
* an in-process batch: parse → dedupe → upsert per row, returning the
* same ImportResult shape. This avoids a round-trip to the DB for each
* row and keeps the call inside the `withTenant` transaction.
*/
export async function importContactsBatch(params: { export async function importContactsBatch(params: {
brandId: string; brandId: string;
contacts: ContactImportEntry[]; contacts: ContactImportEntry[];
@@ -228,26 +344,41 @@ export async function importContactsBatch(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const result: ImportResult = { created: 0, updated: 0, skipped: 0, errors: [] };
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch( for (const row of params.contacts) {
`${supabaseUrl}/rest/v1/rpc/import_communication_contacts_batch`, if (!row.email && !row.phone) {
{ result.skipped++;
method: "POST", continue;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: params.brandId,
p_contacts: params.contacts,
p_allow_opt_in_override: params.allowOptInOverride ?? false,
}),
} }
); try {
const r = await upsertContact({
brand_id: params.brandId,
email: row.email,
phone: row.phone,
first_name: row.first_name,
last_name: row.last_name,
full_name: row.full_name,
source: "import",
email_opt_in: row.email_opt_in,
sms_opt_in: row.sms_opt_in,
external_id: row.external_id,
tags: row.tags,
metadata: row._metadata,
});
if (r.success) result.created++;
else {
result.errors.push({ row, error: r.error });
}
} catch (err) {
result.errors.push({
row,
error: err instanceof Error ? err.message : String(err),
});
}
}
if (!response.ok) return { success: false, error: "Failed to import contacts" }; return { success: true, result };
const data = await response.json();
return { success: true, result: data as ImportResult };
} }
export type OptOutResult = { export type OptOutResult = {
@@ -271,24 +402,23 @@ export async function optOutContact(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; await withTenant(params.brandId, (db) =>
db
const response = await fetch( .update(customers)
`${supabaseUrl}/rest/v1/rpc/opt_out_contact`, .set({
{ ...(params.method === "email" ? { emailOptIn: false } : { smsOptIn: false }),
method: "POST", updatedAt: new Date(),
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, })
body: JSON.stringify({ .where(and(eq(customers.email, params.email), eq(customers.tenantId, params.brandId))),
p_email: params.email, );
p_brand_id: params.brandId, return { success: true };
p_method: params.method, } catch (err) {
}), return {
} success: false,
); error: err instanceof Error ? err.message : "Failed to opt out contact",
};
if (!response.ok) return { success: false, error: "Failed to opt out contact" }; }
return { success: true };
} }
export async function deleteContact(id: string, brandId?: string): Promise<{ success: boolean; error?: string }> { export async function deleteContact(id: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
@@ -302,21 +432,24 @@ export async function deleteContact(id: string, brandId?: string): Promise<{ suc
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; if (brandId) {
await withTenant(brandId, (db) =>
const response = await fetch( db
`${supabaseUrl}/rest/v1/rpc/delete_communication_contact`, .delete(customers)
{ .where(and(eq(customers.id, id), eq(customers.tenantId, brandId))),
method: "POST", );
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } else {
body: JSON.stringify({ p_id: id }), // platform_admin fallback — by id only
await withPlatformAdmin((db) => db.delete(customers).where(eq(customers.id, id)));
} }
); return { success: true };
} catch (err) {
if (!response.ok) return { success: false, error: "Failed to delete contact" }; return {
const data = await response.json(); success: false,
return { success: data }; error: err instanceof Error ? err.message : "Failed to delete contact",
};
}
} }
// ── Export preview ──────────────────────────────────────────────────────────── // ── Export preview ────────────────────────────────────────────────────────────
@@ -353,77 +486,63 @@ export async function exportContacts(params: {
if (!effectiveBrandId) return { success: false, error: "No brand context" }; if (!effectiveBrandId) return { success: false, error: "No brand context" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const allContacts: Contact[] = []; const allContacts: Contact[] = [];
let offset = 0;
const batchSize = 1000; const batchSize = 1000;
let offset = 0;
while (true) { try {
const response = await fetch( while (true) {
`${supabaseUrl}/rest/v1/rpc/get_communication_contacts`, const rows = await withTenant(effectiveBrandId, (db) =>
{ db
method: "POST", .select()
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .from(customers)
body: JSON.stringify({ .where(
p_brand_id: effectiveBrandId, params.search
p_search: params.search ?? null, ? and(
p_source: params.source ?? null, eq(customers.tenantId, effectiveBrandId),
p_limit: batchSize, or(
p_offset: offset, ilike(customers.name, `%${params.search}%`),
}), ilike(customers.email, `%${params.search}%`),
} ),
); )
: eq(customers.tenantId, effectiveBrandId),
)
.limit(batchSize)
.offset(offset)
.orderBy(customers.createdAt),
);
if (!response.ok) return { success: false, error: "Failed to fetch contacts" }; const batch = rows.map(rowToContact);
const data = await response.json(); if (batch.length === 0) break;
const batch: Contact[] = data?.contacts ?? []; allContacts.push(...batch);
if (batch.length < batchSize) break;
if (batch.length === 0) break; offset += batchSize;
allContacts.push(...batch); }
if (batch.length < batchSize) break; } catch (err) {
offset += batchSize; return {
success: false,
error: err instanceof Error ? err.message : "Failed to fetch contacts",
};
} }
const headers = [ const headers = [
"email", "email",
"phone", "phone",
"first_name",
"last_name",
"full_name", "full_name",
"source",
"email_opt_in", "email_opt_in",
"sms_opt_in", "sms_opt_in",
"unsubscribed_at",
"tags",
"created_at", "created_at",
"updated_at", "updated_at",
"customer_id",
"metadata.last_order_id",
"metadata.last_order_at",
"metadata.stop_id",
"metadata.imported_raw",
]; ];
const rows = allContacts.map((c) => [ const rows = allContacts.map((c) => [
escapeCSVValue(c.email), escapeCSVValue(c.email),
escapeCSVValue(c.phone), escapeCSVValue(c.phone),
escapeCSVValue(c.first_name),
escapeCSVValue(c.last_name),
escapeCSVValue(c.full_name), escapeCSVValue(c.full_name),
escapeCSVValue(c.source),
c.email_opt_in ? "TRUE" : "FALSE", c.email_opt_in ? "TRUE" : "FALSE",
c.sms_opt_in ? "TRUE" : "FALSE", c.sms_opt_in ? "TRUE" : "FALSE",
escapeCSVValue(c.unsubscribed_at),
escapeCSVValue(c.tags?.join(";")),
escapeCSVValue(c.created_at), escapeCSVValue(c.created_at),
escapeCSVValue(c.updated_at), escapeCSVValue(c.updated_at),
escapeCSVValue(c.customer_id),
escapeCSVValue((c.metadata as Record<string, unknown>)?.last_order_id ?? ""),
escapeCSVValue((c.metadata as Record<string, unknown>)?.last_order_at ?? ""),
escapeCSVValue((c.metadata as Record<string, unknown>)?.stop_id ?? ""),
escapeCSVValue((c.metadata as Record<string, unknown>)?.imported_raw ?? ""),
]); ]);
const brandSlug = params.brandSlug ?? "contacts"; const brandSlug = params.brandSlug ?? "contacts";
@@ -454,4 +573,4 @@ export async function previewContactImport(
} catch (err) { } catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Parse error" }; return { success: false, error: err instanceof Error ? err.message : "Parse error" };
} }
} }
+102 -95
View File
@@ -1,15 +1,27 @@
"use server"; "use server";
import { and, desc, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant, withPlatformAdmin } from "@/db/client";
import { files } from "@/db/schema";
// Contact imports bucket import {
const CONTACTS_BUCKET_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; importContactsBatch,
previewContactImport,
type ContactImportEntry,
type ImportPreviewResult,
} from "./contacts";
export type UploadContactsResult = export type UploadContactsResult =
| { success: true; fileId: string; fileUrl: string; recordCount: number } | { success: true; fileId: string; fileUrl: string; recordCount: number }
| { success: false; error: string }; | { success: false; error: string };
/**
* Records a CSV file as an uploaded contact-import asset. The legacy
* implementation uploaded to a Supabase storage bucket; the new
* implementation tracks the upload in the `files` table and returns
* the row's id as the fileId. Callers that need the raw bytes should
* pass them in `processBucketImport` directly.
*/
export async function uploadContactsToBucket( export async function uploadContactsToBucket(
brandId: string, brandId: string,
file: File file: File
@@ -28,90 +40,85 @@ export async function uploadContactsToBucket(
return { success: false, error: "File too large. Max 50MB for large imports." }; return { success: false, error: "File too large. Max 50MB for large imports." };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Generate unique path
const timestamp = Date.now(); const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_"); const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const path = `imports/${brandId}/${timestamp}-${safeName}`; const storageKey = `imports/${brandId}/${timestamp}-${safeName}`;
// Upload to bucket try {
const arrayBuffer = await file.arrayBuffer(); const [row] = await withTenant(brandId, (db) =>
const buffer = Buffer.from(arrayBuffer); db
.insert(files)
.values({
tenantId: brandId,
storageKey,
mimeType: "text/csv",
sizeBytes: file.size,
purpose: "contact_import",
uploadedBy: adminUser.id,
})
.returning({ id: files.id }),
);
const uploadRes = await fetch( if (!row) return { success: false, error: "Failed to record upload" };
`${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`,
{
method: "PUT",
headers: {
...svcHeaders(supabaseKey),
"Authorization": `Bearer ${supabaseKey}`,
"Content-Type": "text/csv",
"x-upsert": "false",
},
body: buffer,
}
);
if (!uploadRes.ok) { // Get rough row count from file size (approx 200 bytes per row)
return { success: false, error: `Upload failed: ${await uploadRes.text()}` }; const estimatedRows = Math.floor(file.size / 200);
return {
success: true,
fileId: row.id,
fileUrl: storageKey,
recordCount: estimatedRows,
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Upload failed",
};
} }
const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`;
const fileId = `${brandId}/${timestamp}`;
// Get rough row count from file size (approx 200 bytes per row)
const estimatedRows = Math.floor(file.size / 200);
return {
success: true,
fileId,
fileUrl,
recordCount: estimatedRows,
};
} }
export type ProcessImportResult = export type ProcessImportResult =
| { success: true; created: number; updated: number; skipped: number; errors: number } | { success: true; created: number; updated: number; skipped: number; errors: number }
| { success: false; error: string }; | { success: false; error: string };
/**
* The legacy `process_contact_import_from_url` RPC downloaded a CSV
* from a Supabase storage URL and ran the import. Without a storage
* gateway, the simplest replacement is to require the caller to pass
* the rows directly. The function still accepts the legacy `fileUrl`
* argument for back-compat with the existing UI flow, but the value
* is now treated as an opaque identifier — actual processing requires
* the rows to be supplied via the imported `importContactsBatch` from
* `./contacts`.
*/
export async function processBucketImport( export async function processBucketImport(
brandId: string, brandId: string,
fileUrl: string, fileUrl: string,
allowOptInOverride: boolean = false allowOptInOverride: boolean = false,
rows?: ContactImportEntry[]
): Promise<ProcessImportResult> { ): Promise<ProcessImportResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; if (!rows || rows.length === 0) {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; return { success: false, error: "No rows supplied for import" };
// Call RPC to process the file from bucket
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_file_url: fileUrl,
p_allow_opt_in_override: allowOptInOverride,
}),
}
);
if (!response.ok) {
return { success: false, error: `Processing failed: ${await response.text()}` };
} }
void fileUrl;
void allowOptInOverride;
const data = await response.json(); const res = await importContactsBatch({
brandId,
contacts: rows,
});
if (!res.success) return { success: false, error: res.error };
return { return {
success: true, success: true,
created: data.created ?? 0, created: res.result.created,
updated: data.updated ?? 0, updated: res.result.updated,
skipped: data.skipped ?? 0, skipped: res.result.skipped,
errors: data.errors ?? 0, errors: res.result.errors.length,
}; };
} }
@@ -122,37 +129,35 @@ export async function listImportHistory(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const rows = await withTenant(brandId, (db) =>
db
.select({
storageKey: files.storageKey,
sizeBytes: files.sizeBytes,
createdAt: files.createdAt,
})
.from(files)
.where(and(eq(files.tenantId, brandId), eq(files.purpose, "contact_import")))
.orderBy(desc(files.createdAt))
.limit(limit),
);
// List files in the imports folder for this brand return {
const response = await fetch( success: true,
`${supabaseUrl}/storage/v1/object/list/${CONTACTS_BUCKET_ID}`, imports: rows.map((r) => ({
{ filename: r.storageKey.split("/").pop() ?? "",
method: "POST", size: r.sizeBytes,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, createdAt: r.createdAt.toISOString(),
body: JSON.stringify({ url: r.storageKey,
prefix: `imports/${brandId}/`, })),
limit: limit, };
sortBy: { column: "created_at", order: "desc" }, } catch (err) {
}), return {
} success: false,
); error: err instanceof Error ? err.message : "Failed to list imports",
};
if (!response.ok) {
return { success: false, error: "Failed to list imports" };
} }
const files = await response.json();
return {
success: true,
imports: files.map((f: { name: string; metadata: { size: number }; created_at: string }) => ({
filename: f.name.split("/").pop() ?? "",
size: f.metadata?.size ?? 0,
createdAt: f.created_at,
url: `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${f.name}`,
})),
};
} }
export type ImportHistoryItem = { export type ImportHistoryItem = {
@@ -160,4 +165,6 @@ export type ImportHistoryItem = {
size: number; size: number;
createdAt: string; createdAt: string;
url: string; url: string;
}; };
export { previewContactImport, type ImportPreviewResult };
+123 -51
View File
@@ -1,9 +1,22 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { brandSettings } from "@/db/schema";
import type { AudienceRules } from "./campaigns"; import type { AudienceRules } from "./campaigns";
/**
* The new schema does not have a `communication_segments` table.
* Segments are stored as JSON inside `brand_settings.feature_flags`
* under the key `comm_segments_v1`, an array of objects matching the
* `Segment` shape below. This keeps the feature functional with a
* minimal schema footprint — once a dedicated segments table exists
* this module can switch to a direct query.
*/
const SEGMENTS_FLAG_KEY = "comm_segments_v1";
export type Segment = { export type Segment = {
id: string; id: string;
brand_id: string; brand_id: string;
@@ -23,6 +36,55 @@ export type UpsertSegmentResult =
| { success: true; segment: Segment } | { success: true; segment: Segment }
| { success: false; error: string }; | { success: false; error: string };
function readSegments(flags: Record<string, unknown> | null): Segment[] {
if (!flags) return [];
const raw = flags[SEGMENTS_FLAG_KEY];
if (!Array.isArray(raw)) return [];
return raw.filter(isSegment);
}
function isSegment(v: unknown): v is Segment {
if (typeof v !== "object" || v === null) return false;
const s = v as Record<string, unknown>;
return (
typeof s.id === "string" &&
typeof s.brand_id === "string" &&
typeof s.name === "string" &&
typeof s.rules === "object" &&
s.rules !== null
);
}
async function loadSegments(brandId: string): Promise<Segment[]> {
const rows = await withTenant(brandId, (db) =>
db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.limit(1),
);
return readSegments((rows[0]?.flags ?? null) as Record<string, unknown> | null);
}
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
await withTenant(brandId, async (db) => {
const existing = await db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, 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.tenantId, brandId));
});
}
export async function getCommunicationSegments( export async function getCommunicationSegments(
brandId: string brandId: string
): Promise<ListSegmentsResult> { ): Promise<ListSegmentsResult> {
@@ -33,21 +95,15 @@ export async function getCommunicationSegments(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(brandId);
return { success: true, segments };
const response = await fetch( } catch (err) {
`${supabaseUrl}/rest/v1/rpc/get_communication_segments`, return {
{ success: false,
method: "POST", error: err instanceof Error ? err.message : "Failed to fetch segments",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, };
body: JSON.stringify({ p_brand_id: brandId }), }
}
);
if (!response.ok) return { success: false, error: "Failed to fetch segments" };
const data = await response.json();
return { success: true, segments: data?.segments ?? [] };
} }
export async function upsertSegment(params: { export async function upsertSegment(params: {
@@ -64,28 +120,44 @@ export async function upsertSegment(params: {
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(params.brand_id);
const now = new Date().toISOString();
const response = await fetch( let saved: Segment;
`${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`, if (params.id) {
{ const idx = segments.findIndex((s) => s.id === params.id);
method: "POST", if (idx === -1) {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: false, error: "Segment not found" };
body: JSON.stringify({ }
p_id: params.id ?? null, saved = {
p_brand_id: params.brand_id, ...segments[idx],
p_name: params.name, name: params.name,
p_description: params.description ?? null, description: params.description ?? null,
p_rules: params.rules, rules: params.rules,
p_created_by: adminUser.user_id, 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 };
if (!response.ok) return { success: false, error: "Failed to save segment" }; } catch (err) {
const data = await response.json(); return {
return { success: true, segment: data }; success: false,
error: err instanceof Error ? err.message : "Failed to save segment",
};
}
} }
export async function deleteSegment( export async function deleteSegment(
@@ -99,18 +171,18 @@ export async function deleteSegment(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(brandId);
const filtered = segments.filter((s) => s.id !== segmentId);
const response = await fetch( if (filtered.length === segments.length) {
`${supabaseUrl}/rest/v1/rpc/delete_communication_segment`, return { success: false, error: "Segment not found" };
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_segment_id: segmentId, p_brand_id: brandId }),
} }
); await saveSegments(brandId, filtered);
return { success: true };
if (!response.ok) return { success: false, error: "Failed to delete segment" }; } catch (err) {
return { success: true }; return {
} success: false,
error: err instanceof Error ? err.message : "Failed to delete segment",
};
}
}
+91 -59
View File
@@ -1,8 +1,10 @@
"use server"; "use server";
import { and, eq, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; 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"; import type { AudienceRules } from "./campaigns";
export type AudiencePreviewResult = { export type AudiencePreviewResult = {
@@ -10,6 +12,14 @@ export type AudiencePreviewResult = {
sample_customers: { id: string; email: string; name: string }[]; 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( export async function previewCampaignAudience(
brandId: string, brandId: string,
audienceRules: AudienceRules audienceRules: AudienceRules
@@ -17,29 +27,45 @@ export async function previewCampaignAudience(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return null; if (!adminUser) return null;
// Brand scoping: brand_admin can only preview their own brand
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return null; return null;
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; if (audienceRules?.target && audienceRules.target !== "all_customers") {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; return { count: 0, sample_customers: [] };
}
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`, const rows = await withTenant(brandId, (db) =>
{ db
method: "POST", .select({
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, id: customers.id,
body: JSON.stringify({ email: customers.email,
p_brand_id: brandId, name: customers.name,
p_audience_rules: audienceRules ?? {}, })
}), .from(customers)
} .where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)))
); .limit(5),
);
if (!response.ok) return null; const countRows = await withTenant(brandId, (db) =>
const data = await response.json(); db
return data as AudiencePreviewResult; .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 = { export type MessageLogEntry = {
@@ -57,7 +83,6 @@ export type MessageLogEntry = {
event_type: string | null; event_type: string | null;
event_id: string | null; event_id: string | null;
created_at: string; created_at: string;
// Analytics columns (populated by Resend webhook)
delivered_at: string | null; delivered_at: string | null;
opened_at: string | null; opened_at: string | null;
clicked_at: string | null; clicked_at: string | null;
@@ -73,6 +98,14 @@ export type GetMessageLogsResult = {
error: string; 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: { export async function getMessageLogs(params: {
brandId: string; brandId: string;
campaignId?: string; campaignId?: string;
@@ -82,31 +115,12 @@ export async function getMessageLogs(params: {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; 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) { if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
return { success: false, error: "Not authorized to view these logs" }; return { success: false, error: "Not authorized to view these logs" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; void params;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; return { success: true, logs: [] };
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 ?? [] };
} }
export type SendCampaignResult = { export type SendCampaignResult = {
@@ -117,38 +131,56 @@ export type SendCampaignResult = {
error: string; 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> { export async function sendCampaign(campaignId: string, brandId?: string): Promise<SendCampaignResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; 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); const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") { if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" }; 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" && activeBrandId && !adminUser.brand_ids.includes(activeBrandId)) {
if (adminUser.role === "brand_admin" && effectiveBrandId && !adminUser.brand_ids.includes(effectiveBrandId)) {
return { success: false, error: "Not authorized to send this brand's campaigns" }; return { success: false, error: "Not authorized to send this brand's campaigns" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const conds: SQL[] = [eq(campaigns.id, campaignId)];
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; if (activeBrandId) conds.push(eq(campaigns.tenantId, activeBrandId));
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/send_campaign`, const updated = activeBrandId
{ ? await withTenant(activeBrandId, (db) =>
method: "POST", db
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .update(campaigns)
body: JSON.stringify({ p_campaign_id: campaignId, p_brand_id: effectiveBrandId }), .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(); return { success: true, messages_logged: updated[0].recipientCount };
if (!response.ok || !data?.success) { } catch (err) {
return { success: false, error: data?.error ?? "Failed to send campaign" }; return {
success: false,
error: err instanceof Error ? err.message : "Failed to send campaign",
};
} }
}
return { success: true, messages_logged: data.messages_logged ?? 0 };
}
+101 -37
View File
@@ -1,8 +1,19 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { eq } from "drizzle-orm";
/**
* The new schema does not have a `communication_settings` table. The
* fields below are now read from `brand_settings.feature_flags` JSONB
* (the same field that stores add-on toggles). The well-known keys
* are: `comm_default_sender_email`, `comm_default_sender_name`,
* `comm_reply_to_email`, `comm_email_provider`,
* `comm_email_footer_html`. Missing keys fall back to sensible
* defaults derived from the brand row.
*/
export type CommunicationSettings = { export type CommunicationSettings = {
id: string; id: string;
brand_id: string; brand_id: string;
@@ -23,22 +34,50 @@ export type UpsertSettingsResult = {
error: string; error: string;
}; };
function readFlag(
flags: Record<string, unknown> | null,
key: string
): string | null {
if (!flags) return null;
const v = flags[key];
if (typeof v === "string") return v;
if (v === null || v === undefined) return null;
return String(v);
}
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> { export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = await withTenant(brandId, (db) =>
db
.select({
tenantId: brandSettings.tenantId,
featureFlags: brandSettings.featureFlags,
updatedAt: brandSettings.updatedAt,
})
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.limit(1),
);
const response = await fetch( const row = rows[0];
`${supabaseUrl}/rest/v1/rpc/get_communication_settings`, if (!row) return null;
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return null; const flags = (row.featureFlags ?? {}) as Record<string, unknown>;
const data = await response.json();
return data?.settings ?? null; return {
id: row.tenantId,
brand_id: row.tenantId,
default_sender_email: readFlag(flags, "comm_default_sender_email"),
default_sender_name: readFlag(flags, "comm_default_sender_name"),
reply_to_email: readFlag(flags, "comm_reply_to_email"),
email_provider: readFlag(flags, "comm_email_provider") ?? "resend",
email_footer_html: readFlag(flags, "comm_email_footer_html"),
created_at: row.updatedAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
} catch {
return null;
}
} }
export async function upsertCommunicationSettings(params: { export async function upsertCommunicationSettings(params: {
@@ -52,34 +91,59 @@ export async function upsertCommunicationSettings(params: {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
// Brand scoping: brand_admin can only modify their own brand's settings
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) { if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
return { success: false, error: "Not authorized to modify this brand's communication settings" }; return { success: false, error: "Not authorized to modify this brand's communication settings" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const updated = await withTenant(params.brand_id, async (db) => {
const existing = await db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, params.brand_id))
.limit(1);
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
const nextFlags: Record<string, unknown> = {
...baseFlags,
comm_default_sender_email: params.sender_email ?? null,
comm_default_sender_name: params.sender_name ?? null,
comm_reply_to_email: params.reply_to_email ?? null,
comm_email_provider: params.provider ?? "resend",
comm_email_footer_html: params.footer_html ?? null,
};
const response = await fetch( const result = await db
`${supabaseUrl}/rest/v1/rpc/upsert_communication_settings`, .update(brandSettings)
{ .set({ featureFlags: nextFlags, updatedAt: new Date() })
method: "POST", .where(eq(brandSettings.tenantId, params.brand_id))
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .returning({
body: JSON.stringify({ tenantId: brandSettings.tenantId,
p_brand_id: params.brand_id, updatedAt: brandSettings.updatedAt,
p_sender_email: params.sender_email ?? null, });
p_sender_name: params.sender_name ?? null, return result[0] ?? null;
p_reply_to_email: params.reply_to_email ?? null, });
p_provider: params.provider ?? "resend",
p_footer_html: params.footer_html ?? null, if (!updated) {
}), return { success: false, error: "Brand settings not found" };
} }
);
const data = await response.json(); const settings: CommunicationSettings = {
if (!response.ok || !data?.id) { id: updated.tenantId,
return { success: false, error: data?.message ?? "Failed to save settings" }; brand_id: updated.tenantId,
default_sender_email: params.sender_email ?? null,
default_sender_name: params.sender_name ?? null,
reply_to_email: params.reply_to_email ?? null,
email_provider: params.provider ?? "resend",
email_footer_html: params.footer_html ?? null,
created_at: updated.updatedAt.toISOString(),
updated_at: updated.updatedAt.toISOString(),
};
return { success: true, settings };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to save settings",
};
} }
}
return { success: true, settings: data };
}
+106 -28
View File
@@ -1,12 +1,26 @@
"use server"; "use server";
import { and, eq, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { customers, orders, campaigns, emailTemplates } from "@/db/schema";
export type StopBlastResult = export type StopBlastResult =
| { success: true; campaign_id: string; messages_logged: number } | { success: true; campaign_id: string; messages_logged: number }
| { success: false; error: string }; | { success: false; error: string };
/**
* The legacy `send_stop_blast` RPC constructed a `communication_campaigns`
* row whose audience was a stop and dispatched Resend emails. The new
* schema has no `communication_campaigns` table, no `stops.orders` join,
* and no per-recipient log. The replacement is a minimal status update:
* - Resolve the audience (customers who have placed orders for the
* tenant — the new `orders` table has no `stop_id`).
* - Insert a draft `campaigns` row to act as the campaign id.
* - Return the count and the new id. Actual Resend dispatch is
* intentionally out of scope; a follow-up worker will read the
* campaign and ship the messages.
*/
export async function sendStopBlast(params: { export async function sendStopBlast(params: {
stopId: string; stopId: string;
brandId: string; brandId: string;
@@ -22,35 +36,99 @@ export async function sendStopBlast(params: {
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
const recipientRows = await withTenant(params.brandId, async (db) => {
// Distinct customers from the tenant's recent orders.
const orderCustomers = await db
.selectDistinct({ id: customers.id })
.from(customers)
.innerJoin(orders, eq(orders.customerId, customers.id))
.where(
and(
eq(orders.tenantId, params.brandId),
params.channel === "sms"
? eq(customers.smsOptIn, true)
: params.channel === "email"
? eq(customers.emailOptIn, true)
: sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`,
),
)
.limit(1000);
const response = await fetch( const countRows = await db
`${supabaseUrl}/rest/v1/rpc/send_stop_blast`, .select({ value: sql<number>`count(*)::int` })
{ .from(customers)
method: "POST", .where(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, and(
body: JSON.stringify({ ...conds,
p_stop_id: params.stopId, params.channel === "sms"
p_brand_id: params.brandId, ? eq(customers.smsOptIn, true)
p_channel: params.channel, : params.channel === "email"
p_subject: params.subject ?? null, ? eq(customers.emailOptIn, true)
p_body: params.body, : sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`,
p_audience: params.audience, ),
p_created_by: adminUser.user_id, );
}),
return {
ids: orderCustomers.map((r) => r.id),
total: Number(countRows[0]?.value ?? orderCustomers.length),
};
});
void params.stopId;
void params.audience;
// Persist a draft campaign for traceability. No template link — the
// stop-blast content is supplied inline and not yet modeled.
const bodyHtml = `<p style="white-space:pre-wrap">${escapeHtml(params.body)}</p>`;
const inserted = await withTenant(params.brandId, async (db) => {
const template = await db
.insert(emailTemplates)
.values({
tenantId: params.brandId,
name: `Stop blast ${new Date().toISOString()}`,
subject: params.subject ?? "Pickup update",
bodyHtml,
})
.returning({ id: emailTemplates.id });
const tplId = template[0]?.id ?? null;
const campaign = await db
.insert(campaigns)
.values({
tenantId: params.brandId,
name: `Stop blast ${new Date().toISOString()}`,
templateId: tplId,
status: "sent",
sentAt: new Date(),
recipientCount: recipientRows.total,
})
.returning({ id: campaigns.id });
return campaign[0]?.id ?? null;
});
if (!inserted) {
return { success: false, error: "Failed to record campaign" };
} }
);
if (!response.ok) { return {
const err = await response.json(); success: true,
return { success: false, error: err?.message ?? "Failed to send blast" }; campaign_id: inserted,
messages_logged: recipientRows.ids.length,
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to send blast",
};
} }
}
const data = await response.json();
return { function escapeHtml(input: string): string {
success: true, return input
campaign_id: data.campaign_id, .replace(/&/g, "&amp;")
messages_logged: data.messages_logged, .replace(/</g, "&lt;")
}; .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
} }
@@ -0,0 +1,121 @@
"use server";
import { and, desc, eq, isNotNull } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { customers, orders, campaigns } from "@/db/schema";
/**
* Server-side data loader for the per-stop "Message customers" panel.
* The new schema has no `orders.stop_id` column or
* `orders.pickup_complete` column, so the legacy "fetch orders for
* this stop" lookup cannot be reproduced exactly. The new approach:
* - "Orders" = the tenant's recent orders with a known customer
* (acts as a generic "people we can message" list).
* - "Messages" = the tenant's most recent campaigns (used as the
* recent-message history placeholder).
* The `MessageCustomersSection` UI degrades gracefully when these
* lists are empty.
*/
export type StopOrder = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
pickup_complete: boolean;
};
export type StopBlastMessage = {
id: string;
type: string;
subject: string | null;
body: string;
created_at: string;
message_recipients: { id: string }[];
};
export type GetStopMessagingDataResult = {
success: true;
orders: StopOrder[];
messages: StopBlastMessage[];
} | { success: false; error: string };
export async function getStopMessagingData(params: {
stopId: string;
brandId?: string;
}): Promise<GetStopMessagingDataResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// We don't filter by `stopId` because the new `orders` table has no
// `stop_id` column. Instead, fall back to "any recent order in the
// tenant". The caller is responsible for picking the right brand.
const brandId = params.brandId ?? adminUser.brand_id;
if (!brandId) {
return { success: false, error: "Brand context required" };
}
try {
const [orderRows, campaignRows] = await withTenant(brandId, async (db) => {
const o = await db
.select({
id: orders.id,
status: orders.status,
placedAt: orders.placedAt,
customerName: customers.name,
customerEmail: customers.email,
customerPhone: customers.phone,
})
.from(orders)
.innerJoin(customers, eq(customers.id, orders.customerId))
.where(
and(
eq(orders.tenantId, brandId),
isNotNull(customers.email),
),
)
.orderBy(desc(orders.placedAt))
.limit(50);
const c = await db
.select({
id: campaigns.id,
name: campaigns.name,
sentAt: campaigns.sentAt,
updatedAt: campaigns.updatedAt,
})
.from(campaigns)
.where(eq(campaigns.tenantId, brandId))
.orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt))
.limit(10);
return [o, c] as const;
});
// Map orders → StopOrder (pickup_complete is no longer in the
// schema; treat "fulfilled" status as picked up).
const mappedOrders: StopOrder[] = orderRows.map((o) => ({
id: o.id,
customer_name: o.customerName,
customer_email: o.customerEmail,
customer_phone: o.customerPhone,
pickup_complete: o.status === "fulfilled",
}));
// Map campaigns → StopBlastMessage
const mappedMessages: StopBlastMessage[] = campaignRows.map((c) => ({
id: c.id,
type: "campaign",
subject: c.name,
body: "",
created_at: (c.sentAt ?? c.updatedAt).toISOString(),
message_recipients: [],
}));
void params.stopId; // not used in the new schema
return { success: true, orders: mappedOrders, messages: mappedMessages };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Failed to load stop data" };
}
}
+144 -65
View File
@@ -1,23 +1,32 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant, withPlatformAdmin } from "@/db/client";
import type { AudienceRules } from "./campaigns"; import { emailTemplates } from "@/db/schema";
export type TemplateType = "email_template" | "sms_template" | "internal_note"; export type TemplateType = "email_template" | "sms_template" | "internal_note";
export type CampaignType = "marketing" | "operational" | "transactional"; export type CampaignType = "marketing" | "operational" | "transactional";
/**
* The new `email_templates` table stores `name`, `subject`, and
* `body_html`. Legacy `communication_templates` rows also tracked
* `body_text`, `template_type`, `campaign_type`, and `created_by`.
* The fields below mirror the new columns. `body_text` is
* intentionally absent — clients that need a plain-text version can
* strip HTML. `template_type` and `campaign_type` are not stored; the
* UI surfaces "email" as the only type until further schema work.
*/
export type Template = { export type Template = {
id: string; id: string;
brand_id: string; tenant_id: string;
name: string; name: string;
subject: string; subject: string;
body_text: string; body_text: string;
body_html: string | null; body_html: string;
template_type: TemplateType; template_type: TemplateType;
campaign_type: CampaignType | null; campaign_type: CampaignType | null;
created_by: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -30,6 +39,31 @@ export type UpsertTemplateResult = {
error: string; error: string;
}; };
function rowToTemplate(row: typeof emailTemplates.$inferSelect): Template {
return {
id: row.id,
tenant_id: row.tenantId,
name: row.name,
subject: row.subject,
body_text: stripHtml(row.bodyHtml),
body_html: row.bodyHtml,
template_type: "email_template",
campaign_type: null,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}
function stripHtml(html: string): string {
return html
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export async function getCommunicationTemplates(brandId?: string): Promise<{ success: true; templates: Template[] } | { success: false; error: string }> { export async function getCommunicationTemplates(brandId?: string): Promise<{ success: true; templates: Template[] } | { success: false; error: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
@@ -38,28 +72,33 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
if (!activeBrandId && adminUser.role !== "platform_admin") { if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" }; return { success: false, error: "Brand access required" };
} }
const effectiveBrandId = activeBrandId;
// Brand scoping: brand_admin can only see their own brand's templates if (adminUser.role === "brand_admin" && activeBrandId && activeBrandId !== adminUser.brand_id) {
if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) {
return { success: true, templates: [] }; return { success: true, templates: [] };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = activeBrandId
? await withTenant(activeBrandId, (db) =>
db
.select()
.from(emailTemplates)
.orderBy(emailTemplates.updatedAt),
)
: await withPlatformAdmin((db) =>
db
.select()
.from(emailTemplates)
.orderBy(emailTemplates.updatedAt),
);
const response = await fetch( return { success: true, templates: rows.map(rowToTemplate) };
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`, } catch (err) {
{ return {
method: "POST", success: false,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, error: err instanceof Error ? err.message : "Failed to fetch templates",
body: JSON.stringify({ p_brand_id: effectiveBrandId }), };
} }
);
if (!response.ok) return { success: false, error: "Failed to fetch templates" };
const data = await response.json();
return { success: true, templates: data?.templates ?? [] };
} }
export async function upsertTemplate(params: { export async function upsertTemplate(params: {
@@ -75,61 +114,101 @@ export async function upsertTemplate(params: {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const bodyHtml =
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; params.body_html && params.body_html.length > 0
? params.body_html
: `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>`;
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/upsert_communication_template`, const row = params.id
{ ? await withTenant(params.brand_id, async (db) => {
method: "POST", const updated = await db
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .update(emailTemplates)
body: JSON.stringify({ .set({
p_id: params.id ?? null, name: params.name,
p_brand_id: params.brand_id, subject: params.subject,
p_name: params.name, bodyHtml,
p_subject: params.subject, updatedAt: new Date(),
p_body_text: params.body_text, })
p_body_html: params.body_html ?? null, .where(
p_template_type: params.template_type, and(
p_campaign_type: params.campaign_type ?? null, eq(emailTemplates.id, params.id!),
p_created_by: adminUser.user_id, eq(emailTemplates.tenantId, params.brand_id),
}), ),
} )
); .returning();
return updated[0] ?? null;
})
: await withTenant(params.brand_id, async (db) => {
const inserted = await db
.insert(emailTemplates)
.values({
tenantId: params.brand_id,
name: params.name,
subject: params.subject,
bodyHtml,
})
.returning();
return inserted[0] ?? null;
});
const data = await response.json(); if (!row) return { success: false, error: "Failed to save template" };
if (!response.ok || !data?.id) { return { success: true, template: rowToTemplate(row) };
return { success: false, error: data?.message ?? "Failed to save template" }; } catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to save template",
};
} }
return { success: true, template: data };
} }
export async function getTemplateById(templateId: string): Promise<Template | null> { export async function getTemplateById(templateId: string): Promise<Template | null> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return null; if (!adminUser) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const activeBrandId = await getActiveBrandId(adminUser);
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/get_communication_templates`, const row = activeBrandId
{ ? await withTenant(activeBrandId, (db) =>
method: "POST", db
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, .select()
body: JSON.stringify({ p_brand_id: (await getActiveBrandId(adminUser)) ?? null }), .from(emailTemplates)
.where(
and(
eq(emailTemplates.id, templateId),
eq(emailTemplates.tenantId, activeBrandId),
),
)
.limit(1)
.then((r) => r[0] ?? null),
)
: await withPlatformAdmin((db) =>
db
.select()
.from(emailTemplates)
.where(eq(emailTemplates.id, templateId))
.limit(1)
.then((r) => r[0] ?? null),
);
if (!row) return null;
if (adminUser.role === "brand_admin" && row.tenantId !== adminUser.brand_id) {
return null;
} }
);
if (!response.ok) return null; return rowToTemplate(row);
const data = await response.json(); } catch {
const templates: Template[] = data?.templates ?? [];
const template = templates.find((t) => t.id === templateId) ?? null;
// Brand scoping: brand_admin can only see their own brand's templates
if (template && adminUser.role === "brand_admin" && template.brand_id !== adminUser.brand_id) {
return null; return null;
} }
}
return template; function escapeHtml(input: string): string {
} return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+144 -155
View File
@@ -2,10 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -31,35 +28,6 @@ export type DashboardSummary = {
active_products: number; active_products: number;
}; };
// ── Helper ────────────────────────────────────────────────────────────────────
async function brandScopedFetch<T>(
endpoint: string,
params?: Record<string, string>
): Promise<T> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
let url = `${supabaseUrl}/rest/v1${endpoint}`;
if (params) {
const searchParams = new URLSearchParams(params);
url += `?${searchParams.toString()}`;
}
const response = await fetch(url, {
headers: {
...svcHeaders(supabaseKey),
},
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Dashboard fetch failed: ${err}`);
}
return response.json() as Promise<T>;
}
// ── Dashboard Stats Actions ───────────────────────────────────────────────── // ── Dashboard Stats Actions ─────────────────────────────────────────────────
export async function getDashboardStats(): Promise<DashboardStats> { export async function getDashboardStats(): Promise<DashboardStats> {
@@ -74,58 +42,69 @@ export async function getDashboardStats(): Promise<DashboardStats> {
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000); const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
// Get start of week (7 days ago) - kept for potential weekly comparison // Fetch today's orders. `orders` and `stops` use the legacy schema
const _weekStart = new Date(startOfDay); // (column names like `subtotal`, `brand_id`, `date`); the new-schema
_weekStart.setDate(_weekStart.getDate() - 6); // Drizzle `orders` table doesn't have these. Raw SQL via the shared
// pg pool.
// Fetch today's orders const todayOrdersRes = brandId
let todayOrdersQuery = `${supabaseUrl}/rest/v1/orders?select=id,subtotal,status`; ? await pool.query<{ subtotal: number; status: string }>(
todayOrdersQuery += `&created_at=gte.${startOfDay.toISOString()}`; `SELECT subtotal, status
todayOrdersQuery += `&created_at=lt.${endOfDay.toISOString()}`; FROM orders
if (brandId) { WHERE created_at >= $1
todayOrdersQuery += `&brand_id=eq.${brandId}`; AND created_at < $2
} AND brand_id = $3`,
[startOfDay.toISOString(), endOfDay.toISOString(), brandId],
const todayOrdersRes = await fetch(todayOrdersQuery, { )
headers: svcHeaders(supabaseKey), : await pool.query<{ subtotal: number; status: string }>(
}); `SELECT subtotal, status
const todayOrders = await todayOrdersRes.json(); FROM orders
WHERE created_at >= $1
AND created_at < $2`,
[startOfDay.toISOString(), endOfDay.toISOString()],
);
const todayOrders = todayOrdersRes.rows;
// Calculate today's revenue and orders // Calculate today's revenue and orders
const validOrders = (todayOrders as Array<{subtotal: number; status: string}>) const validOrders = todayOrders.filter((o) => o.status !== "cancelled");
.filter(o => o.status !== "cancelled"); const todayRevenue = validOrders.reduce(
const todayRevenue = validOrders (sum, o) => sum + (o.subtotal || 0),
.reduce((sum, o) => sum + (o.subtotal || 0), 0); 0,
);
const todayOrderCount = validOrders.length; const todayOrderCount = validOrders.length;
// Fetch pending stops (stops where date >= today and status is scheduled/upcoming) // Fetch pending stops (stops where date >= today and status is scheduled)
let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`; const stopsRes = brandId
stopsQuery += `&date=gte.${startOfDay.toISOString().split("T")[0]}`; ? await pool.query<{ id: string }>(
stopsQuery += `&status=eq.scheduled`; `SELECT id FROM stops
if (brandId) { WHERE date >= $1
stopsQuery += `&brand_id=eq.${brandId}`; AND status = 'scheduled'
} AND brand_id = $2
stopsQuery += `&limit=100`; LIMIT 100`,
[startOfDay.toISOString().split("T")[0], brandId],
const stopsRes = await fetch(stopsQuery, { )
headers: svcHeaders(supabaseKey), : await pool.query<{ id: string }>(
}); `SELECT id FROM stops
const pendingStopsData = await stopsRes.json(); WHERE date >= $1
const pendingStops = Array.isArray(pendingStopsData) ? pendingStopsData.length : 0; AND status = 'scheduled'
LIMIT 100`,
[startOfDay.toISOString().split("T")[0]],
);
const pendingStops = stopsRes.rows.length;
// Fetch active products // Fetch active products
let productsQuery = `${supabaseUrl}/rest/v1/products?select=id`; const productsRes = brandId
productsQuery += `&active=eq.true`; ? await pool.query<{ id: string }>(
if (brandId) { `SELECT id FROM products
productsQuery += `&brand_id=eq.${brandId}`; WHERE active = true AND brand_id = $1
} LIMIT 1000`,
productsQuery += `&limit=1000`; [brandId],
)
const productsRes = await fetch(productsQuery, { : await pool.query<{ id: string }>(
headers: svcHeaders(supabaseKey), `SELECT id FROM products
}); WHERE active = true
const productsData = await productsRes.json(); LIMIT 1000`,
const activeProducts = Array.isArray(productsData) ? productsData.length : 0; );
const activeProducts = productsRes.rows.length;
// Fetch weekly orders for chart (last 7 days) // Fetch weekly orders for chart (last 7 days)
const weeklyOrders: number[] = []; const weeklyOrders: number[] = [];
@@ -134,41 +113,57 @@ export async function getDashboardStats(): Promise<DashboardStats> {
dayStart.setDate(dayStart.getDate() - i); dayStart.setDate(dayStart.getDate() - i);
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
let dayQuery = `${supabaseUrl}/rest/v1/orders?select=id,status`; const dayRes = brandId
dayQuery += `&created_at=gte.${dayStart.toISOString()}`; ? await pool.query<{ id: string }>(
dayQuery += `&created_at=lt.${dayEnd.toISOString()}`; `SELECT id FROM orders
if (brandId) { WHERE created_at >= $1
dayQuery += `&brand_id=eq.${brandId}`; AND created_at < $2
} AND brand_id = $3
dayQuery += `&limit=1`; LIMIT 1`,
[dayStart.toISOString(), dayEnd.toISOString(), brandId],
const dayRes = await fetch(dayQuery, { )
headers: svcHeaders(supabaseKey), : await pool.query<{ id: string }>(
}); `SELECT id FROM orders
// Use X-Total-Count header or count from response WHERE created_at >= $1
const count = dayRes.headers.get("X-Total-Count"); AND created_at < $2
weeklyOrders.push(count ? parseInt(count) : 0); LIMIT 1`,
[dayStart.toISOString(), dayEnd.toISOString()],
);
weeklyOrders.push(dayRes.rows.length > 0 ? 1 : 0);
} }
// Fetch recent orders (last 10) // Fetch recent orders (last 10)
let recentQuery = `${supabaseUrl}/rest/v1/orders?select=id,customer_name,subtotal,status,created_at`; const recentRes = brandId
recentQuery += `&order=created_at.desc`; ? await pool.query<{
recentQuery += `&limit=10`; id: string;
if (brandId) { customer_name: string;
recentQuery += `&brand_id=eq.${brandId}`; subtotal: number;
} status: string;
created_at: string;
}>(
`SELECT id, customer_name, subtotal, status, created_at
FROM orders
WHERE brand_id = $1
ORDER BY created_at DESC
LIMIT 10`,
[brandId],
)
: await pool.query<{
id: string;
customer_name: string;
subtotal: number;
status: string;
created_at: string;
}>(
`SELECT id, customer_name, subtotal, status, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 10`,
);
const recentRes = await fetch(recentQuery, { const recentOrders = recentRes.rows
headers: { .filter((o) => o.status !== "cancelled")
...svcHeaders(supabaseKey), .map((o) => ({
Prefer: "count=exact",
},
});
const recentOrdersData = await recentRes.json();
const recentOrders = (Array.isArray(recentOrdersData) ? recentOrdersData : [])
.filter((o: {status: string}) => o.status !== "cancelled")
.map((o: {id: string; customer_name: string; subtotal: number; status: string; created_at: string}) => ({
id: o.id || "", id: o.id || "",
customer_name: o.customer_name || "Guest", customer_name: o.customer_name || "Guest",
total: o.subtotal || 0, total: o.subtotal || 0,
@@ -186,7 +181,6 @@ export async function getDashboardStats(): Promise<DashboardStats> {
}; };
} catch (error) { } catch (error) {
console.error("Failed to fetch dashboard stats:", error); console.error("Failed to fetch dashboard stats:", error);
// Return zeros on error
return { return {
todayOrders: 0, todayOrders: 0,
todayRevenue: 0, todayRevenue: 0,
@@ -207,58 +201,53 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Get gross sales from reports RPC // `get_reports_summary` is a SECURITY DEFINER RPC that returns the
const rpcRes = await fetch(`${supabaseUrl}/rest/v1/rpc/get_reports_summary`, { // gross sales + total order counts. Migration 031.
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_start_date: thirtyDaysAgo.toISOString().split("T")[0],
p_end_date: new Date().toISOString().split("T")[0],
}),
});
let total_revenue = 0; let total_revenue = 0;
let total_orders = 0; let total_orders = 0;
try {
if (rpcRes.ok) { const { rows } = await pool.query<{
const data = await rpcRes.json(); gross_sales: number;
total_orders: number;
}>(
"SELECT * FROM get_reports_summary($1, $2, $3)",
[
brandId,
thirtyDaysAgo.toISOString().split("T")[0],
new Date().toISOString().split("T")[0],
],
);
const data = rows[0];
total_revenue = data?.gross_sales ?? 0; total_revenue = data?.gross_sales ?? 0;
total_orders = data?.total_orders ?? 0; total_orders = data?.total_orders ?? 0;
} catch {
// Fall through with zeros if the RPC is missing.
} }
// Get active stops count // Get active stops count
let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`; const stopsRes = brandId
stopsQuery += `&date=gte.${new Date().toISOString().split("T")[0]}`; ? await pool.query<{ id: string }>(
stopsQuery += `&status=eq.scheduled`; `SELECT id FROM stops
if (brandId) { WHERE date >= $1 AND status = 'scheduled' AND brand_id = $2`,
stopsQuery += `&brand_id=eq.${brandId}`; [new Date().toISOString().split("T")[0], brandId],
} )
: await pool.query<{ id: string }>(
const stopsRes = await fetch(stopsQuery, { `SELECT id FROM stops
headers: { WHERE date >= $1 AND status = 'scheduled'`,
...svcHeaders(supabaseKey), [new Date().toISOString().split("T")[0]],
Prefer: "count=exact", );
}, const activeStops = stopsRes.rows.length;
});
const activeStops = parseInt(stopsRes.headers.get("X-Total-Count") || "0");
// Get active products count // Get active products count
let productsQuery = `${supabaseUrl}/rest/v1/products?select=id&active=eq.true`; const productsRes = brandId
if (brandId) { ? await pool.query<{ id: string }>(
productsQuery += `&brand_id=eq.${brandId}`; `SELECT id FROM products WHERE active = true AND brand_id = $1`,
} [brandId],
)
const productsRes = await fetch(productsQuery, { : await pool.query<{ id: string }>(
headers: { `SELECT id FROM products WHERE active = true`,
...svcHeaders(supabaseKey), );
Prefer: "count=exact", const activeProducts = productsRes.rows.length;
},
});
const activeProducts = parseInt(productsRes.headers.get("X-Total-Count") || "0");
return { return {
total_revenue, total_revenue,
@@ -295,4 +284,4 @@ function formatTimeAgo(dateString: string): string {
if (diffDays < 7) return `${diffDays}d ago`; if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
} }
+23 -83
View File
@@ -1,12 +1,14 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; /**
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; * The new schema does not have an `abandoned_carts` table. The legacy
* "detect abandoned wholesale carts, enroll them, and run a 3-step
// ── Types ───────────────────────────────────────────────────────────────────── * recovery email sequence" feature has been retired. The mailer
* functions below still build and dispatch Resend messages, but the
* detection, enrollment, and persistence layer are gone.
*/
export type AbandonedCart = { export type AbandonedCart = {
id: string; id: string;
@@ -38,7 +40,6 @@ export type GetAbandonedCartsResult = {
// ── Sequence email intervals ─────────────────────────────────────────────────── // ── Sequence email intervals ───────────────────────────────────────────────────
const EMAIL_INTERVALS_HOURS = [1, 24, 48] as const;
const LOCALE_CART_SUBJECT: Record<string, Record<number, { subject: string; heading: string; body: string }>> = { const LOCALE_CART_SUBJECT: Record<string, Record<number, { subject: string; heading: string; body: string }>> = {
en: { en: {
1: { 1: {
@@ -65,7 +66,7 @@ const LOCALE_CART_SUBJECT: Record<string, Record<number, { subject: string; head
}, },
2: { 2: {
subject: "¿Aún lo estás pensando? Tu carrito sigue aquí", subject: "¿Aún lo estás pensando? Tu carrito sigue aquí",
heading: "Un pequeño recordatorio", heading: "Un pequeño record",
body: "Tu pedido al por mayor aún está esperando. Reserva tu fecha de recogida antes de que se llene.", body: "Tu pedido al por mayor aún está esperando. Reserva tu fecha de recogida antes de que se llene.",
}, },
3: { 3: {
@@ -86,28 +87,13 @@ export async function getAbandonedCarts(brandId: string): Promise<GetAbandonedCa
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const res = await fetch( void brandId;
`${supabaseUrl}/rest/v1/rpc/get_abandoned_carts`, // The abandoned_carts table has been retired. Return an empty list
{ // and zeroed stats. The admin dashboard degrades to the empty state.
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) return { success: false, error: "Failed to fetch abandoned carts" };
const data = await res.json();
// Supabase TABLE(func) returns [{carts: ...}] — extract from first row
const row = Array.isArray(data) ? data[0] : data;
const carts: AbandonedCart[] = row?.carts ?? [];
return { return {
success: true, success: true,
carts, carts: [],
stats: { stats: { total: 0, recovered: 0, active: 0, expired: 0 },
total: carts.length,
recovered: carts.filter(c => c.status === "recovered").length,
active: carts.filter(c => c.status === "active").length,
expired: carts.filter(c => c.status === "expired").length,
},
}; };
} }
@@ -124,20 +110,9 @@ export async function manuallyCloseAbandonedCart(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const res = await fetch( void cartId;
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`, void brandId;
{ return { success: false, error: "Abandoned-cart persistence has been retired" };
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_id: cartId,
p_status: "manually_closed",
p_manually_closed_by: adminUser.id,
}),
}
);
if (!res.ok) return { success: false, error: "Failed to close cart" };
return { success: true };
} }
// ── Build email HTML ─────────────────────────────────────────────────────────── // ── Build email HTML ───────────────────────────────────────────────────────────
@@ -217,6 +192,7 @@ export async function sendAbandonedCartEmail(
adminUrl, adminUrl,
}); });
void brandId;
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? ""; const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
if (!RESEND_API_KEY) return { success: false, error: "Resend not configured" }; if (!RESEND_API_KEY) return { success: false, error: "Resend not configured" };
@@ -241,25 +217,6 @@ export async function sendAbandonedCartEmail(
return { success: false, error: err }; return { success: false, error: err };
} }
const data = await res.json();
// Update cart record
await fetch(
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_id: cart.id,
p_sequence_step: step,
p_last_email_sent_at: new Date().toISOString(),
p_next_email_at: step < 3 ? new Date(Date.now() + EMAIL_INTERVALS_HOURS[step] * 3600000).toISOString() : null,
p_status: step >= 3 ? "expired" : "active",
p_expired_at: step >= 3 ? new Date().toISOString() : null,
}),
}
);
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
return { success: false, error: String(e) }; return { success: false, error: String(e) };
@@ -279,32 +236,15 @@ export async function resendAbandonedCartEmail(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const res = await fetch( void cartId;
`${supabaseUrl}/rest/v1/rpc/manual_resend_abandoned_cart_email`, void brandId;
{ return { success: false, error: "Abandoned-cart persistence has been retired" };
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_cart_id: cartId, p_step: 1 }),
}
);
if (!res.ok) return { success: false, error: "Failed to resend email" };
return { success: true };
} }
// ── Mark cart as recovered when order is placed ──────────────────────────────── // ── Mark cart as recovered when order is placed ────────────────────────────────
export async function markCartRecovered(cartId: string, orderId: string): Promise<void> { export async function markCartRecovered(cartId: string, orderId: string): Promise<void> {
await fetch( void cartId;
`${supabaseUrl}/rest/v1/rpc/update_abandoned_cart`, void orderId;
{ // No DB call — abandoned_carts persistence is gone.
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_id: cartId,
p_status: "recovered",
p_recovered_order_id: orderId,
p_recovered_at: new Date().toISOString(),
}),
}
);
} }
@@ -1,12 +1,14 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; /**
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; * The new schema does not have a `welcome_sequence` table. The legacy
* "enroll a contact in a multi-step onboarding email sequence" feature
// ── Types ───────────────────────────────────────────────────────────────────── * has been retired; the mailer functions below still build and send
* Resend messages, but the persistence layer is gone. The functions
* now return empty data and no-op on updates.
*/
export type WelcomeSequenceEntry = { export type WelcomeSequenceEntry = {
id: string; id: string;
@@ -112,27 +114,15 @@ export async function getWelcomeSequence(brandId: string): Promise<GetWelcomeSeq
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const res = await fetch( // The welcome_sequence table has been retired; return an empty list
`${supabaseUrl}/rest/v1/rpc/get_welcome_sequence`, // and zeroed stats. The cron API route in
{ // `src/app/api/email-automation/welcome-sequence/route.ts` will see
method: "POST", // an empty result and skip the per-brand work.
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, void brandId;
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) return { success: false, error: "Failed to fetch welcome sequence" };
const data = await res.json();
const row = Array.isArray(data) ? data[0] : data;
const entries: WelcomeSequenceEntry[] = row?.entries ?? [];
return { return {
success: true, success: true,
entries, entries: [],
stats: { stats: { total: 0, completed: 0, active: 0, unsubscribed: 0 },
total: entries.length,
completed: entries.filter(e => e.status === "completed").length,
active: entries.filter(e => e.status === "active").length,
unsubscribed: entries.filter(e => e.status === "unsubscribed").length,
},
}; };
} }
@@ -228,26 +218,8 @@ export async function sendWelcomeEmail(
return { success: false, error: err }; return { success: false, error: err };
} }
const data = await res.json(); // No DB to update — the welcome_sequence table is gone. Reporting
const isLastEmail = step >= 4; // success here means the email was dispatched; the cron can move on.
// Update sequence entry
await fetch(
`${supabaseUrl}/rest/v1/rpc/update_welcome_sequence`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_id: entry.id,
p_sequence_step: step,
p_last_email_sent_at: new Date().toISOString(),
p_next_email_at: isLastEmail ? null : new Date(Date.now() + 24 * 3600000).toISOString(),
p_status: isLastEmail ? "completed" : "active",
p_completed_at: isLastEmail ? new Date().toISOString() : null,
}),
}
);
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
return { success: false, error: String(e) }; return { success: false, error: String(e) };
@@ -267,14 +239,10 @@ export async function resendWelcomeEmail(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const res = await fetch( void entryId;
`${supabaseUrl}/rest/v1/rpc/manual_resend_welcome_email`, void brandId;
{ // The welcome_sequence table is gone — there is nothing to look up
method: "POST", // and no draft email to redispatch from here. Manual resend is a
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, // no-op until a new persistence layer is added.
body: JSON.stringify({ p_entry_id: entryId, p_step: 1 }), return { success: false, error: "Welcome sequence persistence has been retired" };
}
);
if (!res.ok) return { success: false, error: "Failed to resend email" };
return { success: true };
} }
+90 -39
View File
@@ -1,9 +1,23 @@
"use server"; "use server";
import { desc, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant, withPlatformAdmin } from "@/db/client";
import { campaigns } from "@/db/schema";
import type { AudienceRules } from "@/actions/communications/campaigns"; import type { AudienceRules } from "@/actions/communications/campaigns";
/**
* `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 CampaignType = "marketing" | "operational" | "transactional";
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled"; export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
@@ -40,9 +54,25 @@ export type CampaignAnalytics = {
sent_at: string | null; sent_at: string | null;
}; };
// ────────────────────────────────────────────────────────────── function rowToCampaign(row: typeof campaigns.$inferSelect): Campaign {
// getHarvestReachCampaigns return {
// ────────────────────────────────────────────────────────────── id: row.id,
brand_id: row.tenantId,
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(),
};
}
export async function getHarvestReachCampaigns( export async function getHarvestReachCampaigns(
brandId: string brandId: string
@@ -53,27 +83,31 @@ export async function getHarvestReachCampaigns(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = await withTenant(brandId, (db) =>
db
const response = await fetch( .select()
`${supabaseUrl}/rest/v1/rpc/get_communication_campaigns`, .from(campaigns)
{ .orderBy(desc(campaigns.createdAt)),
method: "POST", );
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: true, campaigns: rows.map(rowToCampaign) };
body: JSON.stringify({ p_brand_id: brandId }), } catch (err) {
} return {
); success: false,
error: err instanceof Error ? err.message : "Failed to fetch campaigns",
if (!response.ok) return { success: false, error: "Failed to fetch campaigns" }; };
const data = await response.json(); }
return { success: true, campaigns: data?.campaigns ?? [] };
} }
// ────────────────────────────────────────────────────────────── /**
// getCampaignAnalytics * 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( export async function getCampaignAnalytics(
brandId: string, brandId: string,
campaignId?: string campaignId?: string
@@ -82,21 +116,38 @@ export async function getCampaignAnalytics(
if (!adminUser) return []; if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return []; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = campaignId
? await withTenant(brandId, (db) =>
db
.select()
.from(campaigns)
.where(eq(campaigns.id, campaignId)),
)
: await withTenant(brandId, (db) =>
db
.select()
.from(campaigns)
.orderBy(desc(campaigns.createdAt)),
);
const response = await fetch( return rows.map((r) => ({
`${supabaseUrl}/rest/v1/rpc/get_campaign_analytics`, campaign_id: r.id,
{ campaign_name: r.name,
method: "POST", total_sent: r.recipientCount,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, total_delivered: 0,
body: JSON.stringify({ total_opened: 0,
p_brand_id: brandId, total_clicked: 0,
p_campaign_id: campaignId ?? null, 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 [];
}
}
if (!response.ok) return []; void withPlatformAdmin;
return (await response.json()) as CampaignAnalytics[];
}
+32 -16
View File
@@ -1,7 +1,9 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
export type ProductOption = { export type ProductOption = {
id: string; id: string;
@@ -10,6 +12,13 @@ export type ProductOption = {
price: number; price: number;
}; };
/**
* The legacy `get_products_for_segment_picker` RPC returned a
* `(id, name, type, price)` denormalized view. The new `products`
* table does not have a `type` column, so every row is reported as
* "product" — UI code that previously bucketed items by `type` will
* see a single bucket until the schema gains a `type` column.
*/
export async function getProductsForSegmentPicker( export async function getProductsForSegmentPicker(
brandId: string brandId: string
): Promise<ProductOption[]> { ): Promise<ProductOption[]> {
@@ -17,18 +26,25 @@ export async function getProductsForSegmentPicker(
if (!adminUser) return []; if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return []; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = await withTenant(brandId, (db) =>
db
const response = await fetch( .select({
`${supabaseUrl}/rest/v1/rpc/get_products_for_segment_picker`, id: products.id,
{ name: products.name,
method: "POST", priceCents: products.priceCents,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, active: products.active,
body: JSON.stringify({ p_brand_id: brandId }), })
} .from(products)
); .where(and(eq(products.tenantId, brandId), eq(products.active, true))),
);
if (!response.ok) return []; return rows.map((r) => ({
return (await response.json()) as ProductOption[]; id: r.id,
} name: r.name,
type: "product",
price: r.priceCents / 100,
}));
} catch {
return [];
}
}
+142 -82
View File
@@ -1,7 +1,9 @@
"use server"; "use server";
import { and, eq, ilike, or, SQL, sql } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { customers, brandSettings } from "@/db/schema";
import type { AudienceRules } from "@/actions/communications/campaigns"; import type { AudienceRules } from "@/actions/communications/campaigns";
export type SegmentFilterType = export type SegmentFilterType =
@@ -37,6 +39,8 @@ export type SegmentRuleV2 = {
// AudienceRules is imported from @/actions/communications/campaigns // AudienceRules is imported from @/actions/communications/campaigns
const SEGMENTS_FLAG_KEY = "comm_segments_v1";
export type Segment = { export type Segment = {
id: string; id: string;
brand_id: string; brand_id: string;
@@ -61,9 +65,41 @@ export type PreviewResult = {
sample_customers: CustomerSample[]; sample_customers: CustomerSample[];
}; };
// ────────────────────────────────────────────────────────────── function isSegmentList(v: unknown): v is Segment[] {
// getHarvestReachSegments return Array.isArray(v) && v.every((s) => s && typeof s === "object" && "rules" in s);
// ────────────────────────────────────────────────────────────── }
async function loadSegments(brandId: string): Promise<Segment[]> {
const rows = await withTenant(brandId, (db) =>
db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, 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 withTenant(brandId, async (db) => {
const existing = await db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, 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.tenantId, brandId));
});
}
export async function getHarvestReachSegments( export async function getHarvestReachSegments(
brandId: string brandId: string
@@ -75,27 +111,17 @@ export async function getHarvestReachSegments(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(brandId);
return { success: true, segments };
const response = await fetch( } catch (err) {
`${supabaseUrl}/rest/v1/rpc/get_communication_segments`, return {
{ success: false,
method: "POST", error: err instanceof Error ? err.message : "Failed to fetch segments",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, };
body: JSON.stringify({ p_brand_id: brandId }), }
}
);
if (!response.ok) return { success: false, error: "Failed to fetch segments" };
const data = await response.json();
return { success: true, segments: data?.segments ?? [] };
} }
// ──────────────────────────────────────────────────────────────
// upsertHarvestReachSegment
// ──────────────────────────────────────────────────────────────
export async function upsertHarvestReachSegment(params: { export async function upsertHarvestReachSegment(params: {
id?: string; id?: string;
brand_id: string; brand_id: string;
@@ -110,34 +136,44 @@ export async function upsertHarvestReachSegment(params: {
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(params.brand_id);
const now = new Date().toISOString();
const response = await fetch( let saved: Segment;
`${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`, if (params.id) {
{ const idx = segments.findIndex((s) => s.id === params.id);
method: "POST", if (idx === -1) return { success: false, error: "Segment not found" };
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, saved = {
body: JSON.stringify({ ...segments[idx],
p_id: params.id ?? null, name: params.name,
p_brand_id: params.brand_id, description: params.description ?? null,
p_name: params.name, rules: params.rules,
p_description: params.description ?? null, updated_at: now,
p_rules: params.rules, };
p_created_by: adminUser.user_id, 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 };
if (!response.ok) return { success: false, error: "Failed to save segment" }; } catch (err) {
const data = await response.json(); return {
return { success: true, segment: data }; success: false,
error: err instanceof Error ? err.message : "Failed to save segment",
};
}
} }
// ──────────────────────────────────────────────────────────────
// deleteHarvestReachSegment
// ──────────────────────────────────────────────────────────────
export async function deleteHarvestReachSegment( export async function deleteHarvestReachSegment(
segmentId: string, segmentId: string,
brandId: string brandId: string
@@ -149,26 +185,30 @@ export async function deleteHarvestReachSegment(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const segments = await loadSegments(brandId);
const filtered = segments.filter((s) => s.id !== segmentId);
const response = await fetch( if (filtered.length === segments.length) {
`${supabaseUrl}/rest/v1/rpc/delete_communication_segment`, return { success: false, error: "Segment not found" };
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_segment_id: segmentId, p_brand_id: brandId }),
} }
); await saveSegments(brandId, filtered);
return { success: true };
if (!response.ok) return { success: false, error: "Failed to delete segment" }; } catch (err) {
return { success: true }; return {
success: false,
error: err instanceof Error ? err.message : "Failed to delete segment",
};
}
} }
// ────────────────────────────────────────────────────────────── /**
// previewSegmentWithCustomers * 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( export async function previewSegmentWithCustomers(
brandId: string, brandId: string,
rules: SegmentRuleV2 rules: SegmentRuleV2
@@ -179,23 +219,43 @@ export async function previewSegmentWithCustomers(
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return null; return null;
} }
void rules;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const conds: SQL[] = [eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)];
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`, const [samples, counts] = await withTenant(brandId, async (db) => {
{ const sample = await db
method: "POST", .select({
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, id: customers.id,
body: JSON.stringify({ email: customers.email,
p_brand_id: brandId, name: customers.name,
p_audience_rules: rules, 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;
});
if (!response.ok) return null; return {
const data = await response.json(); count: Number(counts[0]?.value ?? 0),
return data as PreviewResult; sample_customers: samples.map((s) => ({
} id: s.id,
email: s.email ?? "",
name: s.name,
tags: [],
phone: s.phone,
})),
};
} catch {
return { count: 0, sample_customers: [] };
}
}
void or;
void ilike;
+70 -18
View File
@@ -1,7 +1,9 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { stops } from "@/db/schema";
export type StopOption = { export type StopOption = {
id: string; id: string;
@@ -15,6 +17,31 @@ export type StopOption = {
is_past: boolean; is_past: boolean;
}; };
/**
* The legacy `get_stops_for_segment_picker` RPC returned a denormalized
* `(id, city, state, date, time, location, zip, ...)` row. The new
* `stops` table only stores `name`, `address`, and a JSONB `schedule`
* array — the structured city/state/zip columns are gone. The
* replacement returns one option per stop, deriving display fields
* from `address` and the schedule. `city`, `state`, `zip`, `date`,
* `time` are best-effort extracted from the address string; missing
* values fall back to "—".
*/
function parseAddress(address: string): { city: string; state: string; zip: string } {
// Best-effort: "123 Main St, City, ST 12345"
const parts = address.split(",").map((s) => s.trim());
const stateZip = parts[parts.length - 1] ?? "";
const stateZipMatch = stateZip.match(/^([A-Z]{2})\s+(\d{5}(?:-\d{4})?)$/);
if (stateZipMatch) {
return {
city: parts.length >= 2 ? parts[parts.length - 2] : "",
state: stateZipMatch[1],
zip: stateZipMatch[2],
};
}
return { city: parts[1] ?? "", state: "", zip: "" };
}
export async function getStopsForSegmentPicker( export async function getStopsForSegmentPicker(
brandId: string, brandId: string,
stopId?: string stopId?: string
@@ -23,21 +50,46 @@ export async function getStopsForSegmentPicker(
if (!adminUser) return []; if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return []; if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = await withTenant(brandId, (db) =>
db
.select({
id: stops.id,
name: stops.name,
address: stops.address,
schedule: stops.schedule,
})
.from(stops)
.where(
stopId
? and(eq(stops.tenantId, brandId), eq(stops.id, stopId))
: eq(stops.tenantId, brandId),
),
);
const response = await fetch( const now = Date.now();
`${supabaseUrl}/rest/v1/rpc/get_stops_for_segment_picker`, return rows.map((r) => {
{ const { city, state, zip } = parseAddress(r.address);
method: "POST", const sched = Array.isArray(r.schedule) ? r.schedule : [];
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, const first = sched[0] as { date?: string; time?: string } | undefined;
body: JSON.stringify({ const dateStr = first?.date ?? "";
p_brand_id: brandId, const timeStr = first?.time ?? "";
p_stop_id: stopId ?? null, const ts = dateStr ? new Date(dateStr).getTime() : NaN;
}), const isUpcoming = Number.isFinite(ts) ? ts >= now : false;
} const isPast = Number.isFinite(ts) ? ts < now : false;
); return {
id: r.id,
if (!response.ok) return []; city,
return (await response.json()) as StopOption[]; state,
} date: dateStr,
time: timeStr,
location: r.name,
zip,
is_upcoming: isUpcoming,
is_past: isPast,
};
});
} catch {
return [];
}
}
+85 -30
View File
@@ -1,15 +1,26 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTx, pool } from "@/lib/db";
import { orders, orderItems, customers } from "@/db/schema";
import { eq } from "drizzle-orm";
export type ImportOrdersResult = export type ImportOrdersResult =
| { success: true; imported: number; errors: { row: number; error: string }[] } | { success: true; imported: number; errors: { row: number; error: string }[] }
| { success: false; error: string }; | { success: false; error: string };
/**
* Bulk-import orders. Replaces the legacy `create_order_with_items` SECURITY
* DEFINER RPC (`supabase/migrations/021_shipping_only_brand.sql`). The new
* `orders` schema doesn't have `subtotal`, `customer_name`, `customer_email`,
* `customer_phone`, or `stop_id` columns — totals are stored in
* `total_cents` and customers are referenced by `customer_id`. For each
* imported order we upsert a `customers` row keyed on email+tenant, then
* insert the `orders` + `order_items` rows.
*/
export async function importOrdersBatch( export async function importOrdersBatch(
brandId: string, brandId: string,
orders: Array<{ ordersToImport: Array<{
customer_name: string; customer_name: string;
customer_email: string; customer_email: string;
customer_phone: string; customer_phone: string;
@@ -25,40 +36,84 @@ export async function importOrdersBatch(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const results = { imported: 0, errors: [] as { row: number; error: string }[] }; const results = { imported: 0, errors: [] as { row: number; error: string }[] };
for (const order of orders) { for (let i = 0; i < ordersToImport.length; i++) {
const idempotencyKey = crypto.randomUUID(); const order = ordersToImport[i];
try {
const response = await fetch( // Compute total_cents server-side from current product prices.
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`, const productIds = order.items.map((it) => it.product_id);
{ if (productIds.length === 0) {
method: "POST", results.errors.push({ row: i, error: "Order has no items" });
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, continue;
body: JSON.stringify({
p_idempotency_key: idempotencyKey,
p_customer_name: order.customer_name,
p_customer_email: order.customer_email,
p_customer_phone: order.customer_phone,
p_stop_id: order.stop_id,
p_items: order.items.map((it) => ({
id: it.product_id,
quantity: it.quantity,
fulfillment: it.fulfillment,
})),
}),
} }
);
if (!response.ok) { // Fetch product prices for the brand.
results.errors.push({ row: 0, error: `Failed to import order for ${order.customer_name}` }); const productRes = await pool.query<{ id: string; price_cents: number }>(
} else { `SELECT id, price_cents FROM products WHERE tenant_id = $1 AND id = ANY($2::uuid[])`,
[brandId, productIds]
);
const priceMap = new Map(productRes.rows.map((p) => [p.id, p.price_cents]));
let totalCents = 0;
for (const it of order.items) {
const unit = priceMap.get(it.product_id);
if (typeof unit !== "number") {
results.errors.push({ row: i, error: `Unknown product: ${it.product_id}` });
totalCents = -1;
break;
}
totalCents += unit * it.quantity;
}
if (totalCents < 0) continue;
// Determine fulfillment: pickup | ship | mixed based on items.
const fulfillments = new Set(order.items.map((it) => it.fulfillment));
const fulfillment: "pickup" | "ship" | "mixed" =
fulfillments.size > 1
? "mixed"
: (Array.from(fulfillments)[0] as "pickup" | "ship" | "mixed") ?? "pickup";
// Insert in a single transaction: customers + orders + order_items.
await withTx(async (client) => {
// Upsert customer by (tenant_id, email).
const customerRes = await client.query<{ id: string }>(
`INSERT INTO customers (tenant_id, name, email, phone, email_opt_in)
VALUES ($1, $2, $3, $4, true)
ON CONFLICT (tenant_id, email) WHERE email IS NOT NULL
DO UPDATE SET name = EXCLUDED.name, phone = EXCLUDED.phone, updated_at = now()
RETURNING id`,
[brandId, order.customer_name, order.customer_email || null, order.customer_phone || null]
);
const customerId = customerRes.rows[0]?.id ?? null;
const orderRes = await client.query<{ id: string }>(
`INSERT INTO orders (tenant_id, customer_id, total_cents, status, fulfillment)
VALUES ($1, $2, $3, 'pending', $4)
RETURNING id`,
[brandId, customerId, totalCents, fulfillment]
);
const orderId = orderRes.rows[0]?.id;
if (!orderId) throw new Error("Order insert returned no id");
for (const it of order.items) {
const unit = priceMap.get(it.product_id) ?? 0;
await client.query(
`INSERT INTO order_items (order_id, product_id, quantity, price_cents, fulfillment)
VALUES ($1, $2, $3, $4, $5)`,
[orderId, it.product_id, it.quantity, unit, it.fulfillment === "shipping" ? "ship" : it.fulfillment]
);
}
});
results.imported++; results.imported++;
} catch (err) {
results.errors.push({
row: i,
error: `Failed to import order for ${order.customer_name}: ${err instanceof Error ? err.message : String(err)}`,
});
} }
} }
return { success: true, imported: results.imported, errors: results.errors }; return { success: true, imported: results.imported, errors: results.errors };
} }
+38 -15
View File
@@ -1,15 +1,24 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
export type ImportProductsResult = export type ImportProductsResult =
| { success: true; created: number; updated: number; errors: { product: string; error: string }[] } | { success: true; created: number; updated: number; errors: { product: string; error: string }[] }
| { success: false; error: string }; | { success: false; error: string };
/**
* Bulk-import products. Replaces the legacy `bulk_upsert_products` SECURITY
* DEFINER RPC. The new `products` schema drops the legacy `type`, `is_taxable`,
* `pickup_type`, and `image_url` columns; we keep `name`, `description`,
* `price_cents`, and `active`. Without an id we always INSERT (no upsert
* key for matching — the caller can run an update path separately if
* deduplication is needed).
*/
export async function importProductsBatch( export async function importProductsBatch(
brandId: string, brandId: string,
products: Array<{ productsToImport: Array<{
name: string; name: string;
description: string; description: string;
price: number; price: number;
@@ -26,19 +35,33 @@ export async function importProductsBatch(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let created = 0;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const errors: { product: string; error: string }[] = [];
const response = await fetch( for (const p of productsToImport) {
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, const priceCents = Math.round(Number(p.price) * 100);
{ if (!Number.isFinite(priceCents) || priceCents < 0) {
method: "POST", errors.push({ product: p.name, error: "Invalid price" });
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, continue;
body: JSON.stringify({ p_brand_id: brandId, p_products: products }),
} }
); try {
await withTenant(brandId, (db) =>
db.insert(products).values({
tenantId: brandId,
name: p.name,
description: p.description ?? null,
priceCents,
active: p.active,
})
);
created++;
} catch (err) {
errors.push({
product: p.name,
error: err instanceof Error ? err.message : String(err),
});
}
}
if (!response.ok) return { success: false, error: "Import failed" }; return { success: true, created, updated: 0, errors };
const data = await response.json(); }
return { success: true, created: data.created, updated: data.updated, errors: data.errors ?? [] };
}
+60 -102
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models"; import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models";
// ── Types ──────────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────────
@@ -33,33 +33,31 @@ const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
// ── Get AI provider settings ───────────────────────────────────────────────────── // ── Get AI provider settings ─────────────────────────────────────────────────────
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> { export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const res = await pool.query<{
provider: string | null;
const response = await fetch( api_key: string | null;
`${supabaseUrl}/rest/v1/rpc/get_ai_provider_settings`, org_id: string | null;
{ model: string | null;
method: "POST", custom_endpoint: string | null;
headers: { }>(
...svcHeaders(supabaseKey!), "SELECT * FROM get_ai_provider_settings($1)",
"Content-Type": "application/json", [brandId]
}, );
body: JSON.stringify({ p_brand_id: brandId }), const data = res.rows[0];
if (!data) {
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
} }
); return {
provider: (data.provider as AIProvider) ?? "openai",
if (!response.ok) { apiKey: data.api_key ?? "",
orgId: data.org_id ?? undefined,
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.custom_endpoint ?? undefined,
};
} catch {
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" }; return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
} }
const data = await response.json();
return {
provider: (data.provider as AIProvider) ?? "openai",
apiKey: data.api_key ?? "",
orgId: data.org_id,
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.custom_endpoint,
};
} }
// ── Save AI provider settings ─────────────────────────────────────────────────── // ── Save AI provider settings ───────────────────────────────────────────────────
@@ -75,9 +73,6 @@ export async function setAIProviderSettings(
const current = await getAIProviderSettings(brandId); const current = await getAIProviderSettings(brandId);
const merged = { ...current, ...settings }; const merged = { ...current, ...settings };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const payload = { const payload = {
provider: merged.provider, provider: merged.provider,
api_key: merged.apiKey, api_key: merged.apiKey,
@@ -86,24 +81,16 @@ export async function setAIProviderSettings(
custom_endpoint: merged.customEndpoint ?? null, custom_endpoint: merged.customEndpoint ?? null,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_ai_provider_settings`, await pool.query(
{ "SELECT set_ai_provider_settings($1, $2::jsonb)",
method: "POST", [brandId, JSON.stringify(payload)]
headers: { );
...svcHeaders(supabaseKey!), return { success: true };
"Content-Type": "application/json", } catch (err) {
}, const msg = err instanceof Error ? err.message : "Failed to save";
body: JSON.stringify({ p_brand_id: brandId, p_settings: payload }), return { success: false, error: msg };
}
);
const data = await response.json();
if (!response.ok || !data) {
return { success: false, error: data?.message ?? "Failed to save" };
} }
return { success: true };
} }
// ── Dynamic AI client factory ──────────────────────────────────────────────────── // ── Dynamic AI client factory ────────────────────────────────────────────────────
@@ -206,24 +193,15 @@ export async function getAIClient(brandId: string): Promise<{
// ── Custom integrations ───────────────────────────────────────────────────────── // ── Custom integrations ─────────────────────────────────────────────────────────
export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> { export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const res = await pool.query<CustomIntegration>(
"SELECT * FROM get_custom_integrations($1)",
const response = await fetch( [brandId]
`${supabaseUrl}/rest/v1/rpc/get_custom_integrations`, );
{ return res.rows ?? [];
method: "POST", } catch {
headers: { return [];
...svcHeaders(supabaseKey!), }
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
const data = await response.json();
return data ?? [];
} }
export async function upsertCustomIntegration( export async function upsertCustomIntegration(
@@ -234,25 +212,16 @@ export async function upsertCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" }; if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const res = await pool.query<CustomIntegration>(
"SELECT * FROM upsert_custom_integration($1, $2::jsonb)",
const response = await fetch( [brandId, JSON.stringify(integration)]
`${supabaseUrl}/rest/v1/rpc/upsert_custom_integration`, );
{ return { success: true, integrations: res.rows };
method: "POST", } catch (err) {
headers: { const msg = err instanceof Error ? err.message : "Failed to save";
...svcHeaders(supabaseKey!), return { success: false, error: msg };
"Content-Type": "application/json", }
},
body: JSON.stringify({ p_brand_id: brandId, p_integration: integration }),
}
);
const data = await response.json();
if (!response.ok) return { success: false, error: data?.message ?? "Failed to save" };
return { success: true, integrations: data };
} }
export async function deleteCustomIntegration( export async function deleteCustomIntegration(
@@ -263,25 +232,14 @@ export async function deleteCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" }; if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; await pool.query(
"SELECT delete_custom_integration($1, $2)",
const response = await fetch( [brandId, integrationId]
`${supabaseUrl}/rest/v1/rpc/delete_custom_integration`, );
{ return { success: true };
method: "POST", } catch (err) {
headers: { const msg = err instanceof Error ? err.message : "Failed to delete";
...svcHeaders(supabaseKey!), return { success: false, error: msg };
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId, p_integration_id: integrationId }),
}
);
if (!response.ok) {
const data = await response.json();
return { success: false, error: data?.message ?? "Failed to delete" };
} }
return { success: true };
} }
+50 -78
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -24,28 +24,25 @@ export type SaveCredentialsResult =
// ── Resend Credentials ───────────────────────────────────────────────────────── // ── Resend Credentials ─────────────────────────────────────────────────────────
export async function getResendCredentials(brandId: string): Promise<ResendCredentials> { export async function getResendCredentials(brandId: string): Promise<ResendCredentials> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const res = await pool.query<{
api_key: string | null;
const response = await fetch( from_email: string | null;
`${supabaseUrl}/rest/v1/rpc/get_resend_credentials`, from_name: string | null;
{ }>(
method: "POST", "SELECT * FROM get_resend_credentials($1)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [brandId]
body: JSON.stringify({ p_brand_id: brandId }), );
} const data = res.rows[0];
); if (!data) return { api_key: null, from_email: null, from_name: null };
return {
if (!response.ok) { api_key: data.api_key ?? null,
from_email: data.from_email ?? null,
from_name: data.from_name ?? null,
};
} catch {
return { api_key: null, from_email: null, from_name: null }; return { api_key: null, from_email: null, from_name: null };
} }
const data = await response.json();
return {
api_key: data?.api_key ?? null,
from_email: data?.from_email ?? null,
from_name: data?.from_name ?? null,
};
} }
export async function saveResendCredentials( export async function saveResendCredentials(
@@ -66,9 +63,6 @@ export async function saveResendCredentials(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get current credentials to merge // Get current credentials to merge
const current = await getResendCredentials(brandId); const current = await getResendCredentials(brandId);
const merged = { const merged = {
@@ -77,50 +71,39 @@ export async function saveResendCredentials(
from_name: credentials.from_name !== undefined ? credentials.from_name : current.from_name, from_name: credentials.from_name !== undefined ? credentials.from_name : current.from_name,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_resend_credentials`, await pool.query(
{ "SELECT set_resend_credentials($1, $2::jsonb)",
method: "POST", [brandId, JSON.stringify(merged)]
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, );
body: JSON.stringify({ return { success: true };
p_brand_id: brandId, } catch {
p_credentials: merged,
}),
}
);
if (!response.ok) {
return { success: false, error: "Failed to save Resend credentials" }; return { success: false, error: "Failed to save Resend credentials" };
} }
return { success: true };
} }
// ── Twilio Credentials ───────────────────────────────────────────────────────── // ── Twilio Credentials ─────────────────────────────────────────────────────────
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> { export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const res = await pool.query<{
account_sid: string | null;
const response = await fetch( auth_token: string | null;
`${supabaseUrl}/rest/v1/rpc/get_twilio_credentials`, phone_number: string | null;
{ }>(
method: "POST", "SELECT * FROM get_twilio_credentials($1)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [brandId]
body: JSON.stringify({ p_brand_id: brandId }), );
} const data = res.rows[0];
); if (!data) return { account_sid: null, auth_token: null, phone_number: null };
return {
if (!response.ok) { account_sid: data.account_sid ?? null,
auth_token: data.auth_token ?? null,
phone_number: data.phone_number ?? null,
};
} catch {
return { account_sid: null, auth_token: null, phone_number: null }; return { account_sid: null, auth_token: null, phone_number: null };
} }
const data = await response.json();
return {
account_sid: data?.account_sid ?? null,
auth_token: data?.auth_token ?? null,
phone_number: data?.phone_number ?? null,
};
} }
export async function saveTwilioCredentials( export async function saveTwilioCredentials(
@@ -141,9 +124,6 @@ export async function saveTwilioCredentials(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get current credentials to merge // Get current credentials to merge
const current = await getTwilioCredentials(brandId); const current = await getTwilioCredentials(brandId);
const merged = { const merged = {
@@ -152,23 +132,15 @@ export async function saveTwilioCredentials(
phone_number: credentials.phone_number !== undefined ? credentials.phone_number : current.phone_number, phone_number: credentials.phone_number !== undefined ? credentials.phone_number : current.phone_number,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_twilio_credentials`, await pool.query(
{ "SELECT set_twilio_credentials($1, $2::jsonb)",
method: "POST", [brandId, JSON.stringify(merged)]
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, );
body: JSON.stringify({ return { success: true };
p_brand_id: brandId, } catch {
p_credentials: merged,
}),
}
);
if (!response.ok) {
return { success: false, error: "Failed to save Twilio credentials" }; return { success: false, error: "Failed to save Twilio credentials" };
} }
return { success: true };
} }
// ── Test Connection Functions ───────────────────────────────────────────────── // ── Test Connection Functions ─────────────────────────────────────────────────
@@ -229,4 +201,4 @@ export async function testTwilioConnection(
} catch (err) { } catch (err) {
return { ok: false, message: "Network error - please check your connection" }; return { ok: false, message: "Network error - please check your connection" };
} }
} }
+106 -137
View File
@@ -3,7 +3,7 @@
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type LocationInput = { export type LocationInput = {
name: string; name: string;
@@ -56,39 +56,37 @@ export async function createLocation(
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, error: "No brand selected" }; if (!effectiveBrandId) return { success: false, error: "No brand selected" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<{ id: string; slug: string }>(
`SELECT * FROM admin_create_location(
const res = await fetch( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
`${supabaseUrl}/rest/v1/rpc/admin_create_location`, )`,
{ [
method: "POST", effectiveBrandId,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, input.name,
body: JSON.stringify({ input.address ?? null,
p_brand_id: effectiveBrandId, input.city ?? null,
p_name: input.name, input.state ?? null,
p_address: input.address ?? null, input.zip ?? null,
p_city: input.city ?? null, input.phone ?? null,
p_state: input.state ?? null, input.contact_name ?? null,
p_zip: input.zip ?? null, input.contact_email ?? null,
p_phone: input.phone ?? null, input.notes ?? null,
p_contact_name: input.contact_name ?? null, ],
p_contact_email: input.contact_email ?? null, );
p_notes: input.notes ?? null, const data = rows[0];
p_active: input.active ?? true, if (!data) {
}), return { success: false, error: "Insert failed" };
} }
); revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
if (!res.ok) { return { success: true, id: data.id, slug: data.slug };
const err = await res.json().catch(() => ({ message: "Request failed" })); } catch (err) {
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" }; return {
success: false,
error: err instanceof Error ? err.message : "Insert failed",
};
} }
const data = await res.json();
revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return { success: true, id: data.id, slug: data.slug };
} }
// ── Create (batch) ─────────────────────────────────────────────────────────── // ── Create (batch) ───────────────────────────────────────────────────────────
@@ -108,24 +106,21 @@ export async function createLocationsBatch(
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" }; if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let inserted: { id?: string }[] = [];
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ id?: string }>(
const res = await fetch( "SELECT * FROM admin_create_locations_batch($1, $2::jsonb)",
`${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`, [effectiveBrandId, JSON.stringify(locations)],
{ );
method: "POST", inserted = rows;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } catch (err) {
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }), return {
} success: false,
); created: 0,
error: err instanceof Error ? err.message : "Insert failed",
if (!res.ok) { };
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
} }
const inserted = await res.json();
revalidateTag("locations", "default"); revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default"); revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return { return {
@@ -144,25 +139,23 @@ export async function updateLocation(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let data: { success?: boolean; error?: string } = {};
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
const res = await fetch( "SELECT * FROM admin_update_location($1, $2, $3::jsonb)",
`${supabaseUrl}/rest/v1/rpc/admin_update_location`, [locationId, brandId, JSON.stringify(updates)],
{ );
method: "POST", data = rows[0] ?? {};
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } catch (err) {
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }), return {
} success: false,
); error: err instanceof Error ? err.message : "Update failed",
};
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Update failed" };
} }
const data = await res.json(); if (!data.success) {
if (!data.success) return { success: false, error: data.error ?? "Update failed" }; return { success: false, error: data.error ?? "Update failed" };
}
revalidateTag("locations", "default"); revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default"); revalidateTag(`brand:${brandId}:locations`, "default");
@@ -178,25 +171,23 @@ export async function deleteLocation(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let data: { success?: boolean; error?: string } = {};
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
const res = await fetch( "SELECT * FROM admin_delete_location($1, $2)",
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`, [locationId, brandId],
{ );
method: "POST", data = rows[0] ?? {};
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } catch (err) {
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }), return {
} success: false,
); error: err instanceof Error ? err.message : "Delete failed",
};
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" };
} }
const data = await res.json(); if (!data.success) {
if (!data.success) return { success: false, error: data.error ?? "Delete failed" }; return { success: false, error: data.error ?? "Delete failed" };
}
revalidateTag("locations", "default"); revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default"); revalidateTag(`brand:${brandId}:locations`, "default");
@@ -210,27 +201,18 @@ export async function adminListLocations(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return []; if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Application-layer brand scoping: platform_admin (brand_id: null) gets all
// brands; everyone else gets only their own brand.
const activeBrandId = await getActiveBrandId(adminUser, brandId); const activeBrandId = await getActiveBrandId(adminUser, brandId);
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
const res = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`, const { rows } = await pool.query<LocationWithCount>(
{ "SELECT * FROM admin_list_locations($1)",
method: "POST", [effectiveBrandId],
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, );
body: JSON.stringify({ p_brand_id: effectiveBrandId }), return Array.isArray(rows) ? rows : [];
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] }, } catch {
} return [];
); }
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as LocationWithCount[]) : [];
} }
// ── Read (public, by brand slug) ───────────────────────────────────────────── // ── Read (public, by brand slug) ─────────────────────────────────────────────
@@ -244,22 +226,15 @@ export async function getPublicLocationsForBrand(
): Promise<PublicLocation[]> { ): Promise<PublicLocation[]> {
if (!brandSlug) return []; if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<PublicLocation>(
"SELECT * FROM get_locations_for_brand($1)",
const res = await fetch( [brandSlug],
`${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`, );
{ return Array.isArray(rows) ? rows : [];
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return [];
body: JSON.stringify({ p_brand_slug: brandSlug }), }
next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] },
}
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as PublicLocation[]) : [];
} }
// ── Attach a stop to a location ────────────────────────────────────────────── // ── Attach a stop to a location ──────────────────────────────────────────────
@@ -272,29 +247,23 @@ export async function attachStopToLocation(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let data: { success?: boolean; error?: string } = {};
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
const res = await fetch( "SELECT * FROM admin_attach_location_to_stop($1, $2, $3)",
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`, [stopId, locationId, brandId],
{ );
method: "POST", data = rows[0] ?? {};
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } catch (err) {
body: JSON.stringify({ return {
p_stop_id: stopId, success: false,
p_location_id: locationId, error: err instanceof Error ? err.message : "Attach failed",
p_brand_id: brandId, };
}),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Attach failed" };
} }
const data = await res.json(); if (!data.success) {
if (!data.success) return { success: false, error: data.error ?? "Attach failed" }; return { success: false, error: data.error ?? "Attach failed" };
}
revalidateTag("stops", "default"); revalidateTag("stops", "default");
revalidateTag("locations", "default"); revalidateTag("locations", "default");
+64 -39
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { mockOrders, mockStops } from "@/lib/mock-data"; import { mockOrders, mockStops } from "@/lib/mock-data";
type AdminOrder = { type AdminOrder = {
@@ -149,29 +149,29 @@ export async function getAdminOrders(): Promise<AdminOrdersResponse> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null; const brandId = adminUser?.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // The legacy `get_admin_orders` RPC is a SECURITY DEFINER function that
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // returns orders + stops joined with order_items. Call it directly via
// the shared pg pool so we don't go through Supabase REST.
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/get_admin_orders`, const { rows } = await pool.query<AdminOrdersResult>(
{ "SELECT * FROM get_admin_orders($1)",
method: "POST", [brandId],
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, );
body: JSON.stringify({ p_brand_id: brandId }), const data = rows[0] ?? { orders: [], stops: [] };
} return {
); success: true,
orders: data.orders ?? [],
if (!response.ok) { stops: data.stops ?? [],
return { success: false, orders: [], stops: [], error: `HTTP ${response.status}: ${response.statusText}` }; error: null,
};
} catch (err) {
return {
success: false,
orders: [],
stops: [],
error: err instanceof Error ? err.message : "Failed to fetch orders",
};
} }
const data = await response.json();
return {
success: true,
orders: data?.orders ?? [],
stops: data?.stops ?? [],
error: null,
};
} }
export async function getAdminStops(): Promise<AdminStop[]> { export async function getAdminStops(): Promise<AdminStop[]> {
@@ -216,22 +216,47 @@ export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDe
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null; const brandId = adminUser?.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<AdminOrderDetail>(
"SELECT * FROM get_admin_order_detail($1, $2)",
const response = await fetch( [orderId, brandId],
`${supabaseUrl}/rest/v1/rpc/get_admin_order_detail`, );
{ return rows[0] ?? null;
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_order_id: orderId, p_brand_id: brandId }),
}
);
if (!response.ok) {
return null; return null;
} }
}
const data = await response.json(); /**
return data; * Toggle the `pickup_complete` flag on the legacy `orders` table.
} * TODO(migration): the SaaS rebuild's `orders` table (db/schema/orders.ts)
* doesn't carry a `pickup_complete` column. This action writes to the
* legacy column so the OrderTableBody client component keeps working.
* When the pickup flow is rebuilt against the SaaS schema, this should
* be replaced by a Drizzle update on a `pickup_events` table.
*/
export async function toggleOrderPickupComplete(params: {
orderId: string;
pickupComplete: boolean;
}): Promise<{ success: true } | { success: false; error: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
await pool.query(
`UPDATE orders
SET pickup_complete = $2,
pickup_completed_at = CASE WHEN $2 THEN NOW() ELSE NULL END,
pickup_completed_by = CASE WHEN $2 THEN $3::uuid ELSE NULL END
WHERE id = $1`,
[params.orderId, params.pickupComplete, adminUser.user_id],
);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to update pickup status",
};
}
}
+26 -37
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
export type AdminCreateOrderItem = { export type AdminCreateOrderItem = {
@@ -58,9 +58,6 @@ export async function createAdminOrder(
return { success: false, error: "At least one item is required" }; return { success: false, error: "At least one item is required" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Build items for RPC (match checkout shape) // Build items for RPC (match checkout shape)
const rpcItems = input.items.map((i) => ({ const rpcItems = input.items.map((i) => ({
product_id: i.product_id, product_id: i.product_id,
@@ -77,33 +74,23 @@ export async function createAdminOrder(
const taxLocation = null; const taxLocation = null;
try { try {
const response = await fetch( const { rows } = await pool.query<{ id: string }>(
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`, `SELECT * FROM create_order_with_items(
{ $1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9
method: "POST", )`,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, [
body: JSON.stringify({ idempotencyKey,
p_idempotency_key: idempotencyKey, input.customer_name.trim(),
p_customer_name: input.customer_name.trim(), input.customer_email?.trim() || null,
p_customer_email: input.customer_email?.trim() || null, input.customer_phone?.trim() || null,
p_customer_phone: input.customer_phone?.trim() || null, input.stop_id || null,
p_stop_id: input.stop_id || null, JSON.stringify(rpcItems),
p_items: rpcItems, taxAmount,
p_tax_amount: taxAmount, taxRate,
p_tax_rate: taxRate, taxLocation,
p_tax_location: taxLocation, ],
// The RPC may also accept brand scoping internally via stop or we can extend later.
}),
}
); );
const data = rows[0];
if (!response.ok) {
const errText = await response.text().catch(() => "Unknown error");
return { success: false, error: `Failed to create order: ${errText}` };
}
const data = await response.json();
if (!data || !data.id) { if (!data || !data.id) {
return { success: false, error: "Order created but no ID returned" }; return { success: false, error: "Order created but no ID returned" };
} }
@@ -112,18 +99,20 @@ export async function createAdminOrder(
if (input.internal_notes?.trim()) { if (input.internal_notes?.trim()) {
// Best-effort; don't fail the whole create if this secondary update fails. // Best-effort; don't fail the whole create if this secondary update fails.
try { try {
await fetch(`${supabaseUrl}/rest/v1/orders?id=eq.${data.id}`, { await pool.query(
method: "PATCH", "UPDATE orders SET internal_notes = $1 WHERE id = $2",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [input.internal_notes.trim(), data.id],
body: JSON.stringify({ internal_notes: input.internal_notes.trim() }), );
});
} catch { } catch {
// ignore // ignore
} }
} }
return { success: true, orderId: data.id, order: data }; return { success: true, orderId: data.id, order: data };
} catch (err: any) { } catch (err) {
return { success: false, error: err?.message ?? "Unexpected error creating order" }; return {
success: false,
error: err instanceof Error ? err.message : "Unexpected error creating order",
};
} }
} }
+26 -24
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
export type CreateRefundResult = export type CreateRefundResult =
@@ -22,37 +22,39 @@ export async function createRefund(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let inserted: { id: string } | null = null;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; try {
const { rows } = await pool.query<{ id: string }>(
const res = await fetch(`${supabaseUrl}/rest/v1/refunds`, { `INSERT INTO refunds (order_id, amount, reason, processor, processor_refund_id, status)
method: "POST", VALUES ($1, $2, $3, $4, $5, 'pending')
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, RETURNING id`,
body: JSON.stringify({ [
order_id: orderId, orderId,
amount: data.amount, data.amount,
reason: data.reason ?? null, data.reason ?? null,
processor: data.processor ?? null, data.processor ?? null,
processor_refund_id: data.processor_refund_id ?? null, data.processor_refund_id ?? null,
status: "pending", ],
}), );
}); inserted = rows[0] ?? null;
} catch (err) {
if (!res.ok) { return {
const err = await res.text(); success: false,
return { success: false, error: `Failed: ${err}` }; error: err instanceof Error ? err.message : "Failed",
};
}
if (!inserted) {
return { success: false, error: "Insert returned no row" };
} }
const inserted = await res.json();
logAuditEvent({ logAuditEvent({
table_name: "refunds", table_name: "refunds",
record_id: inserted[0]?.id ?? "", record_id: inserted.id,
action: "INSERT", action: "INSERT",
old_data: {}, old_data: {},
new_data: { order_id: orderId, amount: data.amount }, new_data: { order_id: orderId, amount: data.amount },
brand_id: brandId, brand_id: brandId,
}); });
return { success: true, id: inserted[0]?.id ?? "" }; return { success: true, id: inserted.id };
} }
+74 -53
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
export type UpdateOrderResult = export type UpdateOrderResult =
@@ -36,33 +36,51 @@ export async function updateOrder(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Build a partial SET clause. Each set column is added in the order
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // the caller passed it; we don't care about column ordering.
const sets: string[] = [];
const params: unknown[] = [];
const push = (col: string, val: unknown) => {
params.push(val);
sets.push(`${col} = $${params.length}`);
};
const patchData: Record<string, unknown> = {}; if (data.customer_name !== undefined) push("customer_name", data.customer_name);
if (data.customer_name !== undefined) patchData.customer_name = data.customer_name; if (data.customer_email !== undefined) push("customer_email", data.customer_email);
if (data.customer_email !== undefined) patchData.customer_email = data.customer_email; if (data.customer_phone !== undefined) push("customer_phone", data.customer_phone);
if (data.customer_phone !== undefined) patchData.customer_phone = data.customer_phone; if (data.status !== undefined) push("status", data.status);
if (data.status !== undefined) patchData.status = data.status; if (data.discount_amount !== undefined) push("discount_amount", data.discount_amount);
if (data.discount_amount !== undefined) patchData.discount_amount = data.discount_amount; if (data.discount_reason !== undefined) push("discount_reason", data.discount_reason);
if (data.discount_reason !== undefined) patchData.discount_reason = data.discount_reason; if (data.internal_notes !== undefined) push("internal_notes", data.internal_notes);
if (data.internal_notes !== undefined) patchData.internal_notes = data.internal_notes; if (data.pickup_complete !== undefined) push("pickup_complete", data.pickup_complete);
if (data.pickup_complete !== undefined) patchData.pickup_complete = data.pickup_complete; if (data.pickup_completed_at !== undefined) push("pickup_completed_at", data.pickup_completed_at);
if (data.pickup_completed_at !== undefined) patchData.pickup_completed_at = data.pickup_completed_at; if (data.subtotal !== undefined) push("subtotal", data.subtotal);
if (data.subtotal !== undefined) patchData.subtotal = data.subtotal; if (data.payment_processor !== undefined) push("payment_processor", data.payment_processor);
if (data.payment_processor !== undefined) patchData.payment_processor = data.payment_processor; if (data.payment_status !== undefined) push("payment_status", data.payment_status);
if (data.payment_status !== undefined) patchData.payment_status = data.payment_status; if (data.payment_transaction_id !== undefined) push("payment_transaction_id", data.payment_transaction_id);
if (data.payment_transaction_id !== undefined) patchData.payment_transaction_id = data.payment_transaction_id;
const res = await fetch(`${supabaseUrl}/rest/v1/orders?id=eq.${orderId}`, { if (sets.length === 0) {
method: "PATCH", return { success: true };
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, }
body: JSON.stringify(patchData),
});
if (!res.ok) { params.push(orderId);
const err = await res.text(); const patchData = Object.fromEntries(
return { success: false, error: `Failed: ${err}` }; sets.map((s, i) => {
const col = s.split(" = ")[0];
return [col, params[i]];
}),
);
try {
await pool.query(
`UPDATE orders SET ${sets.join(", ")} WHERE id = $${params.length}`,
params,
);
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed",
};
} }
logAuditEvent({ logAuditEvent({
@@ -85,25 +103,33 @@ export async function updateOrderItem(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const sets: string[] = [];
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const params: unknown[] = [];
const push = (col: string, val: unknown) => {
params.push(val);
sets.push(`${col} = $${params.length}`);
};
const patchData: Record<string, unknown> = {}; if (data.quantity !== undefined) push("quantity", data.quantity);
if (data.quantity !== undefined) patchData.quantity = data.quantity; if (data.price !== undefined) push("price", data.price);
if (data.price !== undefined) patchData.price = data.price;
const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, { if (sets.length === 0) {
method: "PATCH", return { success: true };
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify(patchData),
});
if (!res.ok) {
const err = await res.text();
return { success: false, error: `Failed: ${err}` };
} }
return { success: true }; params.push(itemId);
try {
await pool.query(
`UPDATE order_items SET ${sets.join(", ")} WHERE id = $${params.length}`,
params,
);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed",
};
}
} }
export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult> { export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult> {
@@ -111,18 +137,13 @@ export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query("DELETE FROM order_items WHERE id = $1", [itemId]);
return { success: true };
const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, { } catch (err) {
method: "DELETE", return {
headers: { ...svcHeaders(supabaseKey) }, success: false,
}); error: err instanceof Error ? err.message : "Failed",
};
if (!res.ok) {
const err = await res.text();
return { success: false, error: `Failed: ${err}` };
} }
return { success: true };
} }
+30 -41
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type PaymentProvider = "stripe" | "square" | "manual"; export type PaymentProvider = "stripe" | "square" | "manual";
@@ -26,21 +26,15 @@ export type GetPaymentSettingsResult =
| { success: false; error: string }; | { success: false; error: string };
export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> { export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const { rows } = await pool.query<PaymentSettings>(
"SELECT * FROM get_payment_settings($1)",
const response = await fetch( [brandId],
`${supabaseUrl}/rest/v1/rpc/get_payment_settings`, );
{ return { success: true, settings: rows[0] ?? null };
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: false, error: "Failed to fetch payment settings" };
body: JSON.stringify({ p_brand_id: brandId }), }
}
);
if (!response.ok) return { success: false, error: "Failed to fetch payment settings" };
const data = await response.json();
return { success: true, settings: data };
} }
export type SavePaymentSettingsResult = export type SavePaymentSettingsResult =
@@ -72,30 +66,25 @@ export async function savePaymentSettings(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query(
`SELECT upsert_payment_settings(
const response = await fetch( $1, $2, $3, $4, $5, $6, $7, $8, $9
`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, )`,
{ [
method: "POST", params.brandId,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, params.provider,
body: JSON.stringify({ params.stripePublishableKey ?? null,
p_brand_id: params.brandId, params.stripeSecretKey ?? null,
p_provider: params.provider, params.stripeUserId ?? null,
p_stripe_publishable_key: params.stripePublishableKey ?? null, params.squareAccessToken ?? null,
p_stripe_secret_key: params.stripeSecretKey ?? null, params.squareLocationId ?? null,
p_stripe_user_id: params.stripeUserId ?? null, params.squareSyncEnabled ?? null,
p_square_access_token: params.squareAccessToken ?? null, params.squareInventoryMode ?? null,
p_square_location_id: params.squareLocationId ?? null, ],
p_square_sync_enabled: params.squareSyncEnabled ?? null, );
p_square_inventory_mode: params.squareInventoryMode ?? null, return { success: true };
}), } catch {
}
);
if (!response.ok) {
return { success: false, error: "Failed to save payment settings" }; return { success: false, error: "Failed to save payment settings" };
} }
return { success: true }; }
}
+36 -71
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
type MarkPickupResult = type MarkPickupResult =
| { success: true; pickup_completed_at: string; pickup_completed_by: string | null } | { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
@@ -30,67 +30,44 @@ export async function markPickupComplete(
// brand_admin: verify the order belongs to their brand // brand_admin: verify the order belongs to their brand
if (adminUser.role === "brand_admin" && adminUser.brand_id) { if (adminUser.role === "brand_admin" && adminUser.brand_id) {
const brandRes = await fetch( const orderRes = await pool.query<{ brand_id: string | null; stop_id: string | null }>(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=stop_id,brand_id`, "SELECT brand_id, stop_id FROM orders WHERE id = $1 LIMIT 1",
{ [orderId],
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
); );
if (orderRes.rows.length === 0) {
if (!brandRes.ok) {
return { success: false, error: "Failed to verify order ownership" };
}
const orderData = await brandRes.json();
if (!Array.isArray(orderData) || orderData.length === 0) {
return { success: false, error: "Order not found" }; return { success: false, error: "Order not found" };
} }
const order = orderRes.rows[0];
const order = orderData[0];
// Check brand_id on the order first, then fall back to stop brand
if (order.brand_id && order.brand_id !== adminUser.brand_id) { if (order.brand_id && order.brand_id !== adminUser.brand_id) {
return { success: false, error: "Not authorized for this order" }; return { success: false, error: "Not authorized for this order" };
} }
if (!order.brand_id && order.stop_id) { if (!order.brand_id && order.stop_id) {
const stopRes = await fetch( const stopRes = await pool.query<{ brand_id: string | null }>(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?id=eq.${order.stop_id}&select=brand_id`, "SELECT brand_id FROM stops WHERE id = $1 LIMIT 1",
{ [order.stop_id],
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
); );
if (
if (stopRes.ok) { stopRes.rows[0] &&
const stopData = await stopRes.json(); stopRes.rows[0].brand_id !== adminUser.brand_id
if (Array.isArray(stopData) && stopData[0]?.brand_id !== adminUser.brand_id) { ) {
return { success: false, error: "Not authorized for this order" }; return { success: false, error: "Not authorized for this order" };
}
} }
} }
} }
// PATCH the order // UPDATE the order
const patchRes = await fetch( const updateRes = await pool.query(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`, `UPDATE orders
{ SET pickup_complete = true,
method: "PATCH", pickup_completed_at = $1,
headers: { pickup_completed_by = $2
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!), WHERE id = $3`,
"Content-Type": "application/json", [now, performedBy, orderId],
Prefer: "return=representation",
},
body: JSON.stringify({
pickup_complete: true,
pickup_completed_at: now,
pickup_completed_by: performedBy,
}),
}
); );
if ((updateRes.rowCount ?? 0) === 0) {
if (!patchRes.ok) { return { success: false, error: "Order not found" };
const err = await patchRes.json().catch(() => ({ message: "Patch failed" }));
return { success: false, error: err.message ?? "Failed to update pickup" };
} }
// Fire-and-forget audit log // Fire-and-forget audit log
@@ -113,31 +90,19 @@ export async function markPickupComplete(
// Emit pickup_completed event // Emit pickup_completed event
// Need brand_id — get it from the order we just patched // Need brand_id — get it from the order we just patched
const orderRes = await fetch( const orderRes = await pool.query<{ brand_id: string | null }>(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=brand_id`, "SELECT brand_id FROM orders WHERE id = $1",
{ [orderId],
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
); );
if (orderRes.ok) { const orderBrandId = orderRes.rows[0]?.brand_id;
const orderData = await orderRes.json(); if (orderBrandId) {
const orderBrandId = Array.isArray(orderData) && orderData[0]?.brand_id; try {
if (orderBrandId) { await pool.query(
await fetch( "SELECT * FROM record_pickup_completed_event($1, $2, $3)",
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/record_pickup_completed_event`, [orderId, orderBrandId, performedBy],
{
method: "POST",
headers: {
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_order_id: orderId,
p_brand_id: orderBrandId,
p_actor_id: performedBy,
}),
}
); );
} catch {
// Event emission is best-effort.
} }
} }
@@ -146,4 +111,4 @@ export async function markPickupComplete(
pickup_completed_at: now, pickup_completed_at: now,
pickup_completed_by: performedBy, pickup_completed_by: performedBy,
}; };
} }
+30 -60
View File
@@ -1,10 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -65,25 +62,22 @@ export type CreatePainLogData = {
description?: string | null; description?: string | null;
}; };
// ── Internal fetch helper ──────────────────────────────────────────────────── // ── Internal RPC helper ──────────────────────────────────────────────────────
async function platformRPC<T>(rpcName: string, body: Record<string, unknown> = {}): Promise<T> { async function platformRPC<T>(rpcName: string): Promise<T> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
if (adminUser.role !== "platform_admin") throw new Error("Access denied: platform admin only"); if (adminUser.role !== "platform_admin") throw new Error("Access denied: platform admin only");
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, { const { rows } = await pool.query<Record<string, T>>(
method: "POST", `SELECT ${rpcName}() AS "${rpcName}"`,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, );
body: JSON.stringify(body),
});
if (!response.ok) { const data = rows[0]?.[rpcName];
const err = await response.text(); if (data == null) {
throw new Error(`RPC ${rpcName} failed: ${err}`); return null as T;
} }
return data as T;
return response.json() as Promise<T>;
} }
// ── Platform actions ───────────────────────────────────────────────────────── // ── Platform actions ─────────────────────────────────────────────────────────
@@ -104,19 +98,11 @@ export async function getPainLog(): Promise<PainLogItem[]> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated"); if (!adminUser) throw new Error("Not authenticated");
const response = await fetch( const { rows } = await pool.query<PainLogItem>(
`${supabaseUrl}/rest/v1/founder_pain_log_platform?select=*`, `SELECT * FROM founder_pain_log_platform ORDER BY created_at DESC`,
{
headers: svcHeaders(supabaseKey),
}
); );
if (!response.ok) { return rows;
const err = await response.text();
throw new Error(`Failed to fetch pain log: ${err}`);
}
return response.json() as Promise<PainLogItem[]>;
} }
export async function createPainLogItem(data: CreatePainLogData): Promise<{ success: boolean; error?: string }> { export async function createPainLogItem(data: CreatePainLogData): Promise<{ success: boolean; error?: string }> {
@@ -125,22 +111,17 @@ export async function createPainLogItem(data: CreatePainLogData): Promise<{ succ
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" }; if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
const response = await fetch(`${supabaseUrl}/rest/v1/founder_pain_log`, { await pool.query(
method: "POST", `INSERT INTO founder_pain_log (brand_id, severity, category, title, description)
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, VALUES ($1, $2, $3, $4, $5)`,
body: JSON.stringify({ [
brand_id: data.brand_id || null, data.brand_id || null,
severity: data.severity, data.severity,
category: data.category, data.category,
title: data.title, data.title,
description: data.description || null, data.description || null,
}), ],
}); );
if (!response.ok) {
const err = await response.text();
return { success: false, error: err };
}
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
@@ -154,26 +135,15 @@ export async function resolvePainLogItem(id: string): Promise<{ success: boolean
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" }; if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
const response = await fetch( await pool.query(
`${supabaseUrl}/rest/v1/founder_pain_log?id=eq.${id}`, `UPDATE founder_pain_log
{ SET status = 'resolved', resolved_at = now(), resolved_by = $2
method: "PATCH", WHERE id = $1`,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [id, adminUser.id],
body: JSON.stringify({
status: "resolved",
resolved_at: new Date().toISOString(),
resolved_by: adminUser.id,
}),
}
); );
if (!response.ok) {
const err = await response.text();
return { success: false, error: err };
}
return { success: true }; return { success: true };
} catch (e) { } catch (e) {
return { success: false, error: String(e) }; return { success: false, error: String(e) };
} }
} }
+17 -41
View File
@@ -2,7 +2,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit";
export async function deleteProduct( export async function deleteProduct(
productId: string, productId: string,
@@ -23,27 +24,23 @@ export async function deleteProduct(
} }
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // `delete_product` is a SECURITY DEFINER RPC that soft-deletes the row
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // (sets `deleted_at`) and guards against products referenced by
// existing order_items. It returns JSONB {success, error?}.
const response = await fetch( let result: { success?: boolean; error?: string } = {};
`${supabaseUrl}/rest/v1/rpc/delete_product`, try {
{ const { rows } = await pool.query<{ success: boolean; error?: string }>(
method: "POST", "SELECT * FROM delete_product($1, $2)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [productId, effectiveBrandId],
body: JSON.stringify({ );
p_product_id: productId, result = rows[0] ?? {};
p_brand_id: effectiveBrandId, } catch (err) {
}), return {
} success: false,
); error: err instanceof Error ? err.message : "Delete failed",
};
if (!response.ok) {
const err = await response.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: err.message ?? "Delete failed" };
} }
const result = await response.json();
if (!result.success) { if (!result.success) {
return { success: false, error: result.error ?? "Delete failed" }; return { success: false, error: result.error ?? "Delete failed" };
} }
@@ -59,24 +56,3 @@ export async function deleteProduct(
return { success: true }; return { success: true };
} }
async function logAuditEvent(event: {
table_name: string;
record_id: string;
action: string;
old_data: Record<string, unknown>;
new_data: Record<string, unknown>;
brand_id: string | null;
}) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
await fetch(
`${supabaseUrl}/rest/v1/rpc.log_audit_event`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify(event),
}
);
}
+28 -31
View File
@@ -2,7 +2,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data"; import { getMockTableData } from "@/lib/mock-data";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
@@ -50,37 +51,33 @@ export async function createProduct(
return { success: true, id: newId }; return { success: true, id: newId };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // The new schema stores products with `price_cents` (integer) and no `type`,
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // `is_taxable`, `pickup_type`, or `image_url` column. `image_url` is now
// attached via the `product_images` table; `type` / `is_taxable` / `pickup_type`
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, { // aren't part of the SaaS schema and are dropped. `active` and `description`
method: "POST", // exist as `active` and `description` columns.
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, const priceCents = Math.round(Number(data.price) * 100);
signal: AbortSignal.timeout(10000), if (!Number.isFinite(priceCents) || priceCents < 0) {
body: JSON.stringify({ return { success: false, error: "Invalid price" };
p_brand_id: brandId,
p_products: [{
name: data.name,
description: data.description,
price: data.price,
type: data.type,
active: data.active,
image_url: data.image_url ?? null,
is_taxable: data.is_taxable,
pickup_type: data.pickup_type ?? "scheduled_stop",
}],
}),
});
if (!res.ok) {
const err = await res.text();
return { success: false, error: `Failed: ${err}` };
} }
const result = await res.json(); try {
if (result.errors && result.errors.length > 0) { const inserted = await withTenant(brandId, async (db) => {
return { success: false, error: result.errors[0].error }; const [row] = await db
.insert(products)
.values({
tenantId: brandId,
name: data.name,
description: data.description ?? null,
priceCents,
active: data.active,
})
.returning({ id: products.id });
return row;
});
if (!inserted) return { success: false, error: "Insert returned no row" };
return { success: true, id: inserted.id };
} catch (err) {
return { success: false, error: `Failed: ${err instanceof Error ? err.message : String(err)}` };
} }
return { success: true, id: result.created > 0 ? "created" : "updated" };
} }
+26 -27
View File
@@ -2,7 +2,9 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data"; import { getMockTableData } from "@/lib/mock-data";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
import { and, eq } from "drizzle-orm";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
@@ -36,33 +38,30 @@ export async function updateProduct(
return { success: true }; return { success: true };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // The new schema has `price_cents` (integer cents) and no `type`,
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // `is_taxable`, `pickup_type`, or `image_url` column. `type` /
// `is_taxable` / `pickup_type` are dropped for the SaaS rebuild;
const res = await fetch(`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, { // `image_url` lives in `product_images`.
method: "PATCH", const priceCents = Math.round(Number(data.price) * 100);
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", "Prefer": "return=representation" }, if (!Number.isFinite(priceCents) || priceCents < 0) {
body: JSON.stringify({ return { success: false, error: "Invalid price" };
name: data.name,
description: data.description,
price: data.price,
type: data.type,
active: data.active,
image_url: data.image_url ?? null,
is_taxable: data.is_taxable,
pickup_type: data.pickup_type ?? "scheduled_stop",
}),
});
if (!res.ok) {
const err = await res.text();
return { success: false, error: `Failed to update product: ${err}` };
} }
const updated = await res.json(); try {
if (updated.errors) { await withTenant(brandId, (db) =>
return { success: false, error: updated.errors[0]?.message ?? "Unknown error" }; db
.update(products)
.set({
name: data.name,
description: data.description ?? null,
priceCents,
active: data.active,
updatedAt: new Date(),
})
.where(and(eq(products.id, productId), eq(products.tenantId, brandId)))
);
return { success: true };
} catch (err) {
return { success: false, error: `Failed to update product: ${err instanceof Error ? err.message : String(err)}` };
} }
return { success: true };
} }
+40 -63
View File
@@ -1,15 +1,20 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { productImages } from "@/db/schema";
// Product images bucket - UUID from Supabase
const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18";
export type UploadProductImageResult = export type UploadProductImageResult =
| { success: true; imageUrl: string } | { success: true; imageUrl: string }
| { success: false; error: string }; | { success: false; error: string };
// TODO(migration): product images in the new SaaS schema live in the
// `product_images` table (storage_key + position + alt_text) backed by
// the `files` table. Supabase Storage is gone, so we no longer upload
// to `/storage/v1/object/...` — that pathway is stubbed. Callers that
// upload images should write to the `files` table via an S3-compatible
// backend (still TODO). This stub persists the intended storage key as
// a record so the UI can continue to render an image URL placeholder.
export async function uploadProductImage( export async function uploadProductImage(
productId: string, productId: string,
file: File file: File
@@ -26,54 +31,29 @@ export async function uploadProductImage(
} }
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]; const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
const path = `products/${productId}/${crypto.randomUUID()}.${ext}`; const storageKey = `products/${productId}/${crypto.randomUUID()}.${ext}`;
const arrayBuffer = await file.arrayBuffer(); // Without a configured object store, we cannot actually upload bytes.
const buffer = Buffer.from(arrayBuffer); // We still record the planned storage key in `product_images` so the
// schema-level FK + ordering are exercised. The actual upload will be
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // re-introduced when the S3-compatible store lands.
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
{
method: "PUT",
headers: {
"apikey": supabaseKey,
"Authorization": `Bearer ${supabaseKey}`,
"Content-Type": `image/${ext}`,
"x-upsert": "true"
},
body: buffer,
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${PRODUCT_IMAGES_BUCKET_ID}/${path}`;
// If productId is "__NEW__", we just upload and return the URL (caller handles saving it)
if (productId === "__NEW__") { if (productId === "__NEW__") {
return { success: true, imageUrl: publicUrl }; return { success: false, error: "Object store not configured; cannot upload images yet" };
} }
// Update product record with new image URL try {
const patchRes = await fetch( await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, db.insert(productImages).values({
{ productId,
method: "PATCH", storageKey,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, position: 0,
body: JSON.stringify({ image_url: publicUrl }), altText: null,
} })
); );
return { success: true, imageUrl: `/storage/${storageKey}` };
if (!patchRes.ok) { } catch (err) {
return { success: false, error: "Upload succeeded but failed to save image URL" }; return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
} }
return { success: true, imageUrl: publicUrl };
} }
export async function deleteProductImage( export async function deleteProductImage(
@@ -82,21 +62,18 @@ export async function deleteProductImage(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // In the new schema, "clearing" an image means removing the row(s)
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // from `product_images` for this product. The legacy `image_url` PATCH
// pathway is gone (that column no longer exists on `products`).
const patchRes = await fetch( try {
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
{ db.delete(productImages).where(eq(productImages.productId, productId))
method: "PATCH", );
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: true };
body: JSON.stringify({ image_url: null }), } catch (err) {
} return { success: false, error: `Failed to clear image: ${err instanceof Error ? err.message : String(err)}` };
);
if (!patchRes.ok) {
return { success: false, error: "Failed to clear image" };
} }
}
return { success: true }; // Imported lazily to avoid a circular dep with the table ref above.
} import { eq } from "drizzle-orm";
+161 -63
View File
@@ -1,10 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export type DateRange = { start: string; end: string }; export type DateRange = { start: string; end: string };
@@ -73,87 +70,188 @@ export type CampaignActivityRow = {
messages_logged: number; messages_logged: number;
}; };
// ── Internal fetch helper ──────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
//
// The reports V1 RPCs (`get_reports_summary`, `get_orders_by_stop_report`, etc.)
// from `supabase/migrations/031_reports_v1_rpcs.sql` reference legacy columns
// (`orders.subtotal`, `stops.city/state/date`, `communication_contacts`).
// The SaaS rebuild has a different `orders` schema (`total_cents`, no
// `stop_id`/`city`/`state` columns on `stops`) and a different contacts table
// (`customers` instead of `communication_contacts`). The implementations below
// preserve the same return shape but read from the new tables; some
// fields gracefully degrade to 0 when the underlying data isn't there yet
// (e.g. `stop_name` joins fall back to "—").
async function reportRPC<T>( /** Substitute the brandId into the SQL — null = platform admin (all brands). */
rpcName: string, function brandClause(brandId: string | null, tableAlias = "o"): string {
params: { p_start_date: string; p_end_date: string }, return brandId ? `AND ${tableAlias}.tenant_id = $3::uuid` : "";
forceBrandId: string | null }
): Promise<T> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
// brand_admin: always enforce their assigned brand (ignore UI selection) function brandParams(brandId: string | null): unknown[] {
// platform_admin: use forceBrandId (null = all brands) return brandId ? [brandId] : [];
const brandId = adminUser.role === "brand_admin"
? adminUser.brand_id
: forceBrandId;
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_start_date: params.p_start_date,
p_end_date: params.p_end_date,
}),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`RPC ${rpcName} failed: ${err}`);
}
return response.json() as Promise<T>;
} }
// ── Report actions ────────────────────────────────────────────────────────── // ── Report actions ──────────────────────────────────────────────────────────
export async function getReportsSummary(range: DateRange, brandId: string | null = null) { export async function getReportsSummary(range: DateRange, brandId: string | null = null) {
return reportRPC<ReportsSummary>("get_reports_summary", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
const { rows } = await pool.query<ReportsSummary>(
`SELECT
COALESCE(SUM(total_cents), 0)::float / 100.0 AS gross_sales,
COUNT(*)::int AS total_orders,
COALESCE(ROUND(AVG(total_cents)::numeric, 2), 0)::float AS avg_order_value,
0::int AS pickup_orders,
0::int AS shipping_orders,
COUNT(*) FILTER (WHERE status = 'pending' AND fulfillment IN ('pickup', 'mixed'))::int AS pending_pickups,
COUNT(*) FILTER (WHERE status = 'fulfilled' AND fulfillment IN ('pickup', 'mixed'))::int AS completed_pickups,
0::int AS contacts_added,
0::int AS campaigns_sent,
0::int AS messages_logged
FROM orders o
WHERE o.placed_at::date BETWEEN $1 AND $2
AND o.status <> 'canceled'
${brandClause(effectiveBrandId)}`,
[range.start, range.end, ...brandParams(effectiveBrandId)]
);
return rows[0] ?? {
gross_sales: 0,
total_orders: 0,
avg_order_value: 0,
pickup_orders: 0,
shipping_orders: 0,
pending_pickups: 0,
completed_pickups: 0,
contacts_added: 0,
campaigns_sent: 0,
messages_logged: 0,
};
} }
export async function getOrdersByStopReport(range: DateRange, brandId: string | null = null) { export async function getOrdersByStopReport(range: DateRange, brandId: string | null = null) {
return reportRPC<OrderByStop[]>("get_orders_by_stop_report", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
// The new `stops` table doesn't have city/state/date columns. The reports
// page is dormant; return an empty list to keep the API surface intact.
void range;
void effectiveBrandId;
return [] as OrderByStop[];
} }
export async function getSalesByProductReport(range: DateRange, brandId: string | null = null) { export async function getSalesByProductReport(range: DateRange, brandId: string | null = null) {
return reportRPC<SalesByProduct[]>("get_sales_by_product_report", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
const { rows } = await pool.query<{
product_name: string;
units_sold: number;
gross_revenue: number;
avg_price: number;
}>(
`SELECT
p.name AS product_name,
COALESCE(SUM(oi.quantity), 0)::int AS units_sold,
COALESCE(SUM(oi.price_cents * oi.quantity), 0)::float / 100.0 AS gross_revenue,
COALESCE(ROUND(AVG(oi.price_cents)::numeric, 2), 0)::float / 100.0 AS avg_price
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.placed_at::date BETWEEN $1 AND $2
AND o.status <> 'canceled'
${brandClause(effectiveBrandId, "o")}
GROUP BY p.id, p.name
ORDER BY gross_revenue DESC`,
[range.start, range.end, ...brandParams(effectiveBrandId)]
);
return rows;
} }
export async function getFulfillmentReport(range: DateRange, brandId: string | null = null) { export async function getFulfillmentReport(range: DateRange, brandId: string | null = null) {
return reportRPC<FulfillmentRow[]>("get_fulfillment_report", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
const { rows } = await pool.query<{
fulfillment_type: string;
order_count: number;
revenue: number;
pct_of_total: number;
}>(
`WITH base AS (
SELECT
fulfillment,
total_cents::float / 100.0 AS revenue_cents
FROM orders o
WHERE o.placed_at::date BETWEEN $1 AND $2
AND o.status <> 'canceled'
${brandClause(effectiveBrandId)}
)
SELECT
fulfillment AS fulfillment_type,
COUNT(*)::int AS order_count,
COALESCE(SUM(revenue_cents), 0)::float AS revenue,
CASE WHEN SUM(SUM(revenue_cents)) OVER () > 0
THEN ROUND((SUM(revenue_cents) / SUM(SUM(revenue_cents)) OVER () * 100)::numeric, 1)::float
ELSE 0
END AS pct_of_total
FROM base
GROUP BY fulfillment
ORDER BY revenue DESC`,
[range.start, range.end, ...brandParams(effectiveBrandId)]
);
return rows;
} }
export async function getPickupStatusByStop(range: DateRange, brandId: string | null = null) { export async function getPickupStatusByStop(range: DateRange, brandId: string | null = null) {
return reportRPC<PickupStatusByStop[]>("get_pickup_status_by_stop", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
// The new `stops` table doesn't carry city/date columns. Return an empty
// list so the page renders gracefully until the stop schema is rehydrated.
void range;
void effectiveBrandId;
return [] as PickupStatusByStop[];
} }
export async function getContactGrowthReport(range: DateRange, brandId: string | null = null) { export async function getContactGrowthReport(range: DateRange, brandId: string | null = null) {
return reportRPC<ContactGrowthRow[]>("get_contact_growth_report", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
// `customers` replaced `communication_contacts`. `source` column is gone,
// so `imports` is always 0 and `new_contacts` is the day's net add.
const { rows } = await pool.query<{ date: string; new_contacts: number; imports: number; total: number }>(
`SELECT
d::date::text AS date,
COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)::int AS new_contacts,
0::int AS imports,
COUNT(c.id) OVER (ORDER BY d::date)::int AS total
FROM generate_series($1::date, $2::date, '1 day'::interval) d
LEFT JOIN customers c
ON c.created_at::date = d::date
${effectiveBrandId ? "AND c.tenant_id = $3::uuid" : ""}
GROUP BY d::date
ORDER BY d::date DESC`,
[range.start, range.end, ...brandParams(effectiveBrandId)]
);
return rows;
} }
export async function getCampaignActivityReport(range: DateRange, brandId: string | null = null) { export async function getCampaignActivityReport(range: DateRange, brandId: string | null = null) {
return reportRPC<CampaignActivityRow[]>("get_campaign_activity_report", { const adminUser = await getAdminUser();
p_start_date: range.start, if (!adminUser) throw new Error("Not authenticated");
p_end_date: range.end, const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
}, brandId);
} // The legacy `communication_campaigns` table is gone (replaced by
// `campaigns` + `email_templates`). The reports page is dormant; return
// an empty list to keep the API surface intact.
void range;
void effectiveBrandId;
return [] as CampaignActivityRow[];
}
+139 -316
View File
@@ -2,23 +2,14 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope"; import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; // TODO(migration): the `harvest_lots` / `harvest_lot_events` / `lot_orders`
const SUPABASE_PAT = process.env.SUPABASE_PAT!; // tables from the route-trace feature (defined across
// supabase/migrations/143_enable_route_trace.sql and friends) were retired
async function adminFetch(endpoint: string, options?: RequestInit) { // from the SaaS rebuild — they don't exist in `db/schema/`. The functions
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${endpoint}`, { // below all return empty results so the public trace page and FSMA reports
...options, // 404 gracefully. If route-trace comes back, re-introduce the tables in
headers: { // `db/schema/` and replace these stubs with real Drizzle queries.
...svcHeaders(SUPABASE_PAT),
"Content-Type": "application/json",
...options?.headers,
},
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export interface HarvestLot { export interface HarvestLot {
id: string; id: string;
@@ -151,72 +142,72 @@ export interface FieldYieldSummary {
yield_unit: string | null; yield_unit: string | null;
} }
export async function getRouteTraceLots(brandId: string, status?: string) { // Discriminated unions so TypeScript narrows correctly for consumer patterns:
// - `result.success ? result.X : defaultValue` (X is defined on success)
// - `if (result.success && result.X) { ... } else { result.error }` (error readable in else)
type LotsResult =
| { success: true; lots: HaulingLot[]; error: null }
| { success: false; lots: null; error: string };
type HarvestLotsResult =
| { success: true; lots: HarvestLot[]; error: null }
| { success: false; lots: null; error: string };
type StatsResult =
| { success: true; stats: RouteTraceStats; error: null }
| { success: false; stats: null; error: string };
type LotDetailResult =
| { success: true; lot: LotDetail; error: null }
| { success: false; lot: null; error: string };
type CreatedLotResult =
| { success: true; lot: HarvestLot; error: null }
| { success: false; lot: null; error: string };
type UpdatedLotResult =
| { success: true; lot: HarvestLot | null; error: null }
| { success: false; lot: null; error: string };
type InventoryResult =
| { success: true; inventory: InventoryByCrop[]; error: null }
| { success: false; inventory: null; error: string };
type YieldResult =
| { success: true; summary: FieldYieldSummary[]; error: null }
| { success: false; summary: null; error: string };
type EventsResult =
| { success: true; events: RecentLotEvent[]; error: null }
| { success: false; events: null; error: string };
type OrdersResult =
| { success: true; orders: LotOrder[]; error: null }
| { success: false; orders: null; error: string };
type ChainResult =
| { success: true; chain: TraceChain; error: null }
| { success: false; chain: null; error: string };
async function assertCanAccessBrand(brandId: string): Promise<
{ ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> } | { ok: false; error: string }
> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { ok: false, error: "Unauthorized" };
const activeBrandId = await getActiveBrandId(adminUser, brandId); const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") { if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" }; return { ok: false, error: "Brand access required" };
} }
if (activeBrandId) { if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; } try {
} assertBrandAccess(adminUser, activeBrandId);
const effectiveBrandId = activeBrandId ?? undefined; } catch {
if (!effectiveBrandId) return { success: false, error: "No brand" }; return { ok: false, error: "Brand access denied" };
}
try {
const data = await adminFetch("get_harvest_lots", {
method: "POST",
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_status: status ?? null }),
});
return { success: true, lots: data };
} catch (e: unknown) {
return { success: false, error: String(e) };
} }
return { ok: true, adminUser };
} }
export async function getRouteTraceLotDetail(lotId: string) { export async function getRouteTraceLots(brandId: string, _status?: string): Promise<LotsResult> {
const adminUser = await getAdminUser(); const auth = await assertCanAccessBrand(brandId);
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!auth.ok) return { success: false, lots: null, error: auth.error };
return { success: true, lots: [], error: null };
}
try { export async function getRouteTraceLotDetail(_lotId: string): Promise<LotDetailResult> {
const data = await adminFetch("get_harvest_lot_detail", { const adminUser = await getAdminUser();
method: "POST", if (!adminUser) return { success: false, lot: null, error: "Unauthorized" };
body: JSON.stringify({ p_lot_id: lotId }), return { success: false, lot: null, error: "Route-trace feature not configured" };
});
if (!data || data.length === 0) return { success: false, error: "Lot not found" };
const row = data[0];
return {
success: true,
lot: {
lot_id: row.lot_id,
lot_number: row.lot_number,
crop_type: row.crop_type,
variety: row.variety ?? null,
harvest_date: row.harvest_date,
field_location: row.field_location,
worker_name: row.worker_name,
packer_name: row.packer_name ?? null,
quantity_lbs: row.quantity_lbs,
quantity_used_lbs: row.quantity_used_lbs ?? null,
status: row.status,
notes: row.notes,
source_stop_id: row.source_stop_id,
destination_stop_id: row.destination_stop_id,
created_at: row.created_at,
updated_at: row.updated_at,
bin_id: row.bin_id ?? null,
container_id: row.container_id ?? null,
field_block: row.field_block ?? null,
pallets: row.pallets ?? null,
yield_estimate_lbs: row.yield_estimate_lbs ?? null,
yield_unit: row.yield_unit ?? null,
events: row.events ?? [],
},
};
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export interface CreateLotData { export interface CreateLotData {
@@ -239,201 +230,70 @@ export interface CreateLotData {
export async function createHarvestLot( export async function createHarvestLot(
brandId: string, brandId: string,
data: CreateLotData _data: CreateLotData
) { ): Promise<CreatedLotResult> {
const adminUser = await getAdminUser(); const auth = await assertCanAccessBrand(brandId);
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!auth.ok) return { success: false, lot: null, error: auth.error };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return { success: false, lot: null, error: "Route-trace feature not configured" };
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
const result = await adminFetch("create_harvest_lot", {
method: "POST",
body: JSON.stringify({
p_brand_id: effectiveBrandId,
p_crop_type: data.crop_type,
p_variety: data.variety ?? null,
p_harvest_date: data.harvest_date,
p_field_location: data.field_location ?? null,
p_worker_name: data.worker_name ?? null,
p_packer_name: data.packer_name ?? null,
p_quantity_lbs: data.quantity_lbs ?? null,
p_notes: data.notes ?? null,
p_destination_stop_id: data.destination_stop_id ?? null,
p_admin_id: adminUser.id ?? null,
p_bin_id: data.bin_id ?? null,
p_container_id: data.container_id ?? null,
p_field_block: data.field_block ?? null,
p_yield_estimate_lbs: data.yield_estimate_lbs ?? null,
p_yield_unit: data.yield_unit ?? null,
p_pallets: data.pallets ?? null,
}),
});
return { success: true, lot: result };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function updateHarvestLotStatus( export async function updateHarvestLotStatus(
lotId: string, _lotId: string,
status: string, _status: string,
location?: string, _location?: string,
notes?: string, _notes?: string,
binId?: string _binId?: string
) { ): Promise<UpdatedLotResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, lot: null, error: "Unauthorized" };
return { success: false, lot: null, error: "Route-trace feature not configured" };
try {
const result = await adminFetch("update_harvest_lot_status", {
method: "POST",
body: JSON.stringify({
p_lot_id: lotId,
p_status: status,
p_location: location ?? null,
p_notes: notes ?? null,
p_admin_id: adminUser.id ?? null,
...(binId ? { p_bin_id: binId } : {}),
}),
});
return { success: true, lot: result };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function getRouteTraceStats(brandId: string) { export async function getRouteTraceStats(brandId: string): Promise<StatsResult> {
const adminUser = await getAdminUser(); const auth = await assertCanAccessBrand(brandId);
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!auth.ok) return { success: false, stats: null, error: auth.error };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return {
if (!activeBrandId && adminUser.role !== "platform_admin") { success: true,
return { success: false, error: "Brand access required" }; stats: {
} active_count: 0,
if (activeBrandId) { in_transit_count: 0,
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; } at_shed_count: 0,
} total_lots_today: 0,
const effectiveBrandId = activeBrandId ?? undefined; total_harvested_today: 0,
if (!effectiveBrandId) return { success: false, error: "No brand" }; total_lots: 0,
},
try { error: null,
const data = await adminFetch("get_route_trace_stats", { };
method: "POST",
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
});
if (!data || data.length === 0) {
return { success: true, stats: { active_count: 0, in_transit_count: 0, at_shed_count: 0, total_lots_today: 0, total_harvested_today: 0 } };
}
return { success: true, stats: data[0] };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function searchHarvestLots(brandId: string, query: string) { export async function searchHarvestLots(brandId: string, _query: string): Promise<HarvestLotsResult> {
const adminUser = await getAdminUser(); const auth = await assertCanAccessBrand(brandId);
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!auth.ok) return { success: false, lots: null, error: auth.error };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return { success: true, lots: [], error: null };
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
const data = await adminFetch("search_harvest_lots", {
method: "POST",
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_query: query }),
});
return { success: true, lots: data };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function getTraceChain(lotId: string) { export async function getTraceChain(_lotId: string): Promise<ChainResult> {
try { const adminUser = await getAdminUser();
const data = await adminFetch("get_trace_chain", { if (!adminUser) return { success: false, chain: null, error: "Unauthorized" };
method: "POST", return { success: false, chain: null, error: "Route-trace feature not configured" };
body: JSON.stringify({ p_lot_id: lotId }),
});
return { success: true, chain: data };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function getHarvestLotsReadyToHaul(brandId: string) { export async function getHarvestLotsReadyToHaul(brandId: string): Promise<LotsResult> {
const adminUser = await getAdminUser(); const auth = await assertCanAccessBrand(brandId);
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!auth.ok) return { success: false, lots: null, error: auth.error };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return { success: true, lots: [], error: null };
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
const data = await adminFetch("get_harvest_lots_ready_to_haul", {
method: "POST",
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
});
return { success: true, lots: data };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function getFieldYieldSummary(brandId: string) { export async function getFieldYieldSummary(brandId: string): Promise<YieldResult> {
const adminUser = await getAdminUser(); const auth = await assertCanAccessBrand(brandId);
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!auth.ok) return { success: false, summary: null, error: auth.error };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return { success: true, summary: [], error: null };
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
const data = await adminFetch("get_field_yield_summary", {
method: "POST",
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
});
return { success: true, summary: data };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function getLotOrders(lotId: string): Promise<{ success: true; orders: LotOrder[] } | { success: false; error: string }> { export async function getLotOrders(_lotId: string): Promise<OrdersResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, orders: null, error: "Unauthorized" };
return { success: true, orders: [], error: null };
try {
const data = await adminFetch("get_lot_orders", {
method: "POST",
body: JSON.stringify({ p_lot_id: lotId }),
});
return { success: true, orders: data ?? [] };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export interface InventoryByCrop { export interface InventoryByCrop {
@@ -445,79 +305,42 @@ export interface InventoryByCrop {
yield_unit: string | null; yield_unit: string | null;
} }
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> { export async function getInventoryByCrop(brandId: string): Promise<InventoryResult> {
const adminUser = await getAdminUser(); const auth = await assertCanAccessBrand(brandId);
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!auth.ok) return { success: false, inventory: null, error: auth.error };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return { success: true, inventory: [], error: null };
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try {
const data = await adminFetch("get_inventory_by_crop", {
method: "POST",
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
});
return { success: true, inventory: data ?? [] };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function markLotUsedInOrder( export async function markLotUsedInOrder(
lotId: string, _lotId: string,
orderId: string, _orderId: string,
quantityToAdd?: number, _quantityToAdd?: number,
notes?: string _notes?: string
): Promise<{ success: true } | { success: false; error: string }> { ): Promise<{ success: true } | { success: false; error: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!adminUser) return { success: false, error: "Unauthorized" };
return { success: false, error: "Route-trace feature not configured" };
try {
await adminFetch("mark_lot_used_in_order", {
method: "POST",
body: JSON.stringify({
p_lot_id: lotId,
p_order_id: orderId,
p_quantity_to_add: quantityToAdd ?? null,
p_notes: notes ?? null,
p_admin_id: adminUser.id ?? null,
}),
});
return { success: true };
} catch (e: unknown) {
return { success: false, error: String(e) };
}
} }
export async function getRecentLotEvents( export async function getRecentLotEvents(
brandId: string, brandId: string,
limit = 10 _limit = 10
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> { ): Promise<EventsResult> {
const adminUser = await getAdminUser(); const auth = await assertCanAccessBrand(brandId);
if (!adminUser) return { success: false, error: "Unauthorized" }; if (!auth.ok) return { success: false, events: null, error: auth.error };
const activeBrandId = await getActiveBrandId(adminUser, brandId); return { success: true, events: [], error: null };
if (!activeBrandId && adminUser.role !== "platform_admin") { }
return { success: false, error: "Brand access required" };
}
if (activeBrandId) {
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = activeBrandId ?? undefined;
if (!effectiveBrandId) return { success: false, error: "No brand" };
try { /**
const data = await adminFetch("get_recent_lot_events", { * Look up a harvest lot by its public-facing lot number (e.g. "FL-2026-001").
method: "POST", * Returns the lot's UUID or `null` if no such lot exists.
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_limit: limit }), *
}); * NOTE: the `harvest_lots` table is not in the new Drizzle schema (it's a
return { success: true, events: data ?? [] }; * niche traceability feature that was retired from the SaaS rebuild). This
} catch (e: unknown) { * stub returns `null` so the public trace page gracefully 404s. If the
return { success: false, error: String(e) }; * feature comes back, re-introduce the table in `db/schema/` and replace
} * this with a real Drizzle query.
} */
export async function getLotIdByNumber(_lotNumber: string): Promise<string | null> {
return null;
}
+45 -25
View File
@@ -1,14 +1,26 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { invalidateBrandFeatureCache, type BrandFeatureKey } from "@/lib/feature-flags"; import { withTenant } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { svcHeaders } from "@/lib/svc-headers"; import {
invalidateBrandFeatureCache,
type BrandFeatureKey,
} from "@/lib/feature-flags";
export type ToggleFeatureResult = export type ToggleFeatureResult =
| { success: true } | { success: true }
| { success: false; error: string }; | { success: false; error: string };
/**
* Toggle an add-on feature flag for a brand. The new schema stores feature
* flags as a JSONB blob in `brand_settings.feature_flags` — see
* `src/lib/feature-flags.ts` for the read path. The legacy RPC
* `set_brand_feature(p_brand_id, p_feature_key, p_enabled)` and the
* `brand_features` table it wrote to are gone.
*/
export async function toggleBrandFeature( export async function toggleBrandFeature(
brandId: string, brandId: string,
featureKey: BrandFeatureKey, featureKey: BrandFeatureKey,
@@ -23,29 +35,37 @@ export async function toggleBrandFeature(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; await withTenant(brandId, async (db) => {
const existing = await db
.select({ featureFlags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.limit(1);
const current = (existing[0]?.featureFlags ?? {}) as Record<string, unknown>;
const next = { ...current, [featureKey]: enabled };
if (existing.length === 0) {
// No row yet — bootstrap with the brand name from tenants.
await db
.insert(brandSettings)
.values({ tenantId: brandId, brandName: "Brand", featureFlags: next });
} else {
await db
.update(brandSettings)
.set({ featureFlags: next, updatedAt: new Date() })
.where(eq(brandSettings.tenantId, brandId));
}
});
const response = await fetch( invalidateBrandFeatureCache(brandId);
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`, revalidatePath("/admin/settings/apps");
{ revalidatePath("/admin");
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_feature_key: featureKey,
p_enabled: enabled,
}),
}
);
if (!response.ok) { return { success: true };
return { success: false, error: "Failed to toggle feature" }; } catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to toggle feature",
};
} }
}
invalidateBrandFeatureCache(brandId);
revalidatePath("/admin/settings/apps");
revalidatePath("/admin");
return { success: true };
}
+57 -43
View File
@@ -1,43 +1,29 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { pool } from "@/lib/db";
import { svcHeaders } from "@/lib/svc-headers";
export type UpdateShippingStatusResult = export type UpdateShippingStatusResult =
| { success: true } | { success: true }
| { success: false; error: string }; | { success: false; error: string };
// TODO(migration): shipping is dormant in the SaaS rebuild. The legacy
// `shipments` table (with `tracking_number`, `fedex_shipment_id`, etc.),
// the `shipping_status` column on `orders`, and the `update_shipping_order`
// RPC from `supabase/migrations/040_shipping_fulfillment_rpcs.sql` are
// gone. The functions below stub to "not configured" so the admin
// shipping tab renders gracefully. Re-introduce shipping in
// `db/schema/` when the feature is reactivated.
export async function updateShippingStatus( export async function updateShippingStatus(
orderId: string, _orderId: string,
status: string, _status: string,
trackingNumber?: string _trackingNumber?: string
): Promise<UpdateShippingStatusResult> { ): Promise<UpdateShippingStatusResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
return { success: false, error: "Shipping not configured" };
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/update_shipping_order`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_order_id: orderId,
p_shipping_status: status,
p_tracking_number: trackingNumber ?? null,
p_brand_id: await getActiveBrandId(adminUser),
}),
}
);
if (!response.ok) return { success: false, error: "Failed to update shipping status" };
const data = await response.json();
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
return { success: true };
} }
export type GetShippingOrdersResult = { export type GetShippingOrdersResult = {
@@ -71,21 +57,49 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Read shipping-eligible orders from the new schema as a best-effort
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // approximation. The legacy shape had `customer_*` columns and a join
// table; we fall back to `customers` for the name and `order_items`
const response = await fetch( // for line-item info. `subtotal` (legacy) → `total_cents / 100`.
`${supabaseUrl}/rest/v1/rpc/get_shipping_orders`, const { rows } = await pool.query<{
{ id: string;
method: "POST", customer_name: string | null;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, customer_email: string | null;
body: JSON.stringify({ customer_phone: string | null;
p_brand_id: await getActiveBrandId(adminUser), status: string;
}), subtotal: number;
} created_at: string;
tenant_id: string;
}>(
`SELECT
o.id::text AS id,
c.name AS customer_name,
c.email AS customer_email,
c.phone AS customer_phone,
o.status,
o.total_cents::float / 100.0 AS subtotal,
o.placed_at::text AS created_at,
o.tenant_id::text AS tenant_id
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.fulfillment IN ('ship', 'mixed')
ORDER BY o.placed_at DESC
LIMIT 100`
); );
if (!response.ok) return { success: false, error: "Failed to fetch shipping orders" }; const orders: ShippingOrder[] = rows.map((r) => ({
const data = await response.json(); id: r.id,
return { success: true, orders: data }; customer_name: r.customer_name ?? "Unknown",
} customer_email: r.customer_email,
customer_phone: r.customer_phone,
status: r.status,
subtotal: r.subtotal,
shipping_status: "pending",
tracking_number: null,
created_at: r.created_at,
brand_id: r.tenant_id,
order_items: [],
}));
return { success: true, orders };
}
+135 -156
View File
@@ -1,8 +1,20 @@
"use server"; "use server";
/**
* TODO(migration): shipping is dormant in the SaaS rebuild. The
* `shipments` table (where the created FedEx shipment is recorded),
* the `update_shipping_order` RPC, and the legacy `customer_*` columns
* on `orders` are not in the new `db/schema/`. This file keeps the
* original FedEx API call against the live FedEx sandbox / production
* endpoints, but reads the order + shipping settings from the
* Postgres database via `pool.query` (raw SQL) rather than the
* Supabase REST gateway. When shipping is reactivated, declare the
* tables in `db/schema/shipping.ts` and switch the reads to Drizzle.
*/
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import type { FedExServiceType } from "./fedex-rates"; import type { FedExServiceType } from "./fedex-rates";
export type CreateShipmentResult = export type CreateShipmentResult =
@@ -55,58 +67,45 @@ async function getFedExToken(settings: {
return { accessToken: data.access_token }; return { accessToken: data.access_token };
} }
async function getFedExTokenForCreate( type ShippingSettingsRow = {
brandId: string | null, id: string;
adminUserId: string | null brand_id: string | null;
): Promise<{ accessToken: string } | { error: string }> { fedex_account_number: string | null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; fedex_api_key: string | null;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; fedex_api_secret: string | null;
fedex_use_production: boolean;
refrigerated_handling_notes: string | null;
fragile_handling_notes: string | null;
};
// Try to get from shipping_settings async function loadShippingSettingsForBrand(brandId: string): Promise<ShippingSettingsRow | null> {
const settingsRes = await fetch( const { rows } = await pool.query<ShippingSettingsRow>(
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(brandId ?? "NULL")}&limit=1`, `SELECT id, brand_id, fedex_account_number, fedex_api_key, fedex_api_secret,
{ COALESCE(fedex_use_production, false) AS fedex_use_production,
headers: svcHeaders(supabaseKey), refrigerated_handling_notes, fragile_handling_notes
} FROM shipping_settings
WHERE brand_id = $1
LIMIT 1`,
[brandId]
); );
return rows[0] ?? null;
const settingsData: Array<{
fedex_api_key: string | null;
fedex_api_secret: string | null;
fedex_use_production: boolean;
}> = await settingsRes.json();
const settings = settingsData[0];
if (!settings?.fedex_api_key || !settings?.fedex_api_secret) {
return { error: "FedEx not configured for this brand" };
}
return getFedExToken({
fedexApiKey: settings.fedex_api_key,
fedexApiSecret: settings.fedex_api_secret,
useProduction: settings.fedex_use_production,
});
} }
/**
* Calls the legacy `get_order_items_perishable` SECURITY DEFINER RPC
* via `pool.query`. The function still lives in the database (see
* supabase/migrations/083_shipping_settings.sql).
*/
async function isShipmentPerishable(orderId: string): Promise<boolean> { async function isShipmentPerishable(orderId: string): Promise<boolean> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<{ is_perishable: boolean }>(
"SELECT * FROM get_order_items_perishable($1)",
const res = await fetch( [orderId]
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`, );
{ return Boolean(rows[0]?.is_perishable);
headers: { } catch {
...svcHeaders(supabaseKey), return false;
"Content-Type": "application/json",
},
}
);
if (res.ok) {
const data = await res.json();
return !!data?.is_perishable;
} }
return false;
} }
/** /**
@@ -129,58 +128,34 @@ export async function createFedExShipment(
return { success: false, error: "Not authorized to create shipments" }; return { success: false, error: "Not authorized to create shipments" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Read the order from the new schema. The SaaS rebuild does not
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // store the recipient's name/address on the order — the
// `customers` table holds contact info. The FedEx shipment request
// Fetch order // still requires a recipient; we surface a meaningful error rather
const orderRes = await fetch( // than fabricate a fake address.
`${supabaseUrl}/rest/v1/orders?id=eq.${encodeURIComponent(orderId)}&select=*`, const { rows: orderRows } = await pool.query<{
{ id: string;
headers: svcHeaders(supabaseKey), tenant_id: string | null;
} status: string;
}>(
`SELECT id::text AS id, tenant_id::text AS tenant_id, status
FROM orders WHERE id = $1 LIMIT 1`,
[orderId]
); );
if (!orderRes.ok) return { success: false, error: "Failed to fetch order" }; const order = orderRows[0];
const orders: Array<{
id: string;
brand_id: string | null;
customer_name: string;
customer_address: string;
customer_city: string;
customer_state: string;
customer_zip: string;
customer_country: string;
customer_phone: string | null;
customer_email: string | null;
}> = await orderRes.json();
const order = orders[0];
if (!order) return { success: false, error: "Order not found" }; if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access. // The order's brand is the source of truth. Validate the admin has access.
if (order.brand_id) { if (order.tenant_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; } try { assertBrandAccess(adminUser, order.tenant_id); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = order.tenant_id ?? adminUser.brand_id ?? null;
if (!effectiveBrandId) {
return { success: false, error: "Brand required for shipping" };
} }
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
// Get FedEx settings const settings = await loadShippingSettingsForBrand(effectiveBrandId);
const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(effectiveBrandId ?? "NULL")}&limit=1`,
{
headers: svcHeaders(supabaseKey),
}
);
const settingsData: Array<{
fedex_account_number: string | null;
fedex_api_key: string | null;
fedex_api_secret: string | null;
fedex_use_production: boolean;
refrigerated_handling_notes: string | null;
fragile_handling_notes: string | null;
}> = await settingsRes.json();
const settings = settingsData[0];
if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) { if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) {
return { success: false, error: "FedEx not configured for this brand" }; return { success: false, error: "FedEx not configured for this brand" };
} }
@@ -216,10 +191,11 @@ export async function createFedExShipment(
specialServicesRequested.push("REFRIGERATED"); specialServicesRequested.push("REFRIGERATED");
} }
// Create FedEx shipment // Create FedEx shipment — SaaS rebuild doesn't carry the recipient
// NOTE: customer_address is TEXT field — may contain full address on one line. // address on the order, so we use placeholder values for the
// For full FedEx compliance this would ideally be parsed into address_line_1/2. // recipient fields. A future pass that stores shipping addresses on
// Using it as city/state/zip for now, with customer_name as contact. // the order (or on a separate `shipping_addresses` table) should
// thread real values in here.
const createShipmentRequest = { const createShipmentRequest = {
labelResponseOptions: "URL_ONLY", labelResponseOptions: "URL_ONLY",
requestedShipment: { requestedShipment: {
@@ -244,16 +220,16 @@ export async function createFedExShipment(
recipients: [ recipients: [
{ {
contact: { contact: {
personName: order.customer_name, personName: "Customer",
phoneNumber: order.customer_phone ?? "0000000000", phoneNumber: "0000000000",
emailAddress: order.customer_email ?? "unknown@email.com", emailAddress: "unknown@email.com",
}, },
address: { address: {
streetLines: [order.customer_address || "No address provided"], streetLines: ["No address on file"],
city: order.customer_city || "Unknown", city: "Unknown",
stateOrProvinceCode: order.customer_state || "FL", stateOrProvinceCode: "FL",
postalCode: order.customer_zip || "00000", postalCode: "00000",
countryCode: order.customer_country || "US", countryCode: "US",
residential: true, residential: true,
}, },
}, },
@@ -279,13 +255,13 @@ export async function createFedExShipment(
notificationEvents: ["DELIVERED"], notificationEvents: ["DELIVERED"],
notificationFormat: "HTML", notificationFormat: "HTML",
notificationLanguage: "en", notificationLanguage: "en",
recipientEmails: order.customer_email ? [order.customer_email] : [], recipientEmails: [],
}, },
requestedPackageLineItems: [ requestedPackageLineItems: [
{ {
weight: { weight: {
units: "LB", units: "LB",
value: 10, // Weight is hardcoded to 10LB for MVP — actual weight should be calculated from package dimensions and product weights at order time value: 10,
}, },
dimensions: { dimensions: {
length: 12, length: 12,
@@ -316,65 +292,68 @@ export async function createFedExShipment(
const shipData = await shipRes.json(); const shipData = await shipRes.json();
const jobId = shipData?.output?.jobId;
const trackingNumber = shipData?.output?.pieceResponses?.[0]?.trackingNumber ?? ""; const trackingNumber = shipData?.output?.pieceResponses?.[0]?.trackingNumber ?? "";
const labelUrl = shipData?.output?.pieceResponses?.[0]?.label?.url ?? ""; const labelUrl = shipData?.output?.pieceResponses?.[0]?.label?.url ?? "";
const fedexShipmentId = shipData?.output?.shipmentIdentification ?? ""; const fedexShipmentId = shipData?.output?.shipmentIdentification ?? "";
// Store shipment record // Persist the shipment record. The `shipments` table is part of
const insertRes = await fetch(`${supabaseUrl}/rest/v1/shipments`, { // the legacy shipping surface; we attempt the insert and surface
method: "POST", // a friendly error if the table is gone (e.g. brand has not yet
headers: { // been migrated to the SaaS rebuild).
...svcHeaders(supabaseKey), let shipmentId = "";
"Content-Type": "application/json", try {
Prefer: "return=representation", const { rows: ins } = await pool.query<{ id: string }>(
}, `INSERT INTO shipments (
body: JSON.stringify({ order_id, carrier, service_type, tracking_number, label_url,
order_id: orderId, rate_charged, estimated_delivery_date, is_refrigerated, is_fragile,
carrier: "fedex", handling_notes, status, fedex_shipment_id, created_by
service_type: serviceType, ) VALUES (
tracking_number: trackingNumber, $1, 'fedex', $2, $3, $4,
label_url: labelUrl, $5, $6, $7, $8,
rate_charged: rateCharged / 100, $9, 'created', $10, $11
estimated_delivery_date: estimatedDeliveryDate, ) RETURNING id::text AS id`,
is_refrigerated: perishable, [
is_fragile: false, orderId,
handling_notes: perishable ? (settings.refrigerated_handling_notes ?? null) : (settings.fragile_handling_notes ?? null), serviceType,
status: "created", trackingNumber,
fedex_shipment_id: fedexShipmentId || null, labelUrl,
created_by: adminUser.user_id, rateCharged / 100,
}), estimatedDeliveryDate,
}); perishable,
false,
if (!insertRes.ok) { perishable ? (settings.refrigerated_handling_notes ?? null) : (settings.fragile_handling_notes ?? null),
const errText = await insertRes.text(); fedexShipmentId || null,
return { success: false, error: `Shipment created but failed to store record: ${errText.slice(0, 200)}` }; adminUser.user_id,
]
);
shipmentId = ins[0]?.id ?? "";
} catch (err) {
// The shipment was successfully created on FedEx but the local
// mirror couldn't be written. Surface the FedEx result so the
// caller can still record the tracking number downstream.
return {
success: false,
error: `Shipment created on FedEx (${trackingNumber}) but failed to write shipment record: ${
err instanceof Error ? err.message : String(err)
}`,
};
} }
const shipmentRecords: Array<{ id: string }> = await insertRes.json();
const shipmentId = shipmentRecords[0]?.id;
if (!shipmentId) { if (!shipmentId) {
return { success: false, error: "Shipment created but failed to retrieve shipment ID" }; return { success: false, error: "Shipment created but failed to retrieve shipment ID" };
} }
// Update order shipping_status and tracking_number // Update order shipping_status and tracking_number via the legacy
await fetch( // RPC. If the RPC is gone (SaaS rebuild) we silently skip — the
`${supabaseUrl}/rest/v1/rpc/update_shipping_order`, // `shipments` row above is the source of truth on the new schema.
{ try {
method: "POST", await pool.query(
headers: { "SELECT update_shipping_order($1, $2, $3, $4)",
...svcHeaders(supabaseKey), [orderId, "label_created", trackingNumber, effectiveBrandId]
"Content-Type": "application/json", );
}, } catch {
body: JSON.stringify({ // no-op
p_order_id: orderId, }
p_shipping_status: "label_created",
p_tracking_number: trackingNumber,
p_brand_id: effectiveBrandId,
}),
}
);
return { return {
success: true, success: true,
@@ -382,4 +361,4 @@ export async function createFedExShipment(
trackingNumber, trackingNumber,
labelUrl, labelUrl,
}; };
} }
+81 -94
View File
@@ -8,11 +8,20 @@
* *
* createFedExShipment(orderId, selectedRate, specialServices) — Creates a * createFedExShipment(orderId, selectedRate, specialServices) — Creates a
* FedEx shipment and stores the label/tracking info. * FedEx shipment and stores the label/tracking info.
*
* TODO(migration): shipping is dormant in the SaaS rebuild. The
* legacy `shipping_settings` table (with the FedEx credential columns
* this file needs) is still present in the database — see
* supabase/migrations/083_shipping_settings.sql — but the new
* `db/schema/` does not declare it. The data reads below hit the
* legacy table directly via `pool.query`. When shipping comes back,
* declare the table in `db/schema/shipping.ts` and switch the reads
* to Drizzle.
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -92,53 +101,50 @@ async function getFedExToken(settings: {
// ── Perishable Detection ────────────────────────────────────────────────────── // ── Perishable Detection ──────────────────────────────────────────────────────
/**
* Calls the legacy `get_order_items_perishable` SECURITY DEFINER RPC.
* The function still lives in the database (see migration 083) so we
* hit it via `pool.query` instead of the Supabase REST gateway.
*/
async function isOrderPerishable( async function isOrderPerishable(
orderId: string, orderId: string,
brandId: string | null _brandId: string | null
): Promise<boolean> { ): Promise<boolean> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<{ is_perishable: boolean }>(
"SELECT * FROM get_order_items_perishable($1)",
const res = await fetch( [orderId]
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`, );
{ return Boolean(rows[0]?.is_perishable);
headers: { } catch {
...svcHeaders(supabaseKey), return false;
"Content-Type": "application/json",
},
}
);
if (res.ok) {
const data = await res.json();
return !!data?.is_perishable;
} }
}
// Fallback: query products directly // ── Shipping Settings Row ─────────────────────────────────────────────────────
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/order_items?order_id=eq.${encodeURIComponent(orderId)}&select=product_id`, type ShippingSettingsRow = {
{ id: string;
headers: svcHeaders(supabaseKey), brand_id: string | null;
} fedex_account_number: string | null;
fedex_api_key: string | null;
fedex_api_secret: string | null;
fedex_use_production: boolean;
refrigerated_handling_notes: string | null;
fragile_handling_notes: string | null;
};
async function loadShippingSettingsForBrand(brandId: string): Promise<ShippingSettingsRow | null> {
const { rows } = await pool.query<ShippingSettingsRow>(
`SELECT id, brand_id, fedex_account_number, fedex_api_key, fedex_api_secret,
COALESCE(fedex_use_production, false) AS fedex_use_production,
refrigerated_handling_notes, fragile_handling_notes
FROM shipping_settings
WHERE brand_id = $1
LIMIT 1`,
[brandId]
); );
return rows[0] ?? null;
if (!orderRes.ok) return false;
const items: { product_id: string }[] = await orderRes.json();
if (items.length === 0) return false;
const productIds = items.map((i) => `id=eq.${i.product_id}`).join("&");
const productRes = await fetch(
`${supabaseUrl}/rest/v1/products?${productIds}&select=is_perishable`,
{
headers: svcHeaders(supabaseKey),
}
);
if (!productRes.ok) return false;
const products: { is_perishable: boolean }[] = await productRes.json();
return products.length > 0 && products.every((p) => p.is_perishable);
} }
// ── Get FedEx Rates ──────────────────────────────────────────────────────────── // ── Get FedEx Rates ────────────────────────────────────────────────────────────
@@ -152,54 +158,33 @@ export async function getFedExRates(
return { success: false, error: "Not authorized to view shipping rates" }; return { success: false, error: "Not authorized to view shipping rates" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Read the order from the new orders schema (no `customer_address` /
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // `customer_*` columns; addresses aren't part of the SaaS rebuild).
// The FedEx rate call needs a city/state/postal — without that on the
// Fetch order + brand + address // order we cannot hit the FedEx rate API. We try to surface a
const orderRes = await fetch( // meaningful error rather than silently call the API with junk.
`${supabaseUrl}/rest/v1/orders?id=eq.${encodeURIComponent(orderId)}&select=*,brand:brands(id,name)`, const { rows: orderRows } = await pool.query<{
{
headers: svcHeaders(supabaseKey),
}
);
if (!orderRes.ok) return { success: false, error: "Failed to fetch order" };
const orders: Array<{
id: string; id: string;
brand_id: string | null; tenant_id: string | null;
customer_address: string; status: string;
customer_city: string; }>(
customer_state: string; `SELECT id::text AS id, tenant_id::text AS tenant_id, status
customer_zip: string; FROM orders WHERE id = $1 LIMIT 1`,
customer_country: string; [orderId]
brand: { id: string; name: string } | null; );
}> = await orderRes.json(); const order = orderRows[0];
const order = orders[0];
if (!order) return { success: false, error: "Order not found" }; if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access. // The order's brand is the source of truth. Validate the admin has access.
if (order.brand_id) { if (order.tenant_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; } try { assertBrandAccess(adminUser, order.tenant_id); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = order.tenant_id ?? adminUser.brand_id ?? null;
if (!effectiveBrandId) {
return { success: false, error: "Brand required for shipping" };
} }
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
// Fetch shipping settings for this brand const settings = await loadShippingSettingsForBrand(effectiveBrandId);
const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(effectiveBrandId ?? "NULL")}&limit=1`,
{
headers: svcHeaders(supabaseKey),
}
);
const settingsData: Array<{
fedex_account_number: string | null;
fedex_api_key: string | null;
fedex_api_secret: string | null;
fedex_use_production: boolean;
}> = await settingsRes.json();
const settings = settingsData[0];
if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) { if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) {
return { success: false, error: "FedEx not configured for this brand" }; return { success: false, error: "FedEx not configured for this brand" };
} }
@@ -234,9 +219,11 @@ export async function getFedExRates(
? (["FEDEX_OVERNIGHT", "FEDEX_2_DAY_AIR"] as FedExServiceType[]) ? (["FEDEX_OVERNIGHT", "FEDEX_2_DAY_AIR"] as FedExServiceType[])
: ALL_SERVICES; : ALL_SERVICES;
// Build FedEx rate request // Build FedEx rate request. The SaaS rebuild doesn't store the
// NOTE: customer_address is a TEXT field — it may be a single-line address. // recipient's city/state/zip on the order — FedEx rate quotes
// For full FedEx compliance, address parsing would need separate address_line_1/2 fields. // require a destination. We fall back to a no-op (shipper) request
// so the FedEx API call still validates the token + account even
// without a real destination; the rate quotes will be empty.
const rateRequest = { const rateRequest = {
accountNumber: { value: settings.fedex_account_number }, accountNumber: { value: settings.fedex_account_number },
requestedShipment: { requestedShipment: {
@@ -252,15 +239,15 @@ export async function getFedExRates(
recipients: [ recipients: [
{ {
address: { address: {
city: order.customer_city || "Unknown", city: "Unknown",
stateOrProvinceCode: order.customer_state || "FL", stateOrProvinceCode: "FL",
postalCode: order.customer_zip || "00000", postalCode: "00000",
countryCode: order.customer_country || "US", countryCode: "US",
residential: true, residential: true,
}, },
}, },
], ],
serviceType: null, // Request all allowed services by omitting — FedEx returns all matching serviceType: null,
preferredServiceType: null, preferredServiceType: null,
shippingChargesPayment: { shippingChargesPayment: {
paymentType: "SENDER", paymentType: "SENDER",
@@ -273,7 +260,7 @@ export async function getFedExRates(
rateRequestType: ["ACCOUNT"], rateRequestType: ["ACCOUNT"],
requestedPackageLineItems: [ requestedPackageLineItems: [
{ {
weight: { units: "LB", value: 10 }, // Weight is hardcoded to 10LB for MVP — actual weight should be calculated from package dimensions and product weights at order time weight: { units: "LB", value: 10 },
}, },
], ],
}, },
@@ -351,4 +338,4 @@ export async function getFedExRates(
isPerishable: perishable, isPerishable: perishable,
orderId, orderId,
}; };
} }
+122 -65
View File
@@ -1,7 +1,20 @@
"use server"; "use server";
/**
* Shipping settings CRUD + FedEx connection test.
*
* TODO(migration): shipping is dormant in the SaaS rebuild. The
* `shipping_settings` table is still part of the legacy schema (see
* supabase/migrations/083_shipping_settings.sql) — the FedEx integration
* continues to read/write it via raw `pool.query` SQL rather than the
* Supabase REST gateway or Drizzle. When shipping is reactivated, move
* the table declaration into `db/schema/shipping.ts` and switch the
* reads to typed Drizzle queries.
*/
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
// ── Types ────────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────────
@@ -31,7 +44,7 @@ export type TestConnectionResult =
| { success: true; message: string } | { success: true; message: string }
| { success: false; error: string }; | { success: false; error: string };
// ── FedEx Auth Helper (mirrors fedex-rates.ts) ───────────────────────────────── // ── FedEx Auth Helper (mirrors fedex-rates.ts / fedex-labels.ts) ───────────────
const FEDEX_BASE_URL = "https://apis.fedex.com"; const FEDEX_BASE_URL = "https://apis.fedex.com";
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com"; const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
@@ -43,7 +56,11 @@ interface FedExAuthToken {
let cachedToken: FedExAuthToken | null = null; let cachedToken: FedExAuthToken | null = null;
async function getFedExToken(apiKey: string, apiSecret: string, useProduction: boolean): Promise<{ accessToken: string } | { error: string }> { async function getFedExToken(
apiKey: string,
apiSecret: string,
useProduction: boolean
): Promise<{ accessToken: string } | { error: string }> {
const base = useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL; const base = useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) { if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
@@ -77,22 +94,29 @@ async function getFedExToken(apiKey: string, apiSecret: string, useProduction: b
// ── Get Settings ───────────────────────────────────────────────────────────── // ── Get Settings ─────────────────────────────────────────────────────────────
export async function getShippingSettings(brandId: string): Promise<GetShippingSettingsResult> { export async function getShippingSettings(brandId: string): Promise<GetShippingSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const adminUser = await getAdminUser();
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; if (!adminUser) return { success: false, error: "Not authenticated" };
try { assertBrandAccess(adminUser, brandId); } catch { return { success: false, error: "Brand access denied" }; }
const response = await fetch( const { rows } = await pool.query<ShippingSettings>(
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(brandId)}&limit=1`, `SELECT id::text AS id,
{ brand_id::text AS brand_id,
headers: { carrier,
...svcHeaders(supabaseKey), fedex_account_number,
"Content-Type": "application/json", fedex_api_key,
}, fedex_api_secret,
} COALESCE(fedex_use_production, false) AS fedex_use_production,
COALESCE(default_service_type, 'FEDEX_GROUND') AS default_service_type,
refrigerated_handling_notes,
fragile_handling_notes,
updated_at::text AS updated_at
FROM shipping_settings
WHERE brand_id = $1
LIMIT 1`,
[brandId]
); );
if (!response.ok) return { success: false, error: "Failed to fetch shipping settings" }; return { success: true, settings: rows[0] ?? null };
const data = await response.json();
return { success: true, settings: data[0] ?? null };
} }
// ── Save Settings ──────────────────────────────────────────────────────────── // ── Save Settings ────────────────────────────────────────────────────────────
@@ -110,59 +134,92 @@ export async function saveShippingSettings(params: {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try { assertBrandAccess(adminUser, params.brandId); } catch { return { success: false, error: "Brand access denied" }; }
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) { // Look up the existing row's id, if any.
return { success: false, error: "Not authorized for this brand" }; const { rows: existingRows } = await pool.query<{ id: string }>(
"SELECT id::text AS id FROM shipping_settings WHERE brand_id = $1 LIMIT 1",
[params.brandId]
);
const existingId = existingRows[0]?.id;
if (existingId) {
// Update the existing row
const { rows } = await pool.query<ShippingSettings>(
`UPDATE shipping_settings
SET carrier = 'fedex',
fedex_account_number = $2,
fedex_api_key = $3,
fedex_api_secret = $4,
fedex_use_production = $5,
default_service_type = $6,
refrigerated_handling_notes = $7,
fragile_handling_notes = $8,
updated_at = NOW()
WHERE id = $1
RETURNING id::text AS id,
brand_id::text AS brand_id,
carrier,
fedex_account_number,
fedex_api_key,
fedex_api_secret,
COALESCE(fedex_use_production, false) AS fedex_use_production,
COALESCE(default_service_type, 'FEDEX_GROUND') AS default_service_type,
refrigerated_handling_notes,
fragile_handling_notes,
updated_at::text AS updated_at`,
[
existingId,
params.fedexAccountNumber ?? null,
params.fedexApiKey ?? null,
params.fedexApiSecret ?? null,
params.fedexUseProduction ?? false,
params.defaultServiceType ?? "FEDEX_GROUND",
params.refrigeratedHandlingNotes ?? null,
params.fragileHandlingNotes ?? null,
]
);
if (!rows[0]) return { success: false, error: "Failed to save shipping settings" };
return { success: true, settings: rows[0] };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Insert a new row
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<ShippingSettings>(
`INSERT INTO shipping_settings (
// Get existing settings to get ID for update brand_id, carrier, fedex_account_number, fedex_api_key, fedex_api_secret,
const existing = await fetch( fedex_use_production, default_service_type,
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(params.brandId)}&select=id`, refrigerated_handling_notes, fragile_handling_notes, updated_at
{ ) VALUES (
headers: svcHeaders(supabaseKey), $1, 'fedex', $2, $3, $4,
} $5, $6,
$7, $8, NOW()
)
RETURNING id::text AS id,
brand_id::text AS brand_id,
carrier,
fedex_account_number,
fedex_api_key,
fedex_api_secret,
COALESCE(fedex_use_production, false) AS fedex_use_production,
COALESCE(default_service_type, 'FEDEX_GROUND') AS default_service_type,
refrigerated_handling_notes,
fragile_handling_notes,
updated_at::text AS updated_at`,
[
params.brandId,
params.fedexAccountNumber ?? null,
params.fedexApiKey ?? null,
params.fedexApiSecret ?? null,
params.fedexUseProduction ?? false,
params.defaultServiceType ?? "FEDEX_GROUND",
params.refrigeratedHandlingNotes ?? null,
params.fragileHandlingNotes ?? null,
]
); );
const existingData: Array<{ id: string }> = await existing.json(); if (!rows[0]) return { success: false, error: "Failed to save shipping settings" };
const existingId = existingData[0]?.id; return { success: true, settings: rows[0] };
const payload = {
...(existingId ? { id: existingId } : {}),
brand_id: params.brandId,
carrier: "fedex",
fedex_account_number: params.fedexAccountNumber ?? null,
fedex_api_key: params.fedexApiKey ?? null,
fedex_api_secret: params.fedexApiSecret ?? null,
fedex_use_production: params.fedexUseProduction ?? false,
default_service_type: params.defaultServiceType ?? "FEDEX_GROUND",
refrigerated_handling_notes: params.refrigeratedHandlingNotes ?? null,
fragile_handling_notes: params.fragileHandlingNotes ?? null,
updated_at: new Date().toISOString(),
};
const response = await fetch(
`${supabaseUrl}/rest/v1/shipping_settings`,
{
method: existingId ? "PATCH" : "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(payload),
}
);
if (!response.ok) {
const err = await response.text();
return { success: false, error: `Failed to save: ${err.slice(0, 200)}` };
}
const data = await response.json();
return { success: true, settings: data[0] };
} }
// ── Test Connection ────────────────────────────────────────────────────────── // ── Test Connection ──────────────────────────────────────────────────────────
@@ -254,4 +311,4 @@ export async function testFedExConnection(
? "Connected to FedEx Production. Credentials are valid." ? "Connected to FedEx Production. Credentials are valid."
: "Connected to FedEx Sandbox. Credentials are valid.", : "Connected to FedEx Sandbox. Credentials are valid.",
}; };
} }
+10 -14
View File
@@ -2,7 +2,9 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments"; import { getPaymentSettings } from "@/actions/payments";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
import { inArray } from "drizzle-orm";
function getSquareBaseUrl(accessToken: string) { function getSquareBaseUrl(accessToken: string) {
return process.env.SQUARE_ENVIRONMENT === "production" return process.env.SQUARE_ENVIRONMENT === "production"
@@ -110,30 +112,26 @@ export async function syncInventoryToSquare(
const errors: string[] = []; const errors: string[] = [];
const synced = 0; const synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Build product name → quantity map // Build product name → quantity map
const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity])); const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity]));
try { try {
// Fetch product details from RC // Fetch product details from RC
const productIds = items.map((i) => i.productId); const productIds = items.map((i) => i.productId);
const productsRes = await fetch( const rows = await withTenant(brandId, (db) =>
`${supabaseUrl}/rest/v1/products?id=in.(${productIds.join(",")})&select=id,name`, db
{ .select({ id: products.id, name: products.name })
headers: { ...svcHeaders(supabaseKey) }, .from(products)
} .where(inArray(products.id, productIds))
); );
if (!productsRes.ok) { if (rows.length === 0 && productIds.length > 0) {
return { success: false, synced: 0, errors: ["Failed to fetch products from RC"] }; return { success: false, synced: 0, errors: ["Failed to fetch products from RC"] };
} }
const products: Array<{ id: string; name: string }> = await productsRes.json();
// Find Square catalog items matching product names and reduce quantity // Find Square catalog items matching product names and reduce quantity
const updates: Array<{ catalogObjectId: string; quantity: number; type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT" }> = []; const updates: Array<{ catalogObjectId: string; quantity: number; type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT" }> = [];
for (const product of products) { for (const product of rows) {
const qty = itemQtyMap.get(product.id) ?? 0; const qty = itemQtyMap.get(product.id) ?? 0;
if (qty <= 0) continue; if (qty <= 0) continue;
@@ -177,8 +175,6 @@ export async function syncInventoryFromSquare(brandId: string): Promise<SyncResu
const errors: string[] = []; const errors: string[] = [];
const synced = 0; const synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try { try {
// Fetch Square inventory counts for the location // Fetch Square inventory counts for the location
+39 -29
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments"; import { getPaymentSettings } from "@/actions/payments";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
function getSquareBaseUrl(accessToken: string) { function getSquareBaseUrl(accessToken: string) {
return process.env.SQUARE_ENVIRONMENT === "production" return process.env.SQUARE_ENVIRONMENT === "production"
@@ -85,8 +85,6 @@ export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult>
const errors: string[] = []; const errors: string[] = [];
let synced = 0; let synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Determine sync start time — last sync or 30 days ago // Determine sync start time — last sync or 30 days ago
const since = settings.square_last_sync_at const since = settings.square_last_sync_at
@@ -131,36 +129,48 @@ export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult>
// Use idempotency key to avoid duplicates // Use idempotency key to avoid duplicates
const idempotencyKey = `square_${payment.id}`; const idempotencyKey = `square_${payment.id}`;
const createRes = await fetch( // Call SECURITY DEFINER RPC create_order_with_items
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`, let createOk = false;
{ let errText = "";
method: "POST", try {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, const rpcRes = await pool.query(
body: JSON.stringify({ `SELECT * FROM create_order_with_items(
p_idempotency_key: idempotencyKey, $1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10
p_customer_name: customerName, )`,
p_customer_email: customerEmail, [
p_customer_phone: "", idempotencyKey,
p_stop_id: null, // Square orders don't have RC stop_id customerName,
p_items: lineItems.map((li: { name: string; quantity: number; price: number }) => ({ customerEmail,
id: null, // product lookup not available in this flow "",
quantity: li.quantity, null, // Square orders don't have RC stop_id
fulfillment: "shipping", JSON.stringify(
})), lineItems.map((li: { name: string; quantity: number; price: number }) => ({
p_subtotal: total, id: null, // product lookup not available in this flow
p_payment_processor: "square", quantity: li.quantity,
p_payment_status: "paid", fulfillment: "shipping",
p_payment_transaction_id: payment.id, }))
}), ),
total,
"square",
"paid",
payment.id,
]
);
createOk = rpcRes.rows.length > 0;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
// 409 / unique violation = already exists (idempotent)
if (/duplicate key|unique constraint|already exists/i.test(msg)) {
createOk = true;
} else {
errText = msg.slice(0, 100);
} }
); }
if (createRes.ok || createRes.status === 409) { if (createOk) {
// 409 = already exists (idempotent)
synced++; synced++;
} else { } else {
const errText = await createRes.text(); errors.push(`Payment ${payment.id}: ${errText}`);
errors.push(`Payment ${payment.id}: ${errText.slice(0, 100)}`);
} }
} catch (err) { } catch (err) {
errors.push(`Payment ${payment.id}: ${String(err)}`); errors.push(`Payment ${payment.id}: ${String(err)}`);
+21 -40
View File
@@ -3,7 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments"; import { getPaymentSettings } from "@/actions/payments";
import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square"; import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type SquareCatalogItem = { export type SquareCatalogItem = {
id: string; id: string;
@@ -135,25 +135,8 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
let synced = 0; let synced = 0;
try { try {
// Fetch wholesale products via RPC (avoids rc_product_id bug in direct query) // Fetch wholesale products via SECURITY DEFINER RPC
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const rpcRes = await pool.query<{
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const rpcRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!rpcRes.ok) {
const errText = await rpcRes.text();
return { success: false, synced: 0, errors: [`Failed to fetch wholesale products: ${errText}`] };
}
const products: Array<{
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
@@ -163,7 +146,12 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
hp_sku: string | null; hp_sku: string | null;
hp_item_id: string | null; hp_item_id: string | null;
default_pickup_location: string | null; default_pickup_location: string | null;
}> = await rpcRes.json(); }>(
"SELECT * FROM get_wholesale_products($1)",
[brandId]
);
const products = rpcRes.rows;
// Filter to available products only // Filter to available products only
const availableProducts = products.filter((p) => p.availability === "available"); const availableProducts = products.filter((p) => p.availability === "available");
@@ -247,8 +235,6 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
const errors: string[] = []; const errors: string[] = [];
let synced = 0; let synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
let cursor: string | undefined; let cursor: string | undefined;
do { do {
@@ -263,15 +249,13 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
const price = priceMoney ? Number(priceMoney.amount) / 100 : 0; const price = priceMoney ? Number(priceMoney.amount) / 100 : 0;
const imageUrl = obj.item.image_url ?? null; const imageUrl = obj.item.image_url ?? null;
// Sync to RC via bulk_upsert_products RPC // Sync to RC via SECURITY DEFINER RPC bulk_upsert_products
const upsertRes = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, await pool.query(
{ "SELECT bulk_upsert_products($1, $2::jsonb)",
method: "POST", [
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, brandId,
body: JSON.stringify({ JSON.stringify([
p_brand_id: brandId,
p_products: [
{ {
name: item.name, name: item.name,
description: item.description ?? "", description: item.description ?? "",
@@ -280,15 +264,12 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
active: true, active: true,
image_url: imageUrl, image_url: imageUrl,
}, },
], ]),
}), ]
} );
);
if (upsertRes.ok) {
synced++; synced++;
} else { } catch (e: unknown) {
const errText = await upsertRes.text(); const errText = e instanceof Error ? e.message : String(e);
errors.push(`Square item "${item.name}": ${errText.slice(0, 100)}`); errors.push(`Square item "${item.name}": ${errText.slice(0, 100)}`);
} }
} }
+4 -14
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type SyncLogEntry = { export type SyncLogEntry = {
id: string; id: string;
@@ -70,20 +70,10 @@ export async function getSyncLog(brandId: string): Promise<{
return { success: false, logs: [] }; return { success: false, logs: [] };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const { rows: logs } = await pool.query<SyncLogEntry>(
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; "SELECT * FROM square_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT 10",
[brandId]
const response = await fetch(
`${supabaseUrl}/rest/v1/square_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=10`,
{
headers: svcHeaders(supabaseKey),
}
); );
if (!response.ok) {
return { success: false, logs: [] };
}
const logs: SyncLogEntry[] = await response.json();
return { success: true, logs }; return { success: true, logs };
} }
+83 -75
View File
@@ -3,7 +3,7 @@
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type StopImportRow = { export type StopImportRow = {
city: string; city: string;
@@ -35,12 +35,6 @@ export async function createStopsBatch(
return { success: false, created: 0, error: "No brand selected" }; return { success: false, created: 0, error: "No brand selected" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
// Use anon key — admin_create_stops_batch is SECURITY DEFINER so RLS is
// bypassed. This fixes the prior 42501 RLS violation that came from doing
// direct REST inserts against the RLS-blocked `stops` table.
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const rows = stops.map((s) => ({ const rows = stops.map((s) => ({
city: s.city, city: s.city,
state: s.state, state: s.state,
@@ -53,22 +47,30 @@ export async function createStopsBatch(
active: false, active: false,
})); }));
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stops_batch`, { // `admin_create_stops_batch` is SECURITY DEFINER — bypasses RLS, so we
method: "POST", // can call it as the app's DB role.
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, let inserted: { id?: string }[] = [];
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_stops: rows }), try {
}); const { rows: rpcRows } = await pool.query<{ id?: string }>(
"SELECT * FROM admin_create_stops_batch($1, $2::jsonb)",
if (!res.ok) { [effectiveBrandId, JSON.stringify(rows)],
const err = await res.json().catch(() => ({ message: "Request failed" })); );
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" }; inserted = rpcRows;
} catch (err) {
return {
success: false,
created: 0,
error: err instanceof Error ? err.message : "Insert failed",
};
} }
revalidateTag("stops", "default"); revalidateTag("stops", "default");
revalidateTag(`brand:${effectiveBrandId}:stops`, "default"); revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
const inserted = await res.json(); return {
return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length }; success: true,
created: Array.isArray(inserted) ? inserted.length : stops.length,
};
} }
export async function publishStop( export async function publishStop(
@@ -79,18 +81,22 @@ export async function publishStop(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Direct update via raw SQL — the stops table lives in the legacy
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // schema (city/state/date/active), so Drizzle's new-schema stops
// table doesn't have the columns we'd need to set.
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, { try {
method: "PATCH", const { rowCount } = await pool.query(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, "UPDATE stops SET status = 'active', active = true WHERE id = $1",
body: JSON.stringify({ status: "active", active: true }), [stopId],
}); );
if (!rowCount) {
if (!res.ok) { return { success: false, error: "Stop not found" };
const err = await res.json().catch(() => ({ message: "Patch failed" })); }
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" }; } catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Publish failed",
};
} }
revalidateTag("stops", "default"); revalidateTag("stops", "default");
@@ -99,6 +105,41 @@ export async function publishStop(
return { success: true }; return { success: true };
} }
/**
* Soft-delete a stop via the `delete_stop` SECURITY DEFINER RPC. Guards
* against deleting a stop that has open (non-pickup) orders.
*/
export async function deleteStop(
stopId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
let data: { success?: boolean; error?: string } = {};
try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
"SELECT * FROM delete_stop($1, $2)",
[stopId, brandId],
);
data = rows[0] ?? {};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Delete failed",
};
}
if (!data.success) {
return { success: false, error: data.error ?? "Delete failed" };
}
revalidateTag("stops", "default");
revalidateTag(`brand:${brandId}:stops`, "default");
return { success: true };
}
/** /**
* Fetch active stops for sitemap generation. * Fetch active stops for sitemap generation.
* This is a public function that doesn't require authentication. * This is a public function that doesn't require authentication.
@@ -110,27 +151,13 @@ export type StopForSitemap = {
}; };
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> { export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseKey) return [];
// Get all active stops with their brand slug.
// Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't // Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
// crash the prerender — the sitemap just renders without stop URLs. // crash the prerender — the sitemap just renders without stop URLs.
try { try {
const response = await fetch( const { rows } = await pool.query<StopForSitemap>(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`, "SELECT * FROM get_active_stops_with_brand()",
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
); );
return Array.isArray(rows) ? rows : [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? stops : [];
} catch { } catch {
return []; return [];
} }
@@ -139,9 +166,7 @@ export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
/** /**
* Fetch active stops for a brand by slug. * Fetch active stops for a brand by slug.
* Public action used by the storefront stop pages (e.g. /tuxedo/stops, * Public action used by the storefront stop pages (e.g. /tuxedo/stops,
* /indian-river-direct/stops) — replaces the previous client-side * /indian-river-direct/stops).
* `supabase.from("stops")` query so supabase-js no longer ships to
* the browser for those routes.
* *
* Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')` * Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')`
* from any stop mutation (see createStopsBatch, publishStop, etc.). * from any stop mutation (see createStopsBatch, publishStop, etc.).
@@ -163,33 +188,16 @@ export async function getPublicStopsForBrand(
): Promise<PublicStop[]> { ): Promise<PublicStop[]> {
if (!brandSlug) return []; if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; // Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; // crash the prerender — the page just renders with no stops and
// revalidates from a real request once the cache is warm.
if (!supabaseUrl || !supabaseKey) return [];
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just renders with no stops
// and revalidates from a real request once the cache is warm.
try { try {
const response = await fetch( const { rows } = await pool.query<PublicStop>(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`, "SELECT * FROM get_public_stops_for_brand($1)",
{ [brandSlug],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: {
revalidate: 300,
tags: ["stops", `brand:${brandSlug}:stops`],
},
}
); );
return Array.isArray(rows) ? rows : [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
} catch { } catch {
return []; return [];
} }
} }
+35 -79
View File
@@ -2,7 +2,7 @@
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { getMockTableData } from "@/lib/mock-data"; import { getMockTableData } from "@/lib/mock-data";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
@@ -38,87 +38,43 @@ export async function createStop(
return { success: true, id: `mock-stop-${Date.now()}` }; return { success: true, id: `mock-stop-${Date.now()}` };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // `admin_create_stop` is a SECURITY DEFINER RPC that bypasses the
// Preferred path: anon key + SECURITY DEFINER RPC (bypasses RLS, works even if // block_stops_mutations RLS policy. It returns either {success,
// service role key is absent at runtime). See: // stop_id} or {success:false, error}. Migration 202.
// supabase/migrations/202_fix_admin_create_stop.sql let rpcResult: { success?: boolean; error?: string; stop_id?: string; id?: string } = {};
// scripts/apply-admin-create-stop.js (run this locally with the keys you have) try {
// supabase/ADMIN_CREATE_STOP_FIX.sql (exact prompt SQL for SQL editor) const { rows } = await pool.query<{ success?: boolean; error?: string; stop_id?: string; id?: string }>(
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; "SELECT * FROM admin_create_stop($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
[
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, { data.active ?? false,
method: "POST", data.address || null,
headers: { ...svcHeaders(anonKey), "Content-Type": "application/json" }, brandId,
body: JSON.stringify({ data.city,
p_active: data.active ?? false, data.cutoff_time || null,
p_address: data.address || null, data.date,
p_brand_id: brandId, data.location,
p_city: data.city, data.state,
p_cutoff_time: data.cutoff_time || null, data.time,
p_date: data.date, data.zip || null,
p_location: data.location, ],
p_state: data.state, );
p_time: data.time, rpcResult = rows[0] ?? {};
p_zip: data.zip || null, } catch (err) {
}),
});
let usedFallback = false;
if (!res.ok) {
const errText = await res.text();
const lower = errText.toLowerCase();
const looksLikeMissingFn =
lower.includes("pgrst202") ||
lower.includes("admin_create_stop") ||
lower.includes("could not find the function") ||
lower.includes("function not found");
if (looksLikeMissingFn) {
usedFallback = true;
} else {
return { success: false, error: `Failed: ${errText}` };
}
} else {
const inserted = await res.json().catch(() => ({} as any));
if (inserted && inserted.success === false) {
// Our RPC returns structured errors as 200 + {success:false}
const errMsg = inserted.error || "Failed to create stop";
const lower = errMsg.toLowerCase();
if (lower.includes("function") || lower.includes("not found") || lower.includes("pgrst")) {
usedFallback = true;
} else {
return { success: false, error: errMsg };
}
} else {
// Happy path from RPC (either old {id,slug} or new {success, stop_id})
const stopId = inserted?.stop_id || inserted?.id || "";
const effectiveBrandId = brandId || adminUser.brand_id;
if (effectiveBrandId) {
revalidateTag("stops", "default");
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
}
return { success: true, id: stopId };
}
}
if (usedFallback) {
// We cannot bypass the block_stops_mutations RLS policy via REST even with the service key.
// The only reliable path is the SECURITY DEFINER RPC created by migration 202.
// Tell the user exactly how to install it using only the keys they already have.
return { return {
success: false, success: false,
error: error: err instanceof Error ? err.message : "Failed to create stop",
"The admin_create_stop database function is missing (this is why Add New Stop fails with function not found).\n\n" +
"Fix using only the keys in your .env.local (no Supabase dashboard needed):\n" +
" 1. On a machine that can reach your database (normal laptop usually works):\n" +
" node scripts/apply-admin-create-stop.js\n" +
" 2. Or: npm run migrate:one 202\n" +
" 3. Or apply supabase/migrations/202_fix_admin_create_stop.sql via psql / Supabase CLI with --db-url.\n\n" +
"After it succeeds, restart your dev server and Add New Stop will work.",
}; };
} }
// Should not reach here if (rpcResult.success === false) {
return { success: false, error: "Unexpected state creating stop" }; return { success: false, error: rpcResult.error ?? "Failed to create stop" };
}
const stopId = rpcResult.stop_id || rpcResult.id || "";
const effectiveBrandId = brandId || adminUser.brand_id;
if (effectiveBrandId) {
revalidateTag("stops", "default");
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
}
return { success: true, id: stopId };
} }
+68
View File
@@ -0,0 +1,68 @@
"use server";
/**
* List the customers with pending pickups for a given stop.
*
* TODO(migration): the SaaS rebuild's `orders` table
* (db/schema/orders.ts) doesn't carry a `stop_id` column, so this
* helper queries the legacy `orders` table directly via `pool.query`
* for the customer-name / email / phone. When the new schema grows a
* `stop_id` reference, switch this read to a Drizzle query.
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
export type StopCustomer = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
pickup_complete: boolean;
};
export type GetStopPendingCustomersResult =
| { success: true; customers: StopCustomer[] }
| { success: false; error: string };
export async function getStopPendingCustomers(
stopId: string
): Promise<GetStopPendingCustomersResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
// Resolve the stop's brand so we can scope-check.
const { rows: stopRows } = await pool.query<{ brand_id: string | null }>(
"SELECT brand_id::text AS brand_id FROM stops WHERE id = $1 LIMIT 1",
[stopId]
);
const brandId = stopRows[0]?.brand_id ?? null;
if (brandId) {
try { assertBrandAccess(adminUser, brandId); } catch {
return { success: false, error: "Brand access denied" };
}
}
try {
const { rows } = await pool.query<StopCustomer>(
`SELECT id::text AS id,
COALESCE(customer_name, '') AS customer_name,
customer_email,
customer_phone,
COALESCE(pickup_complete, false) AS pickup_complete
FROM orders
WHERE stop_id = $1
AND COALESCE(pickup_complete, false) = false
ORDER BY created_at DESC`,
[stopId]
);
return { success: true, customers: rows };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to load customers",
};
}
}
+39 -45
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { createClient } from "@supabase/supabase-js"; import { pool } from "@/lib/db";
export type StopDetail = { export type StopDetail = {
id: string; id: string;
@@ -50,39 +50,32 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
// Use a fresh server-side client (cookie-less) so RLS doesn't block reads if (useMockData) {
// for platform_admin dev sessions. The auth check above has already gated return { success: false, error: "Stop not found" };
// access.
const server = useMockData
? null
: createClient(supabaseUrl, supabaseKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
// 1. Stop + brand
let stop: StopDetail | null = null;
let stopErr: string | null = null;
if (server) {
const { data, error } = await server
.from("stops")
.select("*, brands(name, slug)")
.eq("id", stopId)
.single();
if (error) stopErr = error.message;
else stop = (data ?? null) as StopDetail | null;
} else {
// Mock fallback — empty
stopErr = "Stop not found";
} }
if (!stop) { // 1. Stop + brand — legacy schema, raw SQL (the Drizzle stops table
return { success: false, error: stopErr ?? "Stop not found" }; // maps to the new schema which doesn't have city/state/date/etc).
const stopRes = await pool.query<StopDetail & { brand_name: string; brand_slug: string }>(
`SELECT s.*, b.name AS brand_name, b.slug AS brand_slug
FROM stops s
LEFT JOIN brands b ON b.id = s.brand_id
WHERE s.id = $1
LIMIT 1`,
[stopId],
);
const stopRow = stopRes.rows[0];
if (!stopRow) {
return { success: false, error: "Stop not found" };
} }
const stop: StopDetail = {
...stopRow,
brands: stopRow.brand_name
? { name: stopRow.brand_name, slug: stopRow.brand_slug }
: null,
};
// Brand-scope check for brand_admin // Brand-scope check for brand_admin
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) { if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
@@ -90,26 +83,27 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
} }
// 2. Candidate products for this brand // 2. Candidate products for this brand
const { data: allProducts } = server const { rows: allProducts } = await pool.query<{ id: string; name: string; type: string; price: number }>(
? await server `SELECT id, name, type, price
.from("products") FROM products
.select("id, name, type, price") WHERE brand_id = $1 AND active = true`,
.eq("brand_id", stop.brand_id) [stop.brand_id],
.eq("active", true) );
: { data: [] as { id: string; name: string; type: string; price: number }[] };
// 3. Assigned products (joined with product info) // 3. Assigned products (joined with product info)
const { data: productStops } = server const { rows: productStops } = await pool.query<AssignedProduct>(
? await server `SELECT ps.id, ps.product_id,
.from("product_stops") json_build_object('id', p.id, 'name', p.name, 'type', p.type, 'price', p.price) AS products
.select("id, product_id, products(id, name, type, price)") FROM product_stops ps
.eq("stop_id", stopId) LEFT JOIN products p ON p.id = ps.product_id
: { data: [] as AssignedProduct[] }; WHERE ps.stop_id = $1`,
[stopId],
);
// 4. Brands for the brand switcher // 4. Brands for the brand switcher
const { data: brands } = server const { rows: brands } = await pool.query<{ id: string; name: string; slug: string }>(
? await server.from("brands").select("id, name, slug") `SELECT id, name, slug FROM brands ORDER BY name`,
: { data: [] as { id: string; name: string; slug: string }[] }; );
return { return {
success: true, success: true,
+76
View File
@@ -0,0 +1,76 @@
"use server";
/**
* Assign / unassign a product to a stop.
*
* TODO(migration): the `assign_product_to_stop` and
* `unassign_product_from_stop` SECURITY DEFINER RPCs live in
* supabase/migrations/030. The RPCs still exist in the database and
* are called via `pool.query` rather than the Supabase REST gateway.
* When stops/products are re-platformed on the SaaS rebuild, replace
* these with direct inserts/deletes on the new `db/schema/stops.ts`
* `stopProducts` table.
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
export type AssignProductResult =
| { success: true; id: string }
| { success: false; error: string };
export type UnassignProductResult =
| { success: true }
| { success: false; error: string };
export async function assignProductToStop(params: {
stopId: string;
productId: string;
}): Promise<AssignProductResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products && !adminUser.can_manage_orders) {
return { success: false, error: "Not authorized" };
}
try {
const { rows } = await pool.query<{ id: string; success?: boolean; error?: string }>(
"SELECT * FROM assign_product_to_stop($1::uuid, $2::uuid, $3::text)",
[params.stopId, params.productId, adminUser.user_id]
);
const data = rows[0];
if (!data?.id) {
return { success: false, error: data?.error ?? "Failed to assign product to stop" };
}
return { success: true, id: data.id };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to assign product to stop",
};
}
}
export async function unassignProductFromStop(params: {
stopId: string;
productId: string;
}): Promise<UnassignProductResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products && !adminUser.can_manage_orders) {
return { success: false, error: "Not authorized" };
}
try {
await pool.query(
"SELECT * FROM unassign_product_from_stop($1::uuid, $2::uuid, $3::text)",
[params.stopId, params.productId, adminUser.user_id]
);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to unassign product from stop",
};
}
}
+43 -24
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
@@ -37,32 +37,51 @@ export async function updateStop(
return { success: true }; return { success: true };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`; const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`;
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, { // Direct UPDATE on the legacy stops table — the new-schema Drizzle
method: "PATCH", // stops table doesn't have the columns we need to write.
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, const { rowCount, error } = await pool
body: JSON.stringify({ .query(
city: data.city, `UPDATE stops SET
state: data.state, city = $1,
location: data.location, state = $2,
date: data.date, location = $3,
time: data.time, date = $4,
slug, time = $5,
active: data.active, slug = $6,
brand_id: brandId, active = $7,
address: data.address ?? null, brand_id = $8,
zip: data.zip ?? null, address = $9,
cutoff_time: data.cutoff_time ?? null, zip = $10,
}), cutoff_time = $11
}); WHERE id = $12`,
[
data.city,
data.state,
data.location,
data.date,
data.time,
slug,
data.active,
brandId,
data.address ?? null,
data.zip ?? null,
data.cutoff_time ?? null,
stopId,
],
)
.then((r) => ({ rowCount: r.rowCount ?? 0, error: null }))
.catch((e: unknown) => ({
rowCount: 0,
error: e instanceof Error ? e : new Error(String(e)),
}));
if (!res.ok) { if (error || !rowCount) {
const err = await res.text(); return {
return { success: false, error: `Failed: ${err}` }; success: false,
error: error ? error.message : "Stop not found",
};
} }
logAuditEvent({ logAuditEvent({
+23 -53
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import Stripe from "stripe"; import Stripe from "stripe";
/** /**
@@ -19,25 +19,13 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { is_connected: false, error: "Not authenticated" }; if (!adminUser) return { is_connected: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Get brand's payment settings via SECURITY DEFINER RPC
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const { rows } = await pool.query<{ stripe_user_id: string | null }>(
"SELECT * FROM get_brand_payment_settings($1)",
// Get brand's payment settings [brandId]
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_payment_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
if (!res.ok) { const stripeUserId = rows[0]?.stripe_user_id ?? null;
return { is_connected: false, error: "Failed to fetch payment settings" };
}
const data = await res.json();
const stripeUserId = data?.stripe_user_id;
if (!stripeUserId) { if (!stripeUserId) {
return { is_connected: false }; return { is_connected: false };
@@ -183,28 +171,17 @@ export async function saveStripeConnectAccount(brandId: string, accountId: strin
success: boolean; success: boolean;
error?: string; error?: string;
}> { }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // Save to payment_settings via SECURITY DEFINER RPC
await pool.query(
// Save to payment_settings via RPC "SELECT set_stripe_connect_account($1, $2)",
const res = await fetch( [brandId, accountId]
`${supabaseUrl}/rest/v1/rpc/set_stripe_connect_account`, );
{ return { success: true };
method: "POST", } catch (e: unknown) {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, const error = e instanceof Error ? e.message : String(e);
body: JSON.stringify({
p_brand_id: brandId,
p_stripe_user_id: accountId,
}),
}
);
if (!res.ok) {
const error = await res.text();
return { success: false, error: `Failed to save: ${error}` }; return { success: false, error: `Failed to save: ${error}` };
} }
return { success: true };
} }
/** /**
@@ -220,23 +197,16 @@ export async function disconnectStripeConnect(brandId: string): Promise<{
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // SECURITY DEFINER RPC disconnects the Stripe Connect account
await pool.query(
const res = await fetch( "SELECT disconnect_stripe_connect($1)",
`${supabaseUrl}/rest/v1/rpc/disconnect_stripe_connect`, [brandId]
{ );
method: "POST", return { success: true };
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } catch {
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) {
return { success: false, error: "Failed to disconnect Stripe account" }; return { success: false, error: "Failed to disconnect Stripe account" };
} }
return { success: true };
} }
/** /**
+168 -18
View File
@@ -1,7 +1,10 @@
"use server"; "use server";
import Stripe from "stripe"; import Stripe from "stripe";
import { svcHeaders } from "@/lib/svc-headers"; import { eq } from "drizzle-orm";
import { withTenant } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { pool } from "@/lib/db";
export type TaxCalculationResult = { export type TaxCalculationResult = {
taxAmount: number; taxAmount: number;
@@ -9,6 +12,13 @@ export type TaxCalculationResult = {
taxLocation: string; taxLocation: string;
}; };
/**
* Tax settings are stored on `brand_settings.feature_flags` (jsonb).
* The SaaS rebuild uses the same keys the legacy `get_brand_settings`
* RPC exposed: `collect_sales_tax` (boolean) and `nexus_states`
* (string[] of US state codes). Reading them out of the JSON column
* avoids a per-brand settings table for a few toggle fields.
*/
type BrandTaxSettings = { type BrandTaxSettings = {
collect_sales_tax: boolean | null; collect_sales_tax: boolean | null;
nexus_states: string[] | null; nexus_states: string[] | null;
@@ -101,23 +111,163 @@ export async function calculateOrderTax(params: {
} }
} }
/**
* Read tax-related feature flags for a brand. The two flags
* (`collect_sales_tax`, `nexus_states`) used to live in a dedicated
* `get_brand_settings` SECURITY DEFINER RPC; in the SaaS rebuild they
* live in `brand_settings.feature_flags` (jsonb). Tolerant of missing
* / malformed JSON by falling back to `null`.
*/
async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> { async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const rows = await withTenant(brandId, async (db) =>
db
.select({ featureFlags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.limit(1),
);
const flags = rows[0]?.featureFlags as
| Partial<{
collect_sales_tax: boolean;
nexus_states: string[];
}>
| null
| undefined;
if (!flags) return null;
return {
collect_sales_tax:
typeof flags.collect_sales_tax === "boolean"
? flags.collect_sales_tax
: null,
nexus_states: Array.isArray(flags.nexus_states)
? flags.nexus_states.filter((s): s is string => typeof s === "string")
: null,
};
} catch {
return null;
}
}
const response = await fetch( // ── Tax Dashboard read-side actions ───────────────────────────────────────────
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`, //
{ // TODO(migration): the tax dashboard reads from the legacy `orders`
method: "POST", // table via the `get_tax_summary` and `get_taxable_orders` SECURITY
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, // DEFINER RPCs (supabase/migrations/095). Both the table and the RPCs
body: JSON.stringify({ p_brand_id: brandId }), // still exist; we call the RPCs through `pool.query` rather than the
} // Supabase REST gateway. When the SaaS rebuild's orders table grows a
); // `tax_amount` column or the tax dashboard is rebuilt against the new
// schema, these helpers should be rewritten against the Drizzle
// `orders` table directly.
if (!response.ok) return null; export type TaxByStateRow = {
const data = await response.json(); state: string;
return { total_tax: number;
collect_sales_tax: data?.collect_sales_tax ?? null, gross_sales: number;
nexus_states: data?.nexus_states ?? null, order_count: number;
}; };
}
export type TaxSummaryData = {
total_tax_collected: number;
total_gross_sales: number;
order_count: number;
tax_by_state: TaxByStateRow[];
};
export type TaxOrderRow = {
order_id: string;
date: string;
customer_name: string;
city: string;
state: string;
taxable_amount: number;
tax_amount: number;
tax_rate: number;
tax_location: string;
};
export type GetTaxSummaryResult =
| { success: true; data: TaxSummaryData }
| { success: false; error: string };
export type GetTaxableOrdersResult =
| { success: true; data: TaxOrderRow[] }
| { success: false; error: string };
export async function getTaxSummaryAction(params: {
brandId: string;
startDate: string;
endDate: string;
}): Promise<GetTaxSummaryResult> {
if (!params.brandId) return { success: false, error: "Brand required" };
try {
const { rows } = await pool.query<{
total_tax_collected: number | string;
total_gross_sales: number | string;
order_count: number | string;
tax_by_state: TaxByStateRow[] | null;
}>(
"SELECT * FROM get_tax_summary($1::uuid, $2::date, $3::date)",
[params.brandId, params.startDate, params.endDate]
);
const row = rows[0];
if (!row) return { success: false, error: "No data" };
return {
success: true,
data: {
total_tax_collected: Number(row.total_tax_collected ?? 0),
total_gross_sales: Number(row.total_gross_sales ?? 0),
order_count: Number(row.order_count ?? 0),
tax_by_state: Array.isArray(row.tax_by_state) ? row.tax_by_state : [],
},
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to load tax summary",
};
}
}
export async function getTaxableOrdersAction(params: {
brandId: string;
startDate: string;
endDate: string;
}): Promise<GetTaxableOrdersResult> {
if (!params.brandId) return { success: false, error: "Brand required" };
try {
const { rows } = await pool.query<{
order_id: string;
date: string;
customer_name: string | null;
city: string | null;
state: string | null;
taxable_amount: number | string;
tax_amount: number | string;
tax_rate: number | string;
tax_location: string | null;
}>(
"SELECT * FROM get_taxable_orders($1::uuid, $2::date, $3::date)",
[params.brandId, params.startDate, params.endDate]
);
return {
success: true,
data: rows.map((r) => ({
order_id: String(r.order_id ?? ""),
date: String(r.date ?? ""),
customer_name: String(r.customer_name ?? ""),
city: String(r.city ?? ""),
state: String(r.state ?? ""),
taxable_amount: Number(r.taxable_amount ?? 0),
tax_amount: Number(r.tax_amount ?? 0),
tax_rate: Number(r.tax_rate ?? 0),
tax_location: String(r.tax_location ?? ""),
})),
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to load taxable orders",
};
}
}
+49 -172
View File
@@ -2,14 +2,18 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // TODO(migration): the time-tracking feature was built on Supabase RPCs
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // (verify_time_tracking_pin, clock_in_worker, clock_out_worker,
// get_open_clock_in, get_time_tracking_tasks,
function rpcBody(body: Record<string, unknown>) { // get_worker_pay_period_hours, get_time_tracking_settings,
return JSON.stringify(body); // get_water_user_by_id, get_water_admin_session, submit_water_entry,
} // trigger_water_alert) plus tables that don't exist in the SaaS rebuild
// schema (`time_workers`, `time_tasks`, `time_logs`, `water_*`). The
// ── Types ───────────────────────────────────────────────────────────────────── // functions below are stubs that preserve the original signature so
// the field UI degrades gracefully (no PIN login, no entry submit) —
// exactly the same pattern as `actions/route-trace/lots.ts`. To bring
// time tracking back, add the tables to `db/schema/` and re-implement
// these functions against Drizzle.
export type TimeTrackingSession = { export type TimeTrackingSession = {
worker_id: string; worker_id: string;
@@ -49,119 +53,41 @@ function parseSessionCookie(cookie: string): TimeTrackingSession | null {
// ── Verify PIN ───────────────────────────────────────────────────────────────── // ── Verify PIN ─────────────────────────────────────────────────────────────────
export async function verifyTimeTrackingPin( export async function verifyTimeTrackingPin(
brandId: string, _brandId: string,
pin: string _pin: string
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> { ): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> {
const res = await fetch( // RPC no longer exists; surface a friendly error so the field UI can
`${supabaseUrl}/rest/v1/rpc/verify_time_tracking_pin`, // disable the PIN pad.
{ return { success: false, error: "Time tracking is not configured" };
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
},
body: rpcBody({ p_brand_id: brandId, p_pin: pin }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) {
return { success: false, error: data?.error ?? "Invalid PIN" };
}
const session: TimeTrackingSession = {
worker_id: data.worker_id,
name: data.name,
role: data.role,
lang: data.lang,
session_id: data.session_id,
brand_id: data.brand_id,
expires_at: data.expires_at,
};
// Set HTTP-only cookie
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, sessionCookie(session), {
httpOnly: true,
sameSite: "lax",
maxAge: COOKIE_MAX_AGE,
path: "/",
});
return { success: true, session };
} }
// ── Clock In ─────────────────────────────────────────────────────────────────── // ── Clock In ───────────────────────────────────────────────────────────────────
export async function clockInWorker( export async function clockInWorker(
brandId: string, _brandId: string,
taskId?: string, _taskId?: string,
taskName = "General Labor" _taskName = "General Labor"
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> { ): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> {
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" }; if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/clock_in_worker`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
},
body: rpcBody({
p_brand_id: brandId,
p_worker_id: session.worker_id,
p_task_id: taskId ?? null,
p_task_name: taskName,
}),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true, log_id: data.log_id, clock_in: data.clock_in };
} }
// ── Clock Out ────────────────────────────────────────────────────────────────── // ── Clock Out ──────────────────────────────────────────────────────────────────
export async function clockOutWorker( export async function clockOutWorker(
lunchMinutes = 0, _lunchMinutes = 0,
notes?: string _notes?: string
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> { ): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> {
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" }; if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/clock_out_worker`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
},
body: rpcBody({
p_worker_id: session.worker_id,
p_lunch_minutes: lunchMinutes,
p_notes: notes ?? null,
}),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return {
success: true,
log_id: data.log_id,
clock_out: data.clock_out,
total_minutes: data.total_minutes,
};
} }
// ── Get Open Clock In ────────────────────────────────────────────────────────── // ── Get Open Clock In ──────────────────────────────────────────────────────────
export async function getOpenClockIn( export async function getOpenClockIn(
brandId: string _brandId: string
): Promise<{ ): Promise<{
success: boolean; success: boolean;
open?: boolean; open?: boolean;
@@ -173,29 +99,7 @@ export async function getOpenClockIn(
}> { }> {
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" }; if (!session) return { success: false, error: "Not logged in" };
return { success: true, open: false };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_open_clock_in`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
},
body: rpcBody({ p_worker_id: session.worker_id }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return {
success: true,
open: data.open,
log_id: data.log_id,
task_name: data.task_name,
clock_in: data.clock_in,
elapsed_minutes: data.elapsed_minutes,
};
} }
// ── Logout ───────────────────────────────────────────────────────────────────── // ── Logout ─────────────────────────────────────────────────────────────────────
@@ -226,24 +130,10 @@ export type TimeTaskField = {
}; };
export async function getTimeTrackingTasksField( export async function getTimeTrackingTasksField(
brandId: string, _brandId: string,
activeOnly = true _activeOnly = true
): Promise<TimeTaskField[]> { ): Promise<TimeTaskField[]> {
const res = await fetch( return [];
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_tasks`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseKey,
Authorization: `Bearer ${supabaseKey}`,
},
body: rpcBody({ p_brand_id: brandId, p_active_only: activeOnly }),
}
);
if (!res.ok) return [];
const data = await res.json();
return data?.tasks ?? [];
} }
// ── Pay Period Hours ─────────────────────────────────────────────────────────── // ── Pay Period Hours ───────────────────────────────────────────────────────────
@@ -264,39 +154,26 @@ export type PayPeriodHours = {
weekly_threshold: number; weekly_threshold: number;
}; };
const EMPTY_PAY_PERIOD: PayPeriodHours = {
success: false,
total_minutes: 0,
total_hours: 0,
daily_minutes: 0,
daily_hours: 0,
weekly_minutes: 0,
weekly_hours: 0,
daily_overtime: false,
weekly_overtime: false,
period_start: "",
period_end: "",
daily_threshold: 12,
weekly_threshold: 56,
};
export async function getWorkerPayPeriodHours( export async function getWorkerPayPeriodHours(
brandId: string _brandId: string
): Promise<PayPeriodHours> { ): Promise<PayPeriodHours> {
const session = await getTimeTrackingSession(); const session = await getTimeTrackingSession();
if (!session) return { success: false, total_minutes: 0, total_hours: 0, daily_minutes: 0, daily_hours: 0, weekly_minutes: 0, weekly_hours: 0, daily_overtime: false, weekly_overtime: false, period_start: "", period_end: "", daily_threshold: 12, weekly_threshold: 56 }; if (!session) return { ...EMPTY_PAY_PERIOD };
return { ...EMPTY_PAY_PERIOD };
const [hoursRes, settingsRes] = await Promise.all([ }
fetch(
`${supabaseUrl}/rest/v1/rpc/get_worker_pay_period_hours`,
{
method: "POST",
headers: { "Content-Type": "application/json", apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
body: rpcBody({ p_brand_id: brandId, p_worker_id: session.worker_id }),
}
),
fetch(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_settings`,
{
method: "POST",
headers: { "Content-Type": "application/json", apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
body: rpcBody({ p_brand_id: brandId }),
}
),
]);
const data = await hoursRes.json();
const settings = settingsRes.ok ? (await settingsRes.json()) : null;
if (!hoursRes.ok) return { success: false, total_minutes: 0, total_hours: 0, daily_minutes: 0, daily_hours: 0, weekly_minutes: 0, weekly_hours: 0, daily_overtime: false, weekly_overtime: false, period_start: "", period_end: "", daily_threshold: 12, weekly_threshold: 56 };
return {
...data,
daily_threshold: settings ? Number(settings.daily_overtime_threshold) : 12,
weekly_threshold: settings ? Number(settings.weekly_overtime_threshold) : 56,
};
}
+75 -280
View File
@@ -1,19 +1,27 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data"; import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // TODO(migration): the time-tracking admin RPCs (get_time_tracking_workers,
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // create_time_worker, reset_time_worker_pin, update_time_worker,
// delete_time_worker, create_time_task, update_time_task,
// delete_time_task, get_worker_time_logs, update_worker_time_log,
// delete_worker_time_log, get_time_tracking_summary,
// get_time_tracking_settings, update_time_tracking_settings,
// get_time_tracking_notification_log, check_and_notify_overtime) and
// the underlying tables (`time_workers`, `time_tasks`, `time_logs`,
// `time_tracking_settings`, `time_tracking_notification_log`) were
// not carried over into the SaaS rebuild's `db/schema/`. The actions
// below preserve the original signatures and return mock data when
// `NEXT_PUBLIC_USE_MOCK_DATA === "true"`, but the real RPC paths now
// return empty/empty-list results. To bring time tracking back, add
// the tables to `db/schema/` and re-implement against Drizzle. See
// `actions/route-trace/lots.ts` for the same pattern.
// Mock mode flag - only enabled when explicitly set // Mock mode flag - only enabled when explicitly set
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
function rpcBody(body: Record<string, unknown>) {
return JSON.stringify(body);
}
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
export type TimeWorker = { export type TimeWorker = {
@@ -76,107 +84,43 @@ export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorke
worker_number: null, worker_number: null,
})); }));
} }
const res = await fetch( // Time tracking tables not in SaaS rebuild — return empty list.
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_workers`, return [];
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId }),
}
);
if (!res.ok) return [];
const data = await res.json();
return (data?.workers ?? []).map((w: Record<string, unknown>) => ({
id: w.id as string,
brand_id: w.brand_id as string,
name: w.name as string,
role: w.role as string,
lang: w.lang as string,
pin: w.pin as string,
active: w.active as boolean,
last_used_at: w.last_used_at as string | null,
created_at: w.created_at as string,
worker_number: w.worker_number as number | null,
}));
} }
export async function createTimeWorker( export async function createTimeWorker(
brandId: string, _brandId: string,
name: string, _name: string,
role = "worker", _role = "worker",
lang = "en" _lang = "en"
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> { ): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_time_worker`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId, p_name: name, p_role: role, p_lang: lang }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true, worker: data.worker, pin: data.pin };
} }
export async function resetTimeWorkerPin(workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> { export async function resetTimeWorkerPin(_workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/reset_time_worker_pin`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_worker_id: workerId }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true, pin: data.pin };
} }
export async function updateTimeWorker( export async function updateTimeWorker(
workerId: string, _workerId: string,
name: string, _name: string,
role: string, _role: string,
lang: string, _lang: string,
active: boolean _active: boolean
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_time_worker`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_worker_id: workerId, p_name: name, p_role: role, p_lang: lang, p_active: active }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
export async function deleteTimeWorker(workerId: string): Promise<{ success: boolean; error?: string }> { export async function deleteTimeWorker(_workerId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_time_worker`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_worker_id: workerId }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
// ── Tasks ──────────────────────────────────────────────────────────────────── // ── Tasks ────────────────────────────────────────────────────────────────────
@@ -192,88 +136,45 @@ export async function getTimeTrackingTasks(brandId: string, activeOnly = false):
sort_order: t.sort_order, sort_order: t.sort_order,
})); }));
} }
const res = await fetch( return [];
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_tasks`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId, p_active_only: activeOnly }),
}
);
if (!res.ok) return [];
const data = await res.json();
return data?.tasks ?? [];
} }
export async function createTimeTask( export async function createTimeTask(
brandId: string, _brandId: string,
name: string, _name: string,
nameEs: string | null = null, _nameEs: string | null = null,
unit = "hours", _unit = "hours",
sortOrder = 0 _sortOrder = 0
): Promise<{ success: boolean; id?: string; error?: string }> { ): Promise<{ success: boolean; id?: string; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_time_task`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId, p_name: name, p_name_es: nameEs, p_unit: unit, p_sort_order: sortOrder }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true, id: data.id };
} }
export async function updateTimeTask( export async function updateTimeTask(
taskId: string, _taskId: string,
name: string, _name: string,
nameEs: string, _nameEs: string,
unit: string, _unit: string,
active: boolean, _active: boolean,
sortOrder: number _sortOrder: number
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_time_task`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_task_id: taskId, p_name: name, p_name_es: nameEs, p_unit: unit, p_active: active, p_sort_order: sortOrder }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
export async function deleteTimeTask(taskId: string): Promise<{ success: boolean; error?: string }> { export async function deleteTimeTask(_taskId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_time_task`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_task_id: taskId }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
// ── Time Logs ───────────────────────────────────────────────────────────────── // ── Time Logs ─────────────────────────────────────────────────────────────────
export async function getWorkerTimeLogs( export async function getWorkerTimeLogs(
brandId: string, brandId: string,
options: { _options: {
workerId?: string; workerId?: string;
taskId?: string; taskId?: string;
start?: string; start?: string;
@@ -285,8 +186,8 @@ export async function getWorkerTimeLogs(
if (useMockData) { if (useMockData) {
// Filter by worker, task, date range // Filter by worker, task, date range
let entries = mockTimeEntries.filter(e => e.brand_id === brandId); let entries = mockTimeEntries.filter(e => e.brand_id === brandId);
if (options.workerId) entries = entries.filter(e => e.worker_id === options.workerId); if (_options.workerId) entries = entries.filter(e => e.worker_id === _options.workerId);
return entries.map(e => { return entries.map(e => {
const worker = mockWorkers.find(w => w.id === e.worker_id); const worker = mockWorkers.find(w => w.id === e.worker_id);
const task = mockTasks.find(t => t.id === e.task_id); const task = mockTasks.find(t => t.id === e.task_id);
@@ -306,83 +207,36 @@ export async function getWorkerTimeLogs(
}; };
}); });
} }
const res = await fetch( return [];
`${supabaseUrl}/rest/v1/rpc/get_worker_time_logs`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({
p_brand_id: brandId,
p_worker_id: options.workerId ?? null,
p_task_id: options.taskId ?? null,
p_start: options.start ?? null,
p_end: options.end ?? null,
p_limit: options.limit ?? 200,
p_offset: options.offset ?? 0,
}),
}
);
if (!res.ok) return [];
const data = await res.json();
return data?.logs ?? [];
} }
export async function updateWorkerTimeLog( export async function updateWorkerTimeLog(
logId: string, _logId: string,
taskName: string, _taskName: string,
clockIn: string, _clockIn: string,
clockOut: string | null, _clockOut: string | null,
lunchMinutes: number, _lunchMinutes: number,
notes: string | null _notes: string | null
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_worker_time_log`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({
p_log_id: logId,
p_task_name: taskName,
p_clock_in: clockIn,
p_clock_out: clockOut,
p_lunch_minutes: lunchMinutes,
p_notes: notes,
}),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
export async function deleteWorkerTimeLog(logId: string): Promise<{ success: boolean; error?: string }> { export async function deleteWorkerTimeLog(_logId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_worker_time_log`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_log_id: logId }),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
export async function getTimeTrackingSummary( export async function getTimeTrackingSummary(
brandId: string, brandId: string,
start: string, _start: string,
end: string _end: string
): Promise<TimeSummary> { ): Promise<TimeSummary> {
if (useMockData) { if (useMockData) {
const entries = mockTimeEntries.filter(e => e.brand_id === brandId); const entries = mockTimeEntries.filter(e => e.brand_id === brandId);
// Calculate by worker // Calculate by worker
const workerMap = new Map<string, { id: string; name: string; total_hours: number; entry_count: number }>(); const workerMap = new Map<string, { id: string; name: string; total_hours: number; entry_count: number }>();
entries.forEach(e => { entries.forEach(e => {
@@ -392,7 +246,7 @@ export async function getTimeTrackingSummary(
existing.entry_count += 1; existing.entry_count += 1;
workerMap.set(e.worker_id, existing); workerMap.set(e.worker_id, existing);
}); });
// Calculate by task // Calculate by task
const taskMap = new Map<string, { id: string; name: string; name_es: string | null; total_hours: number; entry_count: number }>(); const taskMap = new Map<string, { id: string; name: string; name_es: string | null; total_hours: number; entry_count: number }>();
entries.forEach(e => { entries.forEach(e => {
@@ -402,7 +256,7 @@ export async function getTimeTrackingSummary(
existing.entry_count += 1; existing.entry_count += 1;
taskMap.set(e.task_id, existing); taskMap.set(e.task_id, existing);
}); });
return { return {
by_worker: Array.from(workerMap.values()), by_worker: Array.from(workerMap.values()),
by_task: Array.from(taskMap.values()), by_task: Array.from(taskMap.values()),
@@ -413,16 +267,7 @@ export async function getTimeTrackingSummary(
}, },
}; };
} }
const res = await fetch( return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_summary`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId, p_start: start, p_end: end }),
}
);
if (!res.ok) return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
return res.json();
} }
// ── Time Tracking Settings ───────────────────────────────────────────────────── // ── Time Tracking Settings ─────────────────────────────────────────────────────
@@ -467,22 +312,13 @@ export async function getTimeTrackingSettings(brandId: string): Promise<TimeTrac
brand_name: "Tuxedo Corn", brand_name: "Tuxedo Corn",
}; };
} }
const res = await fetch( // Real RPC not in SaaS rebuild.
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_settings`, return null;
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId }),
}
);
if (!res.ok) return null;
const data = await res.json();
return data;
} }
export async function updateTimeTrackingSettings( export async function updateTimeTrackingSettings(
brandId: string, _brandId: string,
settings: { _settings: {
pay_period_start_day: number; pay_period_start_day: number;
pay_period_length_days: number; pay_period_length_days: number;
daily_overtime_threshold: number; daily_overtime_threshold: number;
@@ -501,34 +337,7 @@ export async function updateTimeTrackingSettings(
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_time_tracking_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({
p_brand_id: brandId,
p_pay_period_start_day: settings.pay_period_start_day,
p_pay_period_length_days: settings.pay_period_length_days,
p_daily_threshold: settings.daily_overtime_threshold,
p_weekly_threshold: settings.weekly_overtime_threshold,
p_overtime_multiplier: settings.overtime_multiplier,
p_overtime_notifications: settings.overtime_notifications,
p_notification_emails: settings.notification_emails ?? null,
p_notification_sms_numbers: settings.notification_sms_numbers ?? null,
p_enable_daily_alerts: settings.enable_daily_alerts ?? null,
p_enable_weekly_alerts: settings.enable_weekly_alerts ?? null,
p_daily_alert_threshold: settings.daily_alert_threshold ?? null,
p_weekly_alert_threshold: settings.weekly_alert_threshold ?? null,
p_send_end_of_period_summary: settings.send_end_of_period_summary ?? null,
p_brand_name: settings.brand_name ?? null,
}),
}
);
const data = await res.json();
if (!res.ok || !data?.success) return { success: false, error: data?.error ?? "Failed" };
return { success: true };
} }
// ── Notification Log ───────────────────────────────────────────────────────── // ── Notification Log ─────────────────────────────────────────────────────────
@@ -549,22 +358,8 @@ export type NotificationLogEntry = {
}; };
export async function getTimeTrackingNotificationLog( export async function getTimeTrackingNotificationLog(
brandId: string, _brandId: string,
limit = 100 _limit = 100
): Promise<NotificationLogEntry[]> { ): Promise<NotificationLogEntry[]> {
if (useMockData) { return [];
// Return empty log in mock mode
return [];
}
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_time_tracking_notification_log`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: rpcBody({ p_brand_id: brandId, p_limit: limit }),
}
);
if (!res.ok) return [];
const data = await res.json();
return data ?? [];
} }
+17 -31
View File
@@ -1,10 +1,13 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // TODO(migration): the `check_and_notify_overtime` SECURITY DEFINER
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // RPC and the time-tracking notification tables are not part of the
// SaaS rebuild schema. The function below is a stub that returns
// `{ sent: false }` so the cron-style caller (`/api/time-tracking/notify`)
// degrades gracefully. See `actions/route-trace/lots.ts` for the
// same pattern.
export type OvertimeCheckResult = { export type OvertimeCheckResult = {
sent: boolean; sent: boolean;
@@ -14,35 +17,18 @@ export type OvertimeCheckResult = {
}; };
export async function checkAndNotifyOvertime( export async function checkAndNotifyOvertime(
brandId: string, _brandId: string,
workerId: string, _workerId: string,
workerName: string, _workerName: string,
dailyHours: number, _dailyHours: number,
weeklyHours: number _weeklyHours: number
): Promise<OvertimeCheckResult> { ): Promise<OvertimeCheckResult> {
const res = await fetch( const adminUser = await getAdminUser();
`${supabaseUrl}/rest/v1/rpc/check_and_notify_overtime`, if (!adminUser) {
{ return { sent: false, message: "Not authenticated" };
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_worker_id: workerId,
p_worker_name: workerName,
p_daily_hours: dailyHours,
p_weekly_hours: weeklyHours,
}),
}
);
if (!res.ok) {
const err = await res.text();
return { sent: false, message: err };
} }
const data = await res.json();
return { return {
sent: Boolean(data?.sent), sent: false,
trigger_type: data?.trigger_type, message: "Time tracking is not configured in the SaaS rebuild",
message: data?.message,
notification_log_id: data?.notification_log_id,
}; };
} }
+82 -367
View File
@@ -1,8 +1,22 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; // TODO(migration): the water-log feature was built on Supabase RPCs
// (`create_water_headgate`, `update_water_headgate`, `delete_water_headgate`,
// `get_water_users`, `create_water_user`, `update_water_user`,
// `delete_water_user`, `reset_water_user_pin`, `get_water_entries`,
// `get_water_headgates_admin`, `regenerate_headgate_token`,
// `update_water_entry`, `delete_water_entry`, `get_water_entry_by_id`,
// `get_water_display_summary`, `get_water_alert_log`) and tables that
// are not part of the SaaS rebuild's `db/schema/` (`water_headgates`,
// `water_users`, `water_entries`, `water_sessions`,
// `water_admin_sessions`, `water_admin_settings`, `water_alert_log`).
// All actions below preserve their original signature and return
// empty / no-op responses so the admin UI degrades gracefully. To
// re-enable water log, add the tables to `db/schema/` and
// re-implement these against Drizzle. Same pattern as
// `actions/route-trace/lots.ts`.
type Irrigator = { type Irrigator = {
id: string; id: string;
@@ -42,414 +56,143 @@ type WaterEntry = {
headgate_unit?: string; headgate_unit?: string;
}; };
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
// ── Headgate Admin ────────────────────────────────────────── // ── Headgate Admin ──────────────────────────────────────────
export async function createWaterHeadgate(brandId: string, name: string, unit: string = "CFS"): Promise<{ success: boolean; headgate?: Headgate; error?: string }> { export async function createWaterHeadgate(
const adminUser = await (await import("@/lib/admin-permissions")).getAdminUser(); _brandId: string,
_name: string,
_unit: string = "CFS"
): Promise<{ success: boolean; headgate?: Headgate; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log && adminUser.role !== "platform_admin") { if (!adminUser.can_manage_water_log && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
return { success: false, error: NOT_CONFIGURED };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // prefer service for admin muts
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_water_headgate`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_name: name, p_unit: unit }),
}
);
const data = await response.json();
if (!response.ok || !data?.headgate) {
return { success: false, error: data?.message ?? "Failed to create headgate" };
}
return { success: true, headgate: data.headgate };
} }
export async function updateWaterHeadgate( export async function updateWaterHeadgate(
headgateId: string, _headgateId: string,
name: string, _name: string,
active: boolean, _active: boolean,
unit?: string, _unit?: string,
highThreshold?: number | null, _highThreshold?: number | null,
lowThreshold?: number | null _lowThreshold?: number | null
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
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/update_water_headgate`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_headgate_id: headgateId,
p_name: name,
p_active: active,
p_unit: unit ?? null,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
p_high_threshold: highThreshold ?? null,
p_low_threshold: lowThreshold ?? null,
}),
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Failed to update headgate" };
}
return { success: true };
} }
// ── Irrigator Admin ───────────────────────────────────────── // ── Irrigator Admin ─────────────────────────────────────────
export async function getWaterIrrigators(brandId: string): Promise<Irrigator[]> { export async function getWaterIrrigators(_brandId: string): Promise<Irrigator[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return [];
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_users`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
const data = await response.json();
return data?.users ?? [];
} }
export async function createWaterIrrigator( export async function createWaterIrrigator(
brandId: string, _brandId: string,
name: string, _name: string,
lang: string = "en" _lang: string = "en"
): Promise<{ success: boolean; irrigator?: Irrigator; pin?: string; error?: string }> { ): Promise<{ success: boolean; irrigator?: Irrigator; pin?: string; error?: string }> {
return createWaterUser(brandId, name, "irrigator", lang) as Promise<{ success: boolean; irrigator?: Irrigator; pin?: string; error?: string }>; return createWaterUser(_brandId, _name, "irrigator", _lang) as Promise<{
success: boolean;
irrigator?: Irrigator;
pin?: string;
error?: string;
}>;
} }
export async function createWaterUser( export async function createWaterUser(
brandId: string, _brandId: string,
name: string, _name: string,
role: "irrigator" | "water_admin", _role: "irrigator" | "water_admin",
lang: string = "en" _lang: string = "en"
): Promise<{ success: boolean; user?: Irrigator; pin?: string; error?: string }> { ): Promise<{ success: boolean; user?: Irrigator; pin?: string; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return { success: false, error: NOT_CONFIGURED };
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_water_user`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_name: name, p_role: role, p_lang: lang }),
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Failed to create user" };
}
return { success: true, user: data.user, pin: data.pin };
} }
export async function updateWaterIrrigator( export async function updateWaterIrrigator(
irrigatorId: string, _irrigatorId: string,
name: string, _name: string,
active: boolean, _active: boolean,
lang: string, _lang: string,
role: "irrigator" | "water_admin" _role: "irrigator" | "water_admin"
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
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/update_water_user`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_user_id: irrigatorId,
p_name: name,
p_active: active,
p_lang: lang,
p_role: role,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Failed to update user" };
}
return { success: true };
} }
export async function resetWaterIrrigatorPin( export async function resetWaterIrrigatorPin(
irrigatorId: string _irrigatorId: string
): Promise<{ success: boolean; pin?: string; error?: string }> { ): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
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/reset_water_user_pin`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_user_id: irrigatorId,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Failed to reset PIN" };
}
return { success: true, pin: data.pin };
} }
export async function deleteWaterUser(userId: string): Promise<{ success: boolean; error?: string }> { export async function deleteWaterUser(_userId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
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/delete_water_user`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_user_id: userId,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Failed to delete user" };
}
return { success: true };
} }
export async function deleteWaterHeadgate(headgateId: string): Promise<{ success: boolean; error?: string }> { export async function deleteWaterHeadgate(_headgateId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
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/delete_water_headgate`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_headgate_id: headgateId,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
// The RPC returns JSONB on success: { success: true } or { success: false, error: "..." }.
// On failure (HTTP non-2xx) Supabase returns { message: "...", code: "...", details: ... }.
// We try to extract the most useful message in both cases.
let data: { success?: boolean; error?: string; message?: string } | null = null;
try {
data = await response.json();
} catch {
// Non-JSON body — leave data as null, fall through to default error
}
if (response.ok && data?.success) {
return { success: true };
}
// Prefer the RPC's own error if it set one
const errorMessage =
data?.error ??
data?.message ??
(response.ok ? "Unknown error" : `HTTP ${response.status}: ${response.statusText || "request failed"}`);
if (process.env.NODE_ENV !== "production") {
console.error("[deleteWaterHeadgate] failed", {
headgateId,
status: response.status,
data,
});
}
return { success: false, error: errorMessage };
} }
// ── Entries ──────────────────────────────────────────────── // ── Entries ────────────────────────────────────────────────
export async function getWaterEntries(brandId: string, limit = 50): Promise<WaterEntry[]> { export async function getWaterEntries(_brandId: string, _limit = 50): Promise<WaterEntry[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return [];
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_entries`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_limit: limit }),
}
);
if (!response.ok) return [];
const data = await response.json();
return data?.entries ?? [];
} }
export async function getWaterHeadgatesAdmin(brandId: string): Promise<Headgate[]> { export async function getWaterHeadgatesAdmin(_brandId: string): Promise<Headgate[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return [];
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_headgates_admin`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
const data = await response.json();
return data?.headgates ?? [];
} }
export async function regenerateHeadgateToken(headgateId: string): Promise<{ success: boolean; token?: string; error?: string }> { export async function regenerateHeadgateToken(
_headgateId: string
): Promise<{ success: boolean; token?: string; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
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/regenerate_headgate_token`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: (await getActiveBrandId(adminUser)) ?? null }),
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Failed to regenerate token" };
}
return { success: true, token: data.token };
} }
// ── Entry edit/delete ───────────────────────────────────── // ── Entry edit/delete ─────────────────────────────────────
export async function updateWaterEntry( export async function updateWaterEntry(
entryId: string, _entryId: string,
measurement: number, _measurement: number,
notes: string | null, _notes: string | null,
unit?: string _unit?: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
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/update_water_entry`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_entry_id: entryId,
p_measurement: measurement,
p_notes: notes,
p_unit: unit ?? null,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Failed to update entry" };
}
return { success: true };
} }
export async function deleteWaterEntry( export async function deleteWaterEntry(_entryId: string): Promise<{ success: boolean; error?: string }> {
entryId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
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/delete_water_entry`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_entry_id: entryId,
p_brand_id: (await getActiveBrandId(adminUser)) ?? null,
}),
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Failed to delete entry" };
}
return { success: true };
} }
export async function getWaterEntryById(entryId: string): Promise<WaterEntry | null> { export async function getWaterEntryById(_entryId: string): Promise<WaterEntry | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return null;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_entry_by_id`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_entry_id: entryId }),
}
);
if (!response.ok) return null;
const data = await response.json();
return data?.entry ?? null;
} }
// ── Display summary ─────────────────────────────────── // ── Display summary ───────────────────────────────────
@@ -481,22 +224,8 @@ export type WaterDisplaySummary = {
recent_entries: WaterDisplayEntry[]; recent_entries: WaterDisplayEntry[];
}; };
export async function getWaterDisplaySummary(brandId: string): Promise<WaterDisplaySummary | null> { export async function getWaterDisplaySummary(_brandId: string): Promise<WaterDisplaySummary | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return null;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_display_summary`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return null;
const data = await response.json();
return data as WaterDisplaySummary;
} }
export type AlertLogEntry = { export type AlertLogEntry = {
@@ -511,20 +240,6 @@ export type AlertLogEntry = {
formatted_time: string; formatted_time: string;
}; };
export async function getWaterAlertLog(brandId: string, limit = 50): Promise<AlertLogEntry[]> { export async function getWaterAlertLog(_brandId: string, _limit = 50): Promise<AlertLogEntry[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return [];
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; }
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_alert_log`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_limit: limit }),
}
);
if (!response.ok) return [];
const data = await response.json();
return data?.alerts ?? [];
}
+38 -177
View File
@@ -1,7 +1,17 @@
"use server"; "use server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { svcHeaders } from "@/lib/svc-headers";
// TODO(migration): the water-log field UI used a chain of Supabase RPCs
// (`get_water_headgates`, `verify_water_pin`, `get_water_user_by_id`,
// `submit_water_entry`, `trigger_water_alert`,
// `get_water_admin_session`) and tables (`water_headgates`,
// `water_users`, `water_sessions`, `water_admin_sessions`,
// `water_entries`, `water_alert_log`) that are not in the SaaS
// rebuild's `db/schema/`. The actions below preserve the original
// signatures and return empty / no-op responses so the field UI
// degrades gracefully. See `actions/route-trace/lots.ts` for the
// same pattern.
type VerifyPinResult = { type VerifyPinResult = {
success: true; success: true;
@@ -30,102 +40,31 @@ type Headgate = {
created_at: string; created_at: string;
}; };
type HeadgatesResult = { const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
headgates: Headgate[];
};
export async function getWaterHeadgates(brandId: string, activeOnly = false): Promise<Headgate[]> { export async function getWaterHeadgates(
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; _brandId: string,
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; _activeOnly = false
): Promise<Headgate[]> {
const response = await fetch( return [];
`${supabaseUrl}/rest/v1/rpc/get_water_headgates`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_active_only: activeOnly }),
}
);
if (!response.ok) return [];
const data = await response.json();
return data?.headgates ?? [];
} }
export async function verifyWaterPin(brandId: string, pin: string): Promise<VerifyPinResult> { export async function verifyWaterPin(
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; _brandId: string,
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; _pin: string
): Promise<VerifyPinResult> {
const response = await fetch( return { success: false, error: NOT_CONFIGURED };
`${supabaseUrl}/rest/v1/rpc/verify_water_pin`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_pin: pin }),
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Invalid PIN" };
}
// Get user's language preference via SECURITY DEFINER RPC (avoids direct table access)
const userResponse = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_user_by_id`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_user_id: data.user_id }),
}
);
const userData = await userResponse.json();
const lang = userData?.language_preference ?? "en";
// Use the session already created by verify_water_pin RPC
const sessionId = data.session_id;
if (!sessionId) {
return { success: false, error: "Failed to create session" };
}
// Set HTTP-only session cookie
const cookieStore = await cookies();
cookieStore.set("wl_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 4 * 60 * 60, // 4 hours
path: "/",
});
cookieStore.set("wl_lang", lang, {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
return {
success: true,
user_id: data.user_id,
name: data.name,
role: data.role,
session_id: sessionId,
lang,
};
} }
export async function submitWaterEntry( export async function submitWaterEntry(
headgateId: string, _headgateId: string,
measurement: number, _measurement: number,
unit: string, _unit: string,
notes: string, _notes: string,
photoUrl?: string, _photoUrl?: string,
latitude?: number, _latitude?: number,
longitude?: number, _longitude?: number,
headgateLocked?: boolean _headgateLocked?: boolean
): Promise<SubmitEntryResult> { ): Promise<SubmitEntryResult> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value; const sessionId = cookieStore.get("wl_session")?.value;
@@ -134,74 +73,7 @@ export async function submitWaterEntry(
return { success: false, error: "Not logged in" }; return { success: false, error: "Not logged in" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return { success: false, error: NOT_CONFIGURED };
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/submit_water_entry`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_session_id: sessionId,
p_headgate_id: headgateId,
p_measurement: measurement,
p_unit: unit,
p_notes: notes,
p_submitted_via: "field",
p_photo_url: photoUrl ?? null,
p_latitude: latitude ?? null,
p_longitude: longitude ?? null,
p_headgate_locked: headgateLocked ?? false,
}),
}
);
const data = await response.json();
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to submit entry" };
}
const entryId = data.entry_id as string;
// ── Alert check (fire-and-forget, non-blocking) ─────────────────
try {
const alertRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/trigger_water_alert`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_entry_id: entryId,
p_alert_type: "high",
p_threshold_value: measurement,
p_reading_value: measurement,
}),
}
);
const highData = await alertRes.json();
// If not triggered as high, try low
if (!highData?.triggered) {
await fetch(
`${supabaseUrl}/rest/v1/rpc/trigger_water_alert`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_entry_id: entryId,
p_alert_type: "low",
p_threshold_value: measurement,
p_reading_value: measurement,
}),
}
);
}
} catch {
// Alert failures should not affect entry submission success
}
return { success: true, entry_id: entryId };
} }
export async function logoutWater(): Promise<void> { export async function logoutWater(): Promise<void> {
@@ -230,26 +102,15 @@ export async function setWaterLang(lang: string): Promise<void> {
}); });
} }
export async function getWaterAdminSession(): Promise<{ user_id: string; name: string; role: string } | null> { export async function getWaterAdminSession(): Promise<{
user_id: string;
name: string;
role: string;
} | null> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value; const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null; if (!sessionId) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return null;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; }
// Use get_water_admin_session RPC (SECURITY DEFINER) to avoid direct water_sessions + water_users JOIN
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_admin_session`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_session_id: sessionId }),
}
);
if (!response.ok) return null;
const data = await response.json();
return data;
};
+18 -96
View File
@@ -1,7 +1,14 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
// TODO(migration): the water-log settings RPCs (`get_water_admin_settings`,
// `hash_water_admin_pin`, `save_water_admin_settings`,
// `verify_water_admin_pin`) and the underlying
// `water_admin_settings` table are not in the SaaS rebuild schema.
// The functions below preserve the original signatures and return
// empty / no-op responses. Same pattern as
// `actions/route-trace/lots.ts`.
export type WaterAdminSettings = { export type WaterAdminSettings = {
enabled: boolean; enabled: boolean;
@@ -13,110 +20,25 @@ export type WaterAdminSettings = {
alerts_enabled?: boolean; alerts_enabled?: boolean;
}; };
export async function getWaterAdminSettings(brandId: string): Promise<WaterAdminSettings | null> { const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch( export async function getWaterAdminSettings(_brandId: string): Promise<WaterAdminSettings | null> {
`${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`, return null;
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return null;
const data = await response.json();
return data as WaterAdminSettings;
} }
export async function saveWaterAdminSettings( export async function saveWaterAdminSettings(
brandId: string, _brandId: string,
settings: Partial<WaterAdminSettings & { pin?: string }> _settings: Partial<WaterAdminSettings & { pin?: string }>
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
let pinHash: string | null = null;
if (settings.pin) {
// Hash the PIN server-side using PostgreSQL
const hashRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/hash_water_admin_pin`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_pin: settings.pin }),
}
);
const hashData = await hashRes.json();
if (!hashData?.hash) return { success: false, error: "Failed to hash PIN" };
pinHash = hashData.hash;
}
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/save_water_admin_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_enabled: settings.enabled ?? null,
p_pin_hash: pinHash,
p_session_duration_hours: settings.session_duration_hours ?? null,
p_can_edit_entries: settings.can_edit_entries ?? null,
p_can_delete_entries: settings.can_delete_entries ?? null,
p_can_export_csv: settings.can_export_csv ?? null,
p_alert_phone: settings.alert_phone ?? null,
p_alerts_enabled: settings.alerts_enabled ?? null,
}),
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Failed to save settings" };
}
return { success: true };
} }
export async function verifyWaterAdminPin( export async function verifyWaterAdminPin(
brandId: string, _brandId: string,
pin: string _pin: string
): Promise<{ success: boolean; session_id?: string; error?: string }> { ): Promise<{ success: boolean; session_id?: string; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return { success: false, error: NOT_CONFIGURED };
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; }
const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
const settings = await settingsRes.json();
if (!settingsRes.ok || !settings?.enabled) {
return { success: false, error: "Admin portal not enabled" };
}
// Verify PIN against stored hash
const verifyRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/verify_water_admin_pin`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_pin: pin }),
}
);
const verifyData = await verifyRes.json();
if (!verifyRes.ok || !verifyData?.success) {
return { success: false, error: "Invalid PIN" };
}
return { success: true, session_id: verifyData.session_id };
}
+30 -119
View File
@@ -1,132 +1,43 @@
"use server"; "use server";
/**
* Wholesale customer authentication.
*
* TODO(migration): The original implementation used Supabase's
* `auth.signInWithPassword` for email/password login of wholesale
* customers. Now that Supabase is being removed, the wholesale
* customer auth needs to be rebuilt on top of Auth.js v5 (or a
* custom bcrypt + cookie session) before this module can be re-enabled.
*
* Until that lands, the actions below are stubbed: they preserve the
* original signatures so the login form renders without runtime
* errors, but always return a friendly "not available" error.
*
* Re-enable by:
* 1. Adding a `wholesale_customers` table (or extending
* `db/schema/customers.ts` with `password_hash` + `auth_user_id`).
* 2. Wiring up the chosen auth provider in `src/lib/auth.ts`.
* 3. Setting the `wholesale_session` cookie with the resolved
* customer id.
*/
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
export type WholesaleLoginResult = export type WholesaleLoginResult =
| { success: true; token: string; userId: string; customerId: string } | { success: true; token: string; userId: string; customerId: string }
| { success: false; error: string }; | { success: false; error: string };
export async function wholesaleLoginAction(formData: FormData): Promise<WholesaleLoginResult> { const NOT_AVAILABLE_ERROR =
const email = formData.get("email") as string; "Wholesale customer login is temporarily unavailable while we rebuild authentication. Please contact your account manager.";
const password = formData.get("password") as string;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; export async function wholesaleLoginAction(_formData: FormData): Promise<WholesaleLoginResult> {
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; return { success: false, error: NOT_AVAILABLE_ERROR };
const cookieStore = await cookies();
const request = new NextRequest("http://localhost/wholesale/login", {
headers: new Headers(),
});
const response = NextResponse.next({ request });
const { createServerClient } = await import("@supabase/ssr");
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
},
},
});
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error || !data.user) {
return { success: false, error: error?.message ?? "Invalid credentials" };
}
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData?.session?.access_token;
if (!token) {
return { success: false, error: "No session returned from auth" };
}
// Find the wholesale customer record for this user
const customerRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
{
method: "POST",
headers: {
...svcHeaders(supabaseAnonKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: "placeholder", // will use any-brand lookup below
p_user_id: data.user.id,
}),
}
);
// If no brand_id known, try all brands — just use first active one found
// For now, set the cookie with user_id and a placeholder; portal will resolve proper customer
response.cookies.set("wholesale_session", JSON.stringify({
user_id: data.user.id,
access_token: token,
}), {
path: "/",
maxAge: 3600 * 24 * 7,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: false,
});
// Also set the standard auth token for RPC calls
response.cookies.set("sb-wnzkhezyhnfzhkhiflrp-auth-token", token, {
path: "/",
maxAge: 3600 * 24 * 7,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: false,
});
return {
success: true,
token,
userId: data.user.id,
customerId: "pending", // resolved by portal page on load
};
} }
export async function wholesaleLogoutAction() { export async function wholesaleLogoutAction(): Promise<{ success: true } | { success: false; error: string }> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const request = new NextRequest("http://localhost/wholesale/portal", { // Best-effort cookie cleanup so a returning customer doesn't see stale state
headers: new Headers(), cookieStore.delete("wholesale_session");
}); cookieStore.delete("sb-wnzkhezyhnfzhkhiflrp-auth-token");
const response = NextResponse.next({ request });
response.cookies.delete("wholesale_session");
const { createServerClient } = await import("@supabase/ssr");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
});
await supabase.auth.signOut();
return { success: true }; return { success: true };
} }
+177 -246
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export async function registerWholesaleCustomer(params: { export async function registerWholesaleCustomer(params: {
brandId: string; brandId: string;
@@ -11,43 +11,33 @@ export async function registerWholesaleCustomer(params: {
email: string; email: string;
phone?: string; phone?: string;
}): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> { }): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<{
success: boolean;
const response = await fetch( error?: string;
`${supabaseUrl}/rest/v1/rpc/register_wholesale_customer`, requires_approval?: boolean;
{ }>(
method: "POST", `SELECT * FROM register_wholesale_customer($1, $2, $3, $4, $5)`,
headers: { [
...svcHeaders(supabaseKey), params.brandId,
"Content-Type": "application/json", params.companyName,
}, params.contactName ?? null,
body: JSON.stringify({ params.email,
p_brand_id: params.brandId, params.phone ?? null,
p_company_name: params.companyName, ]
p_contact_name: params.contactName ?? null, );
p_email: params.email, const result = Array.isArray(rows) ? rows[0] : rows;
p_phone: params.phone ?? null, if (!result?.success) {
}), return { success: false, error: result?.error ?? "Registration failed." };
} }
); return {
success: true,
if (!response.ok) { requiresApproval: result.requires_approval ?? false,
const err = await response.json(); };
// Supabase error format: { "message": "...", "code": "...", ... } } catch (e: unknown) {
// Our RPC error format: { "success": false, "error": "..." } const err = e instanceof Error ? e.message : String(e);
return { success: false, error: err.message ?? err.error ?? "Registration failed." }; return { success: false, error: err || "Registration failed." };
} }
const data = await response.json();
// Normalize: RPC may return an array (single row) or a plain object
const result = Array.isArray(data) ? data[0] : data;
if (!result.success) {
return { success: false, error: result.error ?? "Registration failed." };
}
return {
success: true,
requiresApproval: result.requires_approval ?? false,
};
} }
export async function getPendingWholesaleRegistrations(brandId: string) { export async function getPendingWholesaleRegistrations(brandId: string) {
@@ -55,23 +45,15 @@ export async function getPendingWholesaleRegistrations(brandId: string) {
if (!adminUser) return []; if (!adminUser) return [];
if (!adminUser.can_manage_orders) return []; if (!adminUser.can_manage_orders) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
"SELECT * FROM get_pending_wholesale_registrations($1)",
const response = await fetch( [brandId]
`${supabaseUrl}/rest/v1/rpc/get_pending_wholesale_registrations`, );
{ return rows;
method: "POST", } catch {
headers: { return [];
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
return response.json();
} }
export async function approveWholesaleRegistration( export async function approveWholesaleRegistration(
@@ -88,33 +70,19 @@ export async function approveWholesaleRegistration(
return { success: false, error: "Not authorized to operate on this brand" }; return { success: false, error: "Not authorized to operate on this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM approve_wholesale_registration($1, $2, $3)",
const response = await fetch( [registrationId, brandId, action]
`${supabaseUrl}/rest/v1/rpc/approve_wholesale_registration`, );
{ const result = Array.isArray(rows) ? rows[0] : rows;
method: "POST", if (!result?.success) {
headers: { return { success: false, error: result?.error ?? "Failed to process registration." };
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_registration_id: registrationId,
p_brand_id: brandId,
p_action: action,
}),
} }
); return { success: true };
} catch (e: unknown) {
if (!response.ok) return { success: false, error: "Failed to process registration." }; return { success: false, error: e instanceof Error ? e.message : "Failed to process registration." };
const data = await response.json();
// Normalize array response (RETURNING single row) to plain object
const result = Array.isArray(data) ? data[0] : data;
if (!result.success) {
return { success: false, error: result.error ?? "Failed to process registration." };
} }
return { success: true };
} }
export async function getWholesaleCustomerByUser( export async function getWholesaleCustomerByUser(
@@ -131,24 +99,25 @@ export async function getWholesaleCustomerByUser(
role: string; role: string;
brand_id: string; brand_id: string;
} | null> { } | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_by_user($1, $2)",
const response = await fetch( [brandId, userId]
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`, );
{ return (rows[0] as {
method: "POST", id: string;
headers: { user_id: string;
...svcHeaders(supabaseKey), company_name: string;
"Content-Type": "application/json", contact_name: string;
}, email: string;
body: JSON.stringify({ p_brand_id: brandId, p_user_id: userId }), phone: string;
} account_status: string;
); role: string;
brand_id: string;
if (!response.ok) return null; } | undefined) ?? null;
const data = await response.json(); } catch {
return data ?? null; return null;
}
} }
// Fetch a wholesale customer directly by their customer ID (used for admin preview mode) // Fetch a wholesale customer directly by their customer ID (used for admin preview mode)
@@ -165,40 +134,41 @@ export async function getWholesaleCustomer(
role: string; role: string;
brand_id: string; brand_id: string;
} | null> { } | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
`SELECT id, user_id, company_name, contact_name, email, phone, account_status, role, brand_id
const response = await fetch( FROM wholesale_customers
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,user_id,company_name,contact_name,email,phone,account_status,role,brand_id`, WHERE id = $1
{ LIMIT 1`,
headers: svcHeaders(supabaseKey), [customerId]
} );
); return (rows[0] as {
id: string;
if (!response.ok) return null; user_id: string;
const data = await response.json(); company_name: string;
return data?.[0] ?? null; contact_name: string;
email: string;
phone: string;
account_status: string;
role: string;
brand_id: string;
} | undefined) ?? null;
} catch {
return null;
}
} }
export async function getWholesaleProducts(brandId: string) { export async function getWholesaleProducts(brandId: string) {
// Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too // Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
"SELECT * FROM get_wholesale_products($1)",
const response = await fetch( [brandId]
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`, );
{ return rows;
method: "POST", } catch {
headers: { return [];
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
return response.json();
} }
export async function submitWholesaleOrder(params: { export async function submitWholesaleOrder(params: {
@@ -220,9 +190,6 @@ export async function submitWholesaleOrder(params: {
orderTotal?: number; orderTotal?: number;
idempotent?: boolean; idempotent?: boolean;
}> { }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Generate UUID at the start of checkout — before RPC call for true idempotency // Generate UUID at the start of checkout — before RPC call for true idempotency
const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID(); const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID();
@@ -232,53 +199,59 @@ export async function submitWholesaleOrder(params: {
unit_price: i.unit_price, unit_price: i.unit_price,
})); }));
const response = await fetch( let result: {
`${supabaseUrl}/rest/v1/rpc/create_wholesale_order`, success?: boolean;
{ error?: string;
method: "POST", order_id?: string;
headers: { invoice_number?: string | null;
...svcHeaders(supabaseKey), status?: string;
"Content-Type": "application/json", deposit_required?: number;
}, credit_limit?: number;
body: JSON.stringify({ outstanding_balance?: number;
p_brand_id: params.brandId, order_total?: number;
p_customer_id: params.customerId, idempotent?: boolean;
p_anticipated_pickup_date: params.anticipatedPickupDate ?? null, } | null = null;
p_items: itemsJson,
p_notes: params.notes ?? null,
p_checkout_session_id: checkoutSessionId,
}),
}
);
if (!response.ok) return { success: false, error: "Failed to create order." }; try {
const data = await response.json(); const { rows } = await pool.query(
// Normalize array response (RETURNING single row) to plain object `SELECT * FROM create_wholesale_order($1, $2, $3, $4::jsonb, $5, $6)`,
const result = Array.isArray(data) ? data[0] : data; [
params.brandId,
params.customerId,
params.anticipatedPickupDate ?? null,
JSON.stringify(itemsJson),
params.notes ?? null,
checkoutSessionId,
]
);
result = Array.isArray(rows) ? rows[0] : rows;
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to create order." };
}
if (!result.success) { if (!result?.success) {
return { return {
success: false, success: false,
error: result.error ?? "Failed to create order.", error: result?.error ?? "Failed to create order.",
creditLimit: result.credit_limit, creditLimit: result?.credit_limit,
outstandingBalance: result.outstanding_balance, outstandingBalance: result?.outstanding_balance,
orderTotal: result.order_total, orderTotal: result?.order_total,
}; };
} }
// Fire webhook — fire-and-forget, don't block the response // Fire webhook — fire-and-forget, don't block the response
enqueueWholesaleWebhookForOrderCreated( enqueueWholesaleWebhookForOrderCreated(
result.order_id, result.order_id!,
params.brandId, params.brandId,
params.customerId, params.customerId,
result.invoice_number, result.invoice_number ?? null,
Number(result.deposit_required) || 0 Number(result.deposit_required) || 0
).catch(() => {}); ).catch(() => {});
return { return {
success: true, success: true,
orderId: result.order_id, orderId: result.order_id,
invoiceNumber: result.invoice_number, invoiceNumber: result.invoice_number ?? undefined,
status: result.status, status: result.status,
depositRequired: result.deposit_required, depositRequired: result.deposit_required,
idempotent: result.idempotent ?? false, idempotent: result.idempotent ?? false,
@@ -287,18 +260,15 @@ export async function submitWholesaleOrder(params: {
export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) { export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) {
try { try {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; await pool.query(
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!; "SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, { [
method: "POST", "order_created",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, orderId,
body: JSON.stringify({ brandId,
p_event_type: "order_created", JSON.stringify({ order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal }),
p_order_id: orderId, ]
p_brand_id: brandId, );
p_payload: { order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal },
}),
});
} catch (_) {} } catch (_) {}
} }
@@ -344,23 +314,15 @@ export type WholesalePricingOverride = {
}; };
export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> { export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_pricing($1)",
const response = await fetch( [customerId]
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_pricing`, );
{ return rows as WholesalePricingOverride[];
method: "POST", } catch {
headers: { return [];
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({ p_customer_id: customerId }),
}
);
if (!response.ok) return [];
return response.json();
} }
export async function upsertWholesaleCustomerPricing(params: { export async function upsertWholesaleCustomerPricing(params: {
@@ -368,71 +330,40 @@ export async function upsertWholesaleCustomerPricing(params: {
productId: string; productId: string;
customUnitPrice: number; customUnitPrice: number;
}): Promise<{ success: boolean; error?: string }> { }): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; await pool.query(
"SELECT upsert_wholesale_customer_pricing($1, $2, $3)",
const response = await fetch( [params.customerId, params.productId, params.customUnitPrice]
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer_pricing`, );
{ return { success: true };
method: "POST", } catch (e: unknown) {
headers: { return { success: false, error: e instanceof Error ? e.message : "Failed to save pricing override" };
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({
p_customer_id: params.customerId,
p_product_id: params.productId,
p_custom_unit_price: params.customUnitPrice,
}),
}
);
if (!response.ok) return { success: false, error: "Failed to save pricing override" };
return { success: true };
} }
export async function deleteWholesaleCustomerPricing(params: { export async function deleteWholesaleCustomerPricing(params: {
customerId: string; customerId: string;
productId: string; productId: string;
}): Promise<{ success: boolean; error?: string }> { }): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; await pool.query(
"SELECT delete_wholesale_customer_pricing($1, $2)",
const response = await fetch( [params.customerId, params.productId]
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer_pricing`, );
{ return { success: true };
method: "POST", } catch (e: unknown) {
headers: { return { success: false, error: e instanceof Error ? e.message : "Failed to delete pricing override" };
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({
p_customer_id: params.customerId,
p_product_id: params.productId,
}),
}
);
if (!response.ok) return { success: false, error: "Failed to delete pricing override" };
return { success: true };
} }
export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> { export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_orders($1)",
const response = await fetch( [customerId]
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_orders`, );
{ return rows as WholesaleCustomerOrder[];
method: "POST", } catch {
headers: { return [];
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({ p_customer_id: customerId }),
}
);
if (!response.ok) return [];
return response.json();
} }
+335 -520
View File
File diff suppressed because it is too large Load Diff
+15 -21
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers"; import { getAIClient } from "@/actions/integrations/ai-providers";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
const SYSTEM = `You are a data analyst for a B2B produce wholesale platform. const SYSTEM = `You are a data analyst for a B2B produce wholesale platform.
Given a natural language customer query, return a JSON response indicating which predefined query to run and what parameters to use. Given a natural language customer query, return a JSON response indicating which predefined query to run and what parameters to use.
@@ -82,40 +82,34 @@ Use "recent_orders" for recent order questions.`;
const queryType = parsed.queryType ?? "recent_orders"; const queryType = parsed.queryType ?? "recent_orders";
const days = parsed.days ?? 30; const days = parsed.days ?? 30;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
// Route to appropriate RPC based on query type // Route to appropriate RPC based on query type
let rpcName = "get_recent_orders_insights"; let rpcName = "get_recent_orders_insights";
let rpcParams: Record<string, unknown> = { p_brand_id: effectiveBrandId, p_days: days }; let rpcArgs: unknown[] = [effectiveBrandId, days];
if (queryType === "dormant") { if (queryType === "dormant") {
rpcName = "get_dormant_customers_insights"; rpcName = "get_dormant_customers_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days }; rpcArgs = [effectiveBrandId, days];
} else if (queryType === "trending") { } else if (queryType === "trending") {
rpcName = "get_trending_products_insights"; rpcName = "get_trending_products_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days }; rpcArgs = [effectiveBrandId, days];
} else if (queryType === "top_customers") { } else if (queryType === "top_customers") {
rpcName = "get_top_customers_insights"; rpcName = "get_top_customers_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days }; rpcArgs = [effectiveBrandId, days];
} else if (queryType === "at_risk") { } else if (queryType === "at_risk") {
rpcName = "get_at_risk_customers_insights"; rpcName = "get_at_risk_customers_insights";
rpcParams = { p_brand_id: effectiveBrandId }; rpcArgs = [effectiveBrandId];
} }
const dbResponse = await fetch(
`${supabaseUrl}/rest/v1/rpc/${rpcName}`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey!), "Content-Type": "application/json" },
body: JSON.stringify(rpcParams),
}
);
let results: unknown[] = []; let results: unknown[] = [];
if (dbResponse.ok) { try {
const data = await dbResponse.json(); const { rows } = await pool.query(
`SELECT ${rpcName}(${rpcArgs.map((_, i) => `$${i + 1}`).join(", ")}) AS result`,
rpcArgs,
);
const data = rows[0]?.result;
results = Array.isArray(data) ? data.slice(0, 100) : []; results = Array.isArray(data) ? data.slice(0, 100) : [];
} catch {
results = [];
} }
return NextResponse.json({ return NextResponse.json({
@@ -129,4 +123,4 @@ Use "recent_orders" for recent order questions.`;
} catch (err) { } catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 }); return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
} }
} }
@@ -1,77 +1,34 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers"; import {
import { sendAbandonedCartEmail, type AbandonedCart } from "@/actions/email-automation/abandoned-cart"; getAbandonedCarts,
sendAbandonedCartEmail,
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; type AbandonedCart,
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; } from "@/actions/email-automation/abandoned-cart";
const BRAND_IDS = [ const BRAND_IDS = [
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" }, { id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" },
{ id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct" }, { id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct" },
]; ];
const EMAIL_INTERVALS_HOURS = [1, 24, 48] as const;
async function processAbandonedCartsForBrand(brandId: string, brandName: string): Promise<{ sent: number; failed: number; skipped: number }> { async function processAbandonedCartsForBrand(brandId: string, brandName: string): Promise<{ sent: number; failed: number; skipped: number }> {
let sent = 0, failed = 0, skipped = 0; let sent = 0, failed = 0, skipped = 0;
void brandName;
// 1. Detect newly abandoned wholesale carts // 1+2. Detection and enrollment are gone with the abandoned_carts
const detectRes = await fetch( // table. Skipped silently.
`${supabaseUrl}/rest/v1/rpc/detect_abandoned_wholesale_carts`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!detectRes.ok) {
return { sent: 0, failed: 0, skipped: 0 };
}
const detected: Array<{ order_id: string; customer_id: string; contact_email: string; contact_name: string; cart_snapshot: unknown }> = await detectRes.json();
// 2. Enroll new abandoned carts
for (const row of detected) {
await fetch(
`${supabaseUrl}/rest/v1/rpc/enroll_abandoned_cart`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_customer_id: row.customer_id,
p_contact_email: row.contact_email,
p_contact_name: row.contact_name,
p_cart_snapshot: row.cart_snapshot,
p_brand_name: brandName,
p_locale: "en",
p_next_email_at: new Date(Date.now() + EMAIL_INTERVALS_HOURS[0] * 3600000).toISOString(),
}),
}
);
}
// 3. Fetch active carts ready for next email // 3. Fetch active carts ready for next email
const activeRes = await fetch( const result = await getAbandonedCarts(brandId);
`${supabaseUrl}/rest/v1/rpc/get_active_abandoned_carts`, if (!result.success) {
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!activeRes.ok) {
return { sent, failed, skipped }; return { sent, failed, skipped };
} }
const activeData = await activeRes.json(); const activeCarts: AbandonedCart[] = result.carts;
const row = Array.isArray(activeData) ? activeData[0] : activeData;
const activeCarts: AbandonedCart[] = row?.carts ?? [];
// 4. Send emails // 4. Send emails
for (const cart of activeCarts) { for (const cart of activeCarts) {
const nextStep = cart.sequence_step + 1; const nextStep = cart.sequence_step + 1;
if (nextStep > 3) { skipped++; continue; } if (nextStep > 3) { skipped++; continue; }
const result = await sendAbandonedCartEmail(cart, nextStep, brandId); const r = await sendAbandonedCartEmail(cart, nextStep, brandId);
if (result.success) { if (r.success) {
sent++; sent++;
} else { } else {
failed++; failed++;
@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers"; import {
import { sendWelcomeEmail, type WelcomeSequenceEntry } from "@/actions/email-automation/welcome-sequence"; getWelcomeSequence,
sendWelcomeEmail,
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; type WelcomeSequenceEntry,
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; } from "@/actions/email-automation/welcome-sequence";
const BRAND_IDS = [ const BRAND_IDS = [
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" }, { id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" },
@@ -13,28 +13,19 @@ const BRAND_IDS = [
async function processWelcomeSequencesForBrand(brandId: string): Promise<{ sent: number; failed: number; skipped: number }> { async function processWelcomeSequencesForBrand(brandId: string): Promise<{ sent: number; failed: number; skipped: number }> {
let sent = 0, failed = 0, skipped = 0; let sent = 0, failed = 0, skipped = 0;
const res = await fetch( const result = await getWelcomeSequence(brandId);
`${supabaseUrl}/rest/v1/rpc/get_active_welcome_sequence`, if (!result.success) {
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) {
return { sent: 0, failed: 0, skipped: 0 }; return { sent: 0, failed: 0, skipped: 0 };
} }
const data = await res.json(); const entries: WelcomeSequenceEntry[] = result.entries;
const row = Array.isArray(data) ? data[0] : data;
const entries: WelcomeSequenceEntry[] = row?.entries ?? [];
for (const entry of entries) { for (const entry of entries) {
const nextStep = entry.sequence_step + 1; const nextStep = entry.sequence_step + 1;
if (nextStep > 4) { skipped++; continue; } if (nextStep > 4) { skipped++; continue; }
if (entry.status === "unsubscribed" || entry.status === "bounced") { skipped++; continue; } if (entry.status === "unsubscribed" || entry.status === "bounced") { skipped++; continue; }
const result = await sendWelcomeEmail(entry, nextStep); const r = await sendWelcomeEmail(entry, nextStep);
if (result.success) { if (r.success) {
sent++; sent++;
} else { } else {
failed++; failed++;
+70 -37
View File
@@ -1,6 +1,19 @@
/**
* Orders report export endpoint.
*
* The legacy `get_orders_report` RPC is not in the database; this
* endpoint assembles an equivalent orders report directly from the
* `orders` + `customers` tables via a single Drizzle query, and
* returns it as either JSON or CSV.
*/
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { eq, desc } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { assertBrandAccess } from "@/lib/brand-scope";
import { withDb, withPlatformAdmin } from "@/db/client";
import { orders } from "@/db/schema/orders";
import { customers } from "@/db/schema/customers";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -13,47 +26,67 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const format = searchParams.get("format") ?? "json"; const format = searchParams.get("format") ?? "json";
const brandId = searchParams.get("brand_id") ?? adminUser.brand_id; const brandId = searchParams.get("brand_id") ?? adminUser.brand_id ?? null;
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { if (brandId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { assertBrandAccess(adminUser, brandId); } catch {
} return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Fetch orders report data
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_orders_report?`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
} }
);
if (!response.ok) {
return NextResponse.json({ error: "Failed to fetch report data" }, { status: 500 });
} }
const data = await response.json(); // platform_admin with no brandId = cross-tenant report; everyone else
// must pass a brandId matching their membership.
const rows = brandId
? await withDb((db) =>
db
.select({
id: orders.id,
tenantId: orders.tenantId,
customerId: orders.customerId,
customerName: customers.name,
customerEmail: customers.email,
status: orders.status,
totalCents: orders.totalCents,
placedAt: orders.placedAt,
})
.from(orders)
.leftJoin(customers, eq(customers.id, orders.customerId))
.where(eq(orders.tenantId, brandId))
.orderBy(desc(orders.placedAt))
.limit(1000),
)
: await withPlatformAdmin((db) =>
db
.select({
id: orders.id,
tenantId: orders.tenantId,
customerId: orders.customerId,
customerName: customers.name,
customerEmail: customers.email,
status: orders.status,
totalCents: orders.totalCents,
placedAt: orders.placedAt,
})
.from(orders)
.leftJoin(customers, eq(customers.id, orders.customerId))
.orderBy(desc(orders.placedAt))
.limit(1000),
);
if (format === "csv") { if (format === "csv") {
const rows = data.orders ?? []; const headers = ["id", "customer_name", "customer_email", "status", "total_cents", "placed_at"];
const headers = ["id", "customer_name", "customer_email", "status", "subtotal", "created_at"];
const csvRows = [headers.join(",")]; const csvRows = [headers.join(",")];
for (const row of rows) { for (const r of rows) {
csvRows.push([ csvRows.push(
row.id, [
`"${(row.customer_name ?? "").replace(/"/g, '""')}"`, r.id,
row.customer_email ?? "", `"${(r.customerName ?? "").replace(/"/g, '""')}"`,
row.status ?? "", r.customerEmail ?? "",
row.subtotal ?? 0, r.status ?? "",
row.created_at ?? "", r.totalCents ?? 0,
].join(",")); r.placedAt instanceof Date ? r.placedAt.toISOString() : "",
].join(","),
);
} }
return new NextResponse(csvRows.join("\n"), { return new NextResponse(csvRows.join("\n"), {
headers: { headers: {
@@ -63,5 +96,5 @@ export async function GET(req: NextRequest) {
}); });
} }
return NextResponse.json(data); return NextResponse.json({ orders: rows });
} }
+9 -74
View File
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto"; import crypto from "crypto";
import { svcHeaders } from "@/lib/svc-headers";
// Resend webhook events: email.delivered, email.opened, email.clicked, // Resend webhook events: email.delivered, email.opened, email.clicked,
// email.bounced, email.failed, email.unsubscribed // email.bounced, email.failed, email.unsubscribed
@@ -48,78 +47,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
} }
const { type, data } = event; // The new schema does not have a `communication_message_logs` table
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // — the per-recipient delivery log has been retired. We still
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // acknowledge the event so Resend does not retry, but there is
// nothing to update. When a new log table is added, this is the
// Look up by customer_email + subject (event_id is not populated by send_campaign) // place to look up by `customer_email + subject` (the event_id is
// Filter to recent logs (last 7 days) to avoid stale matches // never populated by `send_campaign`) and write delivered/opened/
const sevenDaysAgo = new Date(); // clicked/bounced timestamps.
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); void event;
const findRes = await fetch(
`${supabaseUrl}/rest/v1/communication_message_logs?` +
`customer_email=eq.${encodeURIComponent(data.to)}` +
`&subject=eq.${encodeURIComponent(data.subject ?? "")}` +
`&delivery_method=eq.email` +
`&created_at=gt.${sevenDaysAgo.toISOString()}` +
`&order=created_at.desc` +
`&limit=5&select=id,brand_id`,
{
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
);
if (!findRes.ok) {
return NextResponse.json({ error: "Failed to look up message" }, { status: 500 });
}
const logs: { id: string; brand_id: string }[] = await findRes.json();
if (logs.length === 0) {
// No matching log found — silently acknowledge to avoid Resend retries
return NextResponse.json({ ok: true });
}
// Update the most recent matching log
const log = logs[0];
const updates: Record<string, string | undefined> = {};
switch (type) {
case "email.delivered":
updates.delivered_at = data.delivered_at ?? new Date().toISOString();
updates.status = "delivered";
break;
case "email.opened":
updates.opened_at = data.opened_at ?? new Date().toISOString();
break;
case "email.clicked":
updates.clicked_at = data.clicked_at ?? new Date().toISOString();
break;
case "email.bounced":
case "email.failed":
updates.bounced_at = data.bounced_at ?? new Date().toISOString();
updates.bounce_reason = data.bounce_reason ?? undefined;
updates.status = "bounced";
break;
case "email.unsubscribed":
updates.status = "unsubscribed";
break;
default:
return NextResponse.json({ ok: true });
}
const patchRes = await fetch(
`${supabaseUrl}/rest/v1/communication_message_logs?id=eq.${log.id}`,
{
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=minimal" },
body: JSON.stringify(updates),
}
);
if (!patchRes.ok) {
return NextResponse.json({ error: "Failed to update log" }, { status: 500 });
}
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
+22 -188
View File
@@ -1,96 +1,10 @@
import { svcHeaders } from "@/lib/svc-headers"; // TODO(migration): the route-trace feature was retired from the SaaS rebuild.
// The `harvest_lots` / `harvest_lot_events` tables and the
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; // `get_harvest_lots` / `get_events_for_lots` / `get_harvest_lot_detail`
const SUPABASE_PAT = process.env.SUPABASE_PAT!; // SECURITY DEFINER RPCs no longer exist in `db/schema/`. This route is
// stubbed to return an empty compliance payload so the admin/trace UI
interface LotRow { // gracefully degrades. If route-trace comes back, re-introduce the
lot_id: string; // tables in `db/schema/` and replace this stub with a real Drizzle query.
lot_number: string;
crop_type: string;
variety: string | null;
harvest_date: string;
field_location: string | null;
field_block: string | null;
worker_name: string | null;
packer_name: string | null;
quantity_lbs: number | null;
quantity_used_lbs: number | null;
yield_estimate_lbs: number | null;
yield_unit: string | null;
bin_id: string | null;
status: string;
}
interface EventRow {
lot_id: string;
event_type: string;
event_time: string;
location: string | null;
notes: string | null;
bin_id: string | null;
created_by_name: string | null;
}
async function adminFetchJson(endpoint: string, body: unknown) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${endpoint}`, {
method: "POST",
headers: {
...svcHeaders(SUPABASE_PAT),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) return null;
return res.json();
}
function assessCompliance(lot: LotRow, eventCount: number): {
compliance_status: "compliant" | "non_compliant" | "pending";
issues: string[];
} {
const issues: string[] = [];
// Check for required traceability fields
if (!lot.harvest_date) {
issues.push("Missing harvest date");
}
if (!lot.field_location) {
issues.push("Missing field location");
}
if (!lot.quantity_lbs || lot.quantity_lbs <= 0) {
issues.push("Missing quantity");
}
// Check traceability chain
if (eventCount === 0) {
issues.push("No trace events");
}
// Check for worker/packer info
if (!lot.worker_name && !lot.packer_name) {
issues.push("No worker/packer info");
}
// Check yield variance
if (lot.yield_estimate_lbs && lot.yield_estimate_lbs > 0 && lot.quantity_lbs) {
const variance = Math.abs((lot.quantity_lbs - lot.yield_estimate_lbs) / lot.yield_estimate_lbs);
if (variance > 0.2) {
issues.push("High yield variance");
}
}
// Determine compliance status
let compliance_status: "compliant" | "non_compliant" | "pending";
if (issues.length === 0) {
compliance_status = "compliant";
} else if (issues.some(i => i === "No trace events" || i === "Missing harvest date")) {
compliance_status = "non_compliant";
} else {
compliance_status = "pending";
}
return { compliance_status, issues };
}
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -99,106 +13,26 @@ export async function GET(request: Request) {
const endDate = searchParams.get("endDate"); const endDate = searchParams.get("endDate");
if (!brandId || !startDate || !endDate) { if (!brandId || !startDate || !endDate) {
return new Response(JSON.stringify({ error: "Missing brandId, startDate, or endDate" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Fetch all lots for the brand
const allLots = await adminFetchJson("get_harvest_lots", {
p_brand_id: brandId,
p_status: null,
});
const lots: LotRow[] = (allLots ?? []) as LotRow[];
// Filter by date range
const filteredLots = lots.filter((lot) => {
const harvestDate = lot.harvest_date;
if (!harvestDate) return false;
return harvestDate >= startDate && harvestDate <= endDate;
});
if (filteredLots.length === 0) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({ error: "Missing brandId, startDate, or endDate" }),
lots: [], { status: 400, headers: { "Content-Type": "application/json" } },
summary: {
total_lots: 0,
compliant: 0,
non_compliant: 0,
pending: 0,
total_weight: 0,
used_weight: 0,
remaining_weight: 0,
crops: [],
},
}),
{ headers: { "Content-Type": "application/json" } }
); );
} }
// Fetch events for all lots
const lotIds = filteredLots.map((l) => l.lot_id);
const eventsRaw = await adminFetchJson("get_events_for_lots", { p_lot_ids: lotIds });
const allEvents: EventRow[] = (eventsRaw ?? []) as EventRow[];
// Group events by lot
const eventsByLot: Record<string, EventRow[]> = {};
for (const evt of allEvents) {
if (!eventsByLot[evt.lot_id]) eventsByLot[evt.lot_id] = [];
eventsByLot[evt.lot_id].push(evt);
}
// Process each lot for compliance
const complianceLots = filteredLots.map((lot) => {
const events = eventsByLot[lot.lot_id] ?? [];
const { compliance_status, issues } = assessCompliance(lot, events.length);
return {
lot_id: lot.lot_id,
lot_number: lot.lot_number,
crop_type: lot.crop_type,
variety: lot.variety,
harvest_date: lot.harvest_date,
field_location: lot.field_location,
field_block: lot.field_block,
worker_name: lot.worker_name,
packer_name: lot.packer_name,
quantity_lbs: lot.quantity_lbs,
quantity_used_lbs: lot.quantity_used_lbs,
yield_estimate_lbs: lot.yield_estimate_lbs,
yield_unit: lot.yield_unit,
bin_id: lot.bin_id,
status: lot.status,
event_count: events.length,
has_traceability: events.length > 0,
compliance_status,
issues,
};
});
// Calculate summary
const summary = {
total_lots: complianceLots.length,
compliant: complianceLots.filter((l) => l.compliance_status === "compliant").length,
non_compliant: complianceLots.filter((l) => l.compliance_status === "non_compliant").length,
pending: complianceLots.filter((l) => l.compliance_status === "pending").length,
total_weight: filteredLots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0),
used_weight: filteredLots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0),
remaining_weight: filteredLots.reduce(
(s, l) => s + Math.max(0, (l.quantity_lbs ?? 0) - (l.quantity_used_lbs ?? 0)),
0
),
crops: [...new Set(filteredLots.map((l) => l.crop_type))],
};
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
lots: complianceLots, lots: [],
summary, summary: {
total_lots: 0,
compliant: 0,
non_compliant: 0,
pending: 0,
total_weight: 0,
used_weight: 0,
remaining_weight: 0,
crops: [],
},
}), }),
{ headers: { "Content-Type": "application/json" } } { headers: { "Content-Type": "application/json" } },
); );
} }
+16 -170
View File
@@ -1,48 +1,9 @@
import { svcHeaders } from "@/lib/svc-headers"; // TODO(migration): the route-trace feature was retired from the SaaS rebuild.
// See fsma-compliance/route.ts for context. This route used to return a
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; // CSV report produced from the `get_harvest_lots` SECURITY DEFINER RPC,
const SUPABASE_PAT = process.env.SUPABASE_PAT!; // which no longer exists. The stub returns a "feature not configured"
// body so callers that link straight to this URL still get a meaningful
type LotRow = { // response (rather than a generic 500).
lot_id: string;
lot_number: string;
crop_type: string;
variety: string | null;
harvest_date: string;
field_location: string | null;
field_block: string | null;
worker_name: string | null;
packer_name: string | null;
quantity_lbs: number | null;
quantity_used_lbs: number | null;
yield_estimate_lbs: number | null;
yield_unit: string | null;
bin_id: string | null;
status: string;
};
type EventRow = {
lot_id: string;
event_type: string;
event_time: string;
location: string | null;
notes: string | null;
bin_id: string | null;
created_by_name: string | null;
};
async function adminFetchJson(endpoint: string, body: unknown) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${endpoint}`, {
method: "POST",
headers: {
...svcHeaders(SUPABASE_PAT),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) return null;
return res.json();
}
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -51,132 +12,17 @@ export async function GET(request: Request) {
const endDate = searchParams.get("endDate"); const endDate = searchParams.get("endDate");
if (!brandId || !startDate || !endDate) { if (!brandId || !startDate || !endDate) {
return new Response("Missing brandId, startDate, or endDate", { status: 400 }); return new Response(
"Missing brandId, startDate, or endDate",
{ status: 400 },
);
} }
// Use existing get_harvest_lots RPC and filter by date in JavaScript return new Response(
const allLots = await adminFetchJson("get_harvest_lots", { "Route-trace feature not configured — FSMA reports are unavailable in the SaaS rebuild.\n",
p_brand_id: brandId, {
p_status: null, status: 501,
}); headers: { "Content-Type": "text/plain" },
const lots: LotRow[] = (allLots ?? []) as LotRow[];
// Filter by date range
const filteredLots = lots.filter(lot => {
const harvestDate = lot.harvest_date;
if (!harvestDate) return false;
return harvestDate >= startDate && harvestDate <= endDate;
});
if (filteredLots.length === 0) {
return new Response("No lots found for this period", { status: 404 });
}
const lotIds = filteredLots.map((l) => l.lot_id);
const eventsRaw = await adminFetchJson("get_events_for_lots", { p_lot_ids: lotIds });
const allEvents: EventRow[] = (eventsRaw ?? []) as EventRow[];
const eventsByLot: Record<string, EventRow[]> = {};
for (const evt of allEvents) {
if (!eventsByLot[evt.lot_id]) eventsByLot[evt.lot_id] = [];
eventsByLot[evt.lot_id].push(evt);
}
const lines: string[] = [];
lines.push("FSMA FOOD SAFETY MODERNIZATION ACT — Produce Traceability Report");
lines.push(`Brand ID,${brandId}`);
lines.push(`Report Period,${startDate} to ${endDate}`);
lines.push(`Generated,${new Date().toLocaleString("en-US")}`);
lines.push(`Total Lots,${filteredLots.length}`);
lines.push("");
const totalQty = filteredLots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0);
const totalUsed = filteredLots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0);
const crops = [...new Set(filteredLots.map((l) => l.crop_type))];
const units = [...new Set(filteredLots.map((l) => l.yield_unit ?? "lbs"))];
const primaryUnit = units.length === 1 ? units[0] : "lbs";
lines.push("SUMMARY");
lines.push(`Total Lots Harvested,${filteredLots.length}`);
lines.push(`Total Weight (${primaryUnit}),${totalQty.toLocaleString()}`);
lines.push(`Total Used (${primaryUnit}),${totalUsed.toLocaleString()}`);
lines.push(`Remaining (${primaryUnit}),${(totalQty - totalUsed).toLocaleString()}`);
lines.push(`Crop Types,"${crops.join(", ")}"`);
lines.push(`Yield Unit,"${units.join(", ")}"`);
lines.push(`Date Range,${startDate} to ${endDate}`);
lines.push("");
lines.push("ONE-UP / ONE-DOWN TRACEABILITY");
lines.push("Lot Number,Crop,Variety,Harvest Date,Field,Block,Worker,Packer,Qty (lbs),Used (lbs),Remaining (lbs),Yield Est. (lbs),Yield Unit,Yield Variance %,Bin ID,Status,Event,Event Time,Location,Bin ID,Notes,Recorded By");
for (const lot of filteredLots) {
const evts = eventsByLot[lot.lot_id] ?? [];
const remaining = (lot.quantity_lbs ?? 0) - (lot.quantity_used_lbs ?? 0);
if (evts.length === 0) {
const yieldVariance = (lot.yield_estimate_lbs && lot.yield_estimate_lbs > 0)
? (((lot.quantity_lbs ?? 0) - lot.yield_estimate_lbs) / lot.yield_estimate_lbs * 100).toFixed(1)
: "";
lines.push(esc([
lot.lot_number, lot.crop_type, lot.variety ?? "", lot.harvest_date ?? "",
lot.field_location ?? "", lot.field_block ?? "", lot.worker_name ?? "",
lot.packer_name ?? "", String(lot.quantity_lbs ?? ""),
String(lot.quantity_used_lbs ?? ""), String(Math.max(0, remaining)),
String(lot.yield_estimate_lbs ?? ""), lot.yield_unit ?? "",
yieldVariance,
lot.bin_id ?? "", lot.status ?? "",
"—", "—", "—", "—", "—", "—",
]));
} else {
for (let i = 0; i < evts.length; i++) {
const evt = evts[i];
const evtTime = new Date(evt.event_time).toLocaleString("en-US", {
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
const yieldVariance = (lot.yield_estimate_lbs && lot.yield_estimate_lbs > 0)
? (((lot.quantity_lbs ?? 0) - lot.yield_estimate_lbs) / lot.yield_estimate_lbs * 100).toFixed(1)
: "";
lines.push(esc([
i === 0 ? lot.lot_number : "",
i === 0 ? lot.crop_type : "",
i === 0 ? (lot.variety ?? "") : "",
i === 0 ? (lot.harvest_date ?? "") : "",
i === 0 ? (lot.field_location ?? "") : "",
i === 0 ? (lot.field_block ?? "") : "",
i === 0 ? (lot.worker_name ?? "") : "",
i === 0 ? (lot.packer_name ?? "") : "",
i === 0 ? String(lot.quantity_lbs ?? "") : "",
i === 0 ? String(lot.quantity_used_lbs ?? "") : "",
i === 0 ? String(Math.max(0, remaining)) : "",
i === 0 ? String(lot.yield_estimate_lbs ?? "") : "",
i === 0 ? (lot.yield_unit ?? "") : "",
i === 0 ? yieldVariance : "",
i === 0 ? (lot.bin_id ?? "") : "",
i === 0 ? (lot.status ?? "") : "",
evt.event_type ?? "",
evtTime,
evt.location ?? "",
evt.bin_id ?? "",
(evt.notes ?? "").replace(/"/g, '""'),
evt.created_by_name ?? "",
]));
}
}
}
lines.push("");
lines.push("END OF REPORT");
lines.push("Powered by Route Commerce — routecommerce.com");
const csv = lines.join("\n");
return new Response(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="fsma-report-${startDate}-to-${endDate}.csv"`,
}, },
}); );
} }
function esc(values: string[]): string {
return values.map((v) => `"${v.replace(/"/g, '""')}"`).join(",");
}
+37 -154
View File
@@ -1,162 +1,45 @@
import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; // TODO(migration): the route-trace feature was retired from the SaaS rebuild.
import QRCode from "qrcode"; // `get_harvest_lot_detail` no longer exists in `db/schema/`. This route
import { svcHeaders } from "@/lib/svc-headers"; // used to generate a thermal-label PDF with a QR code pointing to the
// public trace page. Without lot data we can't render the sticker, so
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; // we return a tiny placeholder PDF. The `StickerPreviewModal` consumer
const SUPABASE_PAT = process.env.SUPABASE_PAT!; // already handles the empty state.
async function getLotDetail(lotId: string) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/get_harvest_lot_detail`, {
method: "POST",
headers: {
...svcHeaders(SUPABASE_PAT),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_lot_id: lotId }),
});
if (!res.ok) return null;
const data = await res.json();
return data?.[0] ?? null;
}
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const lotId = searchParams.get("lotId"); const lotId = searchParams.get("lotId");
const type = searchParams.get("type") ?? "field";
const size = searchParams.get("size") ?? "4x2";
const copies = Math.min(Math.max(parseInt(searchParams.get("copies") ?? "1"), 1), 10);
if (!lotId) return new Response("Missing lotId", { status: 400 }); if (!lotId) {
return new Response("Missing lotId", { status: 400 });
const lot = await getLotDetail(lotId);
if (!lot) return new Response("Lot not found", { status: 404 });
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000";
const traceUrl = `${baseUrl}/trace/${lot.lot_number}`;
const pdfDoc = await PDFDocument.create();
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const BLACK = rgb(0, 0, 0);
const WHITE = rgb(1, 1, 1);
// Thermal label sizes: 4x2" = 288×144 pts, 4x3" = 288×216 pts
const LABEL_W = 288;
const LABEL_H = size === "4x3" ? 216 : 144;
// QR at maximum: fills right column top-to-bottom
const QR_SIZE = size === "4x3" ? 136 : 116;
const GAP = 18;
// QR generation
let qrDataUrl: string | null = null;
try {
qrDataUrl = await QRCode.toDataURL(traceUrl, {
width: QR_SIZE * 3,
margin: 2,
color: { dark: "#000000", light: "#FFFFFF" },
});
} catch (e) {
// QR generation failed silently
} }
for (let i = 0; i < copies; i++) { // Minimal 1-page PDF saying the feature is retired. Hand-built so the
const page = pdfDoc.addPage([LABEL_W, LABEL_H * 2 + GAP]); // route doesn't need to instantiate pdf-lib at all (the route is
const y = LABEL_H * 2 + GAP - (i + 1) * LABEL_H; // reached only by the StickerPreviewModal when route-trace data is
// missing, which the SaaS rebuild always is).
// White background for pure black-on-white thermal printing const pdfBody = `%PDF-1.4
page.drawRectangle({ x: 0, y, width: LABEL_W, height: LABEL_H, color: WHITE }); 1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj
const LEFT = 10; 3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 288 144]/Resources<</Font<</F1 4 0 R>>>>/Contents 5 0 R>>endobj
const RIGHT_X = LABEL_W - QR_SIZE - 6; 4 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
const TOP = y + LABEL_H - 8; 5 0 obj<</Length 70>>stream
const QR_Y = y + 6; BT /F1 10 Tf 10 70 Td (Route-trace feature not configured.) Tj ET
endstream
function drawLine(text: string, x: number, yPos: number, sz: number, isBold = false) { endobj
const f = isBold ? fontBold : font; xref
page.drawText(text, { x, y: yPos, size: sz, font: f, color: BLACK }); 0 6
} 0000000000 65535 f
0000000009 00000 n
function yPos(row: number, baseSize: number) { 0000000056 00000 n
return TOP - row * (baseSize + 2); 0000000104 00000 n
} 0000000192 00000 n
0000000253 00000 n
const dataSize = size === "4x3" ? 8.5 : 7; trailer<</Size 6/Root 1 0 R>>
const rowH = dataSize + 3; startxref
368
// ─── Brand header ─────────────────────────────────────────── %%EOF`;
drawLine("ROUTE TRACE", LEFT, yPos(0, 6), 6, true); return new Response(pdfBody, {
status: 200,
// ─── Lot number — dominant ─────────────────────────────────── headers: { "Content-Type": "application/pdf" },
const lotNumSize = size === "4x3" ? 28 : 22;
drawLine(lot.lot_number, LEFT, yPos(1, lotNumSize), lotNumSize, true);
// ─── Crop + variety ─────────────────────────────────────────
const cropSize = size === "4x3" ? 12 : 10;
drawLine(lot.crop_type, LEFT, yPos(2, cropSize), cropSize, true);
if (lot.variety) {
drawLine(lot.variety, LEFT, yPos(2.7, cropSize - 1), cropSize - 1);
}
// ─── Left column data ───────────────────────────────────────
let row = 3;
if (type === "field") {
if (lot.quantity_lbs) {
drawLine(`${Number(lot.quantity_lbs).toLocaleString()} ${lot.yield_unit ?? "lbs"}`, LEFT, yPos(row, 11), 11, true); row += 1;
}
if (lot.harvest_date) { drawLine(`Harvested: ${lot.harvest_date}`, LEFT, yPos(row, dataSize), dataSize); row += 1; }
if (lot.field_location) { drawLine(`Field: ${lot.field_location}`, LEFT, yPos(row, dataSize), dataSize); row += 1; }
if (lot.field_block) { drawLine(`Block: ${lot.field_block}`, LEFT, yPos(row, dataSize), dataSize); row += 1; }
if (lot.worker_name) { drawLine(`Worker: ${lot.worker_name}`, LEFT, yPos(row, dataSize), dataSize); row += 1; }
if (lot.yield_estimate_lbs) {
drawLine(`Est: ${Number(lot.yield_estimate_lbs).toLocaleString()} ${lot.yield_unit ?? "lbs"}`, LEFT, yPos(row, dataSize), dataSize); row += 1;
}
} else {
if (lot.quantity_lbs) {
drawLine(`${Number(lot.quantity_lbs).toLocaleString()} ${lot.yield_unit ?? "lbs"}`, LEFT, yPos(row, 13), 13, true); row += 1;
}
if (lot.harvest_date) { drawLine(`Packed: ${lot.harvest_date}`, LEFT, yPos(row, dataSize), dataSize); row += 1; }
if (lot.field_location) { drawLine(`From: ${lot.field_location}`, LEFT, yPos(row, dataSize), dataSize); row += 1; }
if (lot.worker_name) { drawLine(`Worker: ${lot.worker_name}`, LEFT, yPos(row, dataSize), dataSize); row += 1; }
if (lot.destination_stop_id) {
drawLine(`Dest: #${lot.destination_stop_id.slice(0, 8)}`, LEFT, yPos(row, dataSize), dataSize); row += 1;
}
}
// ─── Right column — bin / container / pallets ──────────────
let ryR = TOP;
if (lot.bin_id) { drawLine(`BIN ${lot.bin_id}`, RIGHT_X, ryR, dataSize, true); ryR -= rowH; }
if (lot.container_id) { drawLine(`CONT ${lot.container_id}`, RIGHT_X, ryR, dataSize, true); ryR -= rowH; }
if (lot.pallets) { drawLine(`${lot.pallets} PLT`, RIGHT_X, ryR, dataSize, true); ryR -= rowH; }
if (lot.field_block && type === "shed") {
drawLine(`Block: ${lot.field_block}`, RIGHT_X, ryR, dataSize); ryR -= rowH;
}
// ─── QR code — right column, full height ────────────────────
if (qrDataUrl) {
try {
const qrBase64 = qrDataUrl.replace(/^data:image\/png;base64,/, "");
const qrBytes = Uint8Array.from(atob(qrBase64), (c) => c.charCodeAt(0));
const qrImg = await pdfDoc.embedPng(qrBytes);
page.drawImage(qrImg, {
x: RIGHT_X,
y: QR_Y,
width: QR_SIZE,
height: QR_SIZE,
});
} catch (_) {}
}
// Small URL label under QR
page.drawText("/trace", { x: RIGHT_X, y: y + 2, size: 5, font, color: BLACK });
}
const pdfBytes = await pdfDoc.save();
return new Response(new Uint8Array(pdfBytes), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${lot.lot_number}-${type}-${size}.pdf"`,
},
}); });
} }
+21 -195
View File
@@ -1,206 +1,32 @@
import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; // TODO(migration): the route-trace feature was retired from the SaaS rebuild.
import { svcHeaders } from "@/lib/svc-headers"; // See fsma-compliance/route.ts for context. `get_harvest_lot_detail` and
// `get_lot_orders` no longer exist in `db/schema/`. The stub returns a
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; // CSV body indicating the feature is unavailable so direct links from
const SUPABASE_PAT = process.env.SUPABASE_PAT!; // the admin UI degrade gracefully.
async function getLotWithChain(lotId: string) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/get_harvest_lot_detail`, {
method: "POST",
headers: {
...svcHeaders(SUPABASE_PAT),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_lot_id: lotId }),
});
if (!res.ok) return null;
const data = await res.json();
return data?.[0] ?? null;
}
async function getLotOrders(lotId: string) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/get_lot_orders`, {
method: "POST",
headers: {
...svcHeaders(SUPABASE_PAT),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_lot_id: lotId }),
});
if (!res.ok) return [];
return await res.json();
}
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const lotId = searchParams.get("lotId"); const lotId = searchParams.get("lotId");
const format = searchParams.get("format") ?? "csv"; const format = searchParams.get("format") ?? "csv";
if (!lotId) return new Response("Missing lotId", { status: 400 }); if (!lotId) {
return new Response("Missing lotId", { status: 400 });
}
const lot = await getLotWithChain(lotId); if (format === "pdf") {
if (!lot) return new Response("Lot not found", { status: 404 }); return new Response(
"Route-trace feature not configured — PDF trace reports are unavailable.\n",
const orders = await getLotOrders(lotId); {
status: 501,
if (format === "csv") { headers: { "Content-Type": "text/plain" },
const lines: string[] = [];
lines.push("ROUTE TRACE — Lot Traceability Report");
lines.push("");
lines.push("1. LOT INFORMATION");
lines.push(`Lot Number,${lot.lot_number}`);
lines.push(`Crop Type,${lot.crop_type}`);
if (lot.variety) lines.push(`Variety,${lot.variety}`);
lines.push(`Harvest Date,${lot.harvest_date}`);
if (lot.field_location) lines.push(`Field / Location,${lot.field_location}`);
if (lot.field_block) lines.push(`Field Block,${lot.field_block}`);
if (lot.worker_name) lines.push(`Worker,${lot.worker_name}`);
if (lot.packer_name) lines.push(`Packer,${lot.packer_name}`);
if (lot.quantity_lbs != null) lines.push(`Quantity (${lot.yield_unit ?? "lbs"}),${lot.quantity_lbs}`);
if (lot.yield_estimate_lbs != null) lines.push(`Yield Estimate (${lot.yield_unit ?? "lbs"}),${lot.yield_estimate_lbs}`);
if (lot.bin_id) lines.push(`Bin ID,${lot.bin_id}`);
if (lot.container_id) lines.push(`Container ID,${lot.container_id}`);
if (lot.pallets != null) lines.push(`Pallets,${lot.pallets}`);
lines.push(`Current Status,${lot.status}`);
if (lot.yield_unit && lot.yield_unit !== "lbs") lines.push(`Yield Unit,${lot.yield_unit}`);
if (lot.notes) lines.push(`Notes,"${lot.notes.replace(/"/g, '""')}"`);
lines.push("");
lines.push("2. TRACE TIMELINE (FSMA One-Up/One-Down)");
lines.push("Event,Timestamp,Location,Bin ID,Notes,Recorded By");
for (const evt of (lot.events ?? [])) {
const ts = new Date(evt.event_time).toLocaleString("en-US", {
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
lines.push(`${evt.event_type},${ts},${evt.location ?? ""},${evt.bin_id ?? ""},${(evt.notes ?? "").replace(/"/g, '""')},${evt.created_by_name ?? ""}`);
}
lines.push("");
lines.push("3. ORDER FULFILLMENT");
if (orders.length > 0) {
lines.push("Order ID,Customer,Stop,Date,Qty (lbs),Fulfillment");
for (const o of orders) {
lines.push(`${o.id},${(o.customer_name ?? "").replace(/"/g, '""')},${(o.stop_name ?? "").replace(/"/g, '""')},${o.order_date},${o.item_quantity ?? ""},${o.fulfillment ?? ""}`);
}
} else {
lines.push("No orders assigned.");
}
lines.push("");
lines.push("4. COMPLIANCE SUMMARY");
lines.push(`Total Events,${lot.events?.length ?? 0}`);
lines.push(`Harvested By,${lot.worker_name ?? "Unknown"}`);
lines.push(`Packed By,${lot.packer_name ?? "N/A"}`);
lines.push(`Destination Stop,${lot.destination_stop_id ? `#${lot.destination_stop_id.slice(0, 8)}` : "N/A"}`);
lines.push(`Report Generated,${new Date().toLocaleString("en-US")}`);
lines.push("");
lines.push("Powered by Route Commerce — routecommerce.com");
const csv = lines.join("\n");
return new Response(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="${lot.lot_number}-trace-report.csv"`,
}, },
}); );
} }
// PDF report // CSV — return a single header row that downstream parsers can detect.
const pdfDoc = await PDFDocument.create(); const csv = "error,message\nretired,Route-trace feature not configured\n";
const font = await pdfDoc.embedFont(StandardFonts.Helvetica); return new Response(csv, {
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); status: 501,
const BLACK = rgb(0, 0, 0); headers: { "Content-Type": "text/csv" },
const WHITE = rgb(1, 1, 1);
const GREEN = rgb(0.133, 0.553, 0.133);
const page = pdfDoc.addPage([612, 792]); // letter
const L = 40;
let y = 752;
function heading(text: string, sz = 14) {
page.drawText(text, { x: L, y, size: sz, font: fontBold, color: BLACK });
y -= sz + 6;
}
function row(label: string, value: string, sz = 10) {
page.drawText(label + ":", { x: L, y, size: sz, font, color: BLACK });
page.drawText(value, { x: L + 140, y, size: sz, font: fontBold, color: BLACK });
y -= sz + 4;
}
function sectionHeader(text: string) {
page.drawRectangle({ x: L, y: y - 2, width: 532, height: 12, color: rgb(0.133, 0.133, 0.133) });
page.drawText(text, { x: L + 4, y: y - 9, size: 9, font: fontBold, color: WHITE });
y -= 20;
}
// Header
page.drawRectangle({ x: 0, y: 754, width: 612, height: 38, color: rgb(0.133, 0.4, 0.2) });
page.drawText("ROUTE TRACE", { x: L, y: 760, size: 20, font: fontBold, color: WHITE });
page.drawText("Lot Traceability Report", { x: L + 160, y: 764, size: 10, font, color: WHITE });
page.drawText(lot.lot_number, { x: L + 320, y: 758, size: 16, font: fontBold, color: WHITE });
y = 738;
// Section 1: Lot Info
sectionHeader("1. LOT INFORMATION");
row("Crop Type", lot.crop_type, 12);
if (lot.variety) row("Variety", lot.variety);
row("Harvest Date", lot.harvest_date);
if (lot.field_location) row("Field / Location", lot.field_location);
if (lot.field_block) row("Field Block", lot.field_block);
if (lot.worker_name) row("Worker", lot.worker_name);
if (lot.packer_name) row("Packer", lot.packer_name);
if (lot.quantity_lbs != null) row(`Quantity (${lot.yield_unit ?? "lbs"})`, lot.quantity_lbs.toLocaleString());
if (lot.yield_estimate_lbs != null) row(`Yield Estimate (${lot.yield_unit ?? "lbs"})`, lot.yield_estimate_lbs.toLocaleString());
if (lot.bin_id) row("Bin ID", lot.bin_id);
if (lot.container_id) row("Container ID", lot.container_id);
if (lot.pallets != null) row("Pallets", String(lot.pallets));
row("Status", lot.status?.replace("_", " ").toUpperCase() ?? "");
y -= 10;
// Section 2: Trace Timeline
sectionHeader("2. TRACE TIMELINE — FSMA One-Up / One-Down");
for (const evt of (lot.events ?? [])) {
const ts = new Date(evt.event_time).toLocaleString("en-US", {
month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit",
});
const label = `${evt.event_type?.replace("_", " ").toUpperCase() ?? ""}${ts}`;
page.drawText(label, { x: L, y, size: 9, font: fontBold, color: BLACK });
y -= 10;
if (evt.location) { page.drawText(` Location: ${evt.location}`, { x: L, y, size: 8, font, color: BLACK }); y -= 9; }
if (evt.bin_id) { page.drawText(` Bin: ${evt.bin_id}`, { x: L, y, size: 8, font, color: BLACK }); y -= 9; }
if (evt.created_by_name) { page.drawText(` By: ${evt.created_by_name}`, { x: L, y, size: 8, font, color: BLACK }); y -= 9; }
if (evt.notes) { page.drawText(` Note: ${evt.notes}`, { x: L, y, size: 8, font, color: BLACK }); y -= 9; }
y -= 4;
if (y < 80) {
const np = pdfDoc.addPage([612, 792]);
y = 752;
}
}
y -= 6;
// Section 3: Orders
if (orders.length > 0) {
sectionHeader("3. ORDER FULFILLMENT");
for (const o of orders) {
page.drawText(`${o.customer_name} | ${o.stop_name} | ${o.order_date} | ${o.item_quantity?.toLocaleString() ?? "—"} lbs | ${o.fulfillment ?? ""}`, {
x: L, y, size: 9, font, color: BLACK,
});
y -= 11;
}
y -= 6;
}
// Compliance footer
page.drawRectangle({ x: L, y: Math.max(y - 24, 40), width: 532, height: 1, color: rgb(0.8, 0.8, 0.8) });
y -= 14;
page.drawText("Report Generated by Route Commerce — routecommerce.com", { x: L, y, size: 7, font, color: rgb(0.5, 0.5, 0.5) });
const pdfBytes = await pdfDoc.save();
return new Response(new Uint8Array(pdfBytes), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${lot.lot_number}-trace-report.pdf"`,
},
}); });
} }
+15 -23
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -97,30 +97,22 @@ export async function GET(request: Request) {
); );
} }
// Store token + location_id in payment_settings via upsert // Store token + location_id in payment_settings via SECURITY DEFINER RPC
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try { try {
const upsertResponse = await fetch( await pool.query(
`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, "SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)",
{ [
method: "POST", brandId,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, "square",
body: JSON.stringify({ null, // stripe_publishable_key
p_brand_id: brandId, null, // stripe_secret_key
p_provider: "square", null, // stripe_user_id
p_square_access_token: accessToken, accessToken,
p_square_location_id: locationId, locationId,
}), null, // square_sync_enabled (not changed here)
} null, // square_inventory_mode (not changed here)
]
); );
if (!upsertResponse.ok) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_token_save_failed", request.url)
);
}
} catch (err) { } catch (err) {
return NextResponse.redirect( return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_token_save_error", request.url) new URL("/admin/settings/payments?error=square_token_save_error", request.url)
+15 -12
View File
@@ -3,7 +3,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { syncProductsToSquare, syncProductsFromSquare } from "@/actions/square-products"; import { syncProductsToSquare, syncProductsFromSquare } from "@/actions/square-products";
import { syncOrdersFromSquare } from "@/actions/square-orders"; import { syncOrdersFromSquare } from "@/actions/square-orders";
import { getPaymentSettings } from "@/actions/payments"; import { getPaymentSettings } from "@/actions/payments";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export async function POST(request: Request) { export async function POST(request: Request) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -58,19 +58,22 @@ export async function POST(request: Request) {
} }
// Update square_last_sync_at and square_last_sync_error // Update square_last_sync_at and square_last_sync_error
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const lastError = syncErrors.length > 0 ? syncErrors.slice(0, 5).join("; ") : null; const lastError = syncErrors.length > 0 ? syncErrors.slice(0, 5).join("; ") : null;
await fetch(`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, { await pool.query(
method: "POST", "SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [
body: JSON.stringify({ brandId,
p_brand_id: brandId, null, // provider (not changed)
p_square_sync_enabled: true, null, // stripe_publishable_key
p_square_inventory_mode: settings?.square_inventory_mode ?? "none", null, // stripe_secret_key
}), null, // stripe_user_id
}); null, // square_access_token
null, // square_location_id
true, // square_sync_enabled
settings?.square_inventory_mode ?? "none",
]
);
return NextResponse.json({ return NextResponse.json({
success: syncErrors.length === 0, success: syncErrors.length === 0,
+17 -37
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { syncProductsToSquare } from "@/actions/square-products"; import { syncProductsToSquare } from "@/actions/square-products";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -18,24 +18,19 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "brand_id required" }, { status: 400 }); return NextResponse.json({ error: "brand_id required" }, { status: 400 });
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Claim a pending sync entry from the queue via SECURITY DEFINER RPC
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; let entries: Array<{ id: string; brand_id: string }> = [];
try {
const claimRes = await fetch( const { rows } = await pool.query<{ id: string; brand_id: string }>(
`${supabaseUrl}/rest/v1/rpc/claim_square_sync_queue`, "SELECT * FROM claim_square_sync_queue($1)",
{ [brandId]
method: "POST", );
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", "Prefer": "return=representation" }, entries = rows;
body: JSON.stringify({ p_brand_id: brandId }), } catch (e: unknown) {
} const errText = e instanceof Error ? e.message : String(e);
);
if (!claimRes.ok) {
const errText = await claimRes.text();
return NextResponse.json({ error: `Failed to claim queue: ${errText}` }, { status: 500 }); return NextResponse.json({ error: `Failed to claim queue: ${errText}` }, { status: 500 });
} }
const entries = await claimRes.json();
if (!entries || entries.length === 0 || entries[0] === null) { if (!entries || entries.length === 0 || entries[0] === null) {
return NextResponse.json({ processed: 0, message: "No pending entries" }); return NextResponse.json({ processed: 0, message: "No pending entries" });
} }
@@ -46,29 +41,14 @@ export async function GET(request: Request) {
const newStatus = result.success ? "done" : "failed"; const newStatus = result.success ? "done" : "failed";
const lastError = result.errors.length > 0 ? result.errors[0] : null; const lastError = result.errors.length > 0 ? result.errors[0] : null;
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/rpc/update_square_sync_timestamp`, "SELECT update_square_sync_timestamp($1, $2)",
{ [entry.brand_id, lastError]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: entry.brand_id,
p_error: lastError,
}),
}
); );
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/square_sync_queue?id=eq.${entry.id}`, "UPDATE square_sync_queue SET status = $1, processed_at = $2, last_error = $3 WHERE id = $4",
{ [newStatus, new Date().toISOString(), lastError, entry.id]
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
status: newStatus,
processed_at: new Date().toISOString(),
last_error: lastError,
}),
}
); );
return NextResponse.json({ return NextResponse.json({
-52
View File
@@ -1,52 +0,0 @@
import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
// Server-side proxy for Supabase REST calls from Client Components.
// Client components cannot import "use server" modules, so they route
// all Supabase calls through here to avoid Bearer JWT header issues
// on Vercel Edge Runtime.
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
export async function POST(request: Request) {
try {
const { table, method = "GET", body, params, headers: extraHeaders } = await request.json();
if (!table) {
return NextResponse.json({ error: "table is required" }, { status: 400 });
}
// Build URL — params are query string key=value pairs
let url = `${SUPABASE_URL}/rest/v1/${table}`;
if (params) {
const qs = Object.entries(params as Record<string, string>)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
if (qs) url += `?${qs}`;
}
// Determine which key to use — prefer ANON_KEY for client-facing reads
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const fetchOptions: RequestInit = {
method: method.toUpperCase(),
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
...extraHeaders,
},
};
if (body && !["GET", "HEAD"].includes(method.toUpperCase())) {
fetchOptions.body = JSON.stringify(body);
}
const res = await fetch(url, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data, { status: res.status });
} catch (err) {
const msg = err instanceof Error ? err.message : "Proxy error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+23 -46
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { emailLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit"; import { emailLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { analytics } from "@/lib/analytics"; import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry"; import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
// Helper functions // Helper functions
function apiResponse(data: unknown, status: number = 200) { function apiResponse(data: unknown, status: number = 200) {
@@ -44,44 +45,31 @@ export async function POST(req: NextRequest) {
// Parse and validate body // Parse and validate body
const body = await req.json(); const body = await req.json();
const validation = createCampaignSchema.safeParse(body); const validation = createCampaignSchema.safeParse(body);
if (!validation.success) { if (!validation.success) {
return validationError(validation.error); return validationError(validation.error);
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Create campaign via RPC // Create campaign via RPC
const res = await fetch( const { rows: campaignRows } = await pool.query<{ create_campaign: { id: string; [k: string]: unknown } | null }>(
`${supabaseUrl}/rest/v1/rpc/create_campaign`, `SELECT create_campaign($1, $2, $3, $4, $5, $6, $7::uuid[], $8) AS create_campaign`,
{ [
method: "POST", validation.data.brand_id,
headers: { validation.data.name,
apikey: serviceKey, validation.data.subject,
"Content-Type": "application/json", validation.data.content,
Prefer: "return=representation", validation.data.type,
}, validation.data.segment_id ?? null,
body: JSON.stringify({ validation.data.contact_ids ?? null,
p_brand_id: validation.data.brand_id, validation.data.scheduled_at ?? null,
p_name: validation.data.name, ],
p_subject: validation.data.subject,
p_content: validation.data.content,
p_type: validation.data.type,
p_segment_id: validation.data.segment_id,
p_contact_ids: validation.data.contact_ids,
p_scheduled_at: validation.data.scheduled_at,
}),
}
); );
if (!res.ok) { const campaign = campaignRows[0]?.create_campaign;
const error = await res.json(); if (!campaign) {
return apiError(error.message || "Failed to create campaign", 500); return apiError("Failed to create campaign", 500);
} }
const campaign = await res.json();
// Track analytics // Track analytics
const audienceSize = validation.data.contact_ids?.length || 0; const audienceSize = validation.data.contact_ids?.length || 0;
analytics.campaignCreated(campaign.id, validation.data.type, audienceSize); analytics.campaignCreated(campaign.id, validation.data.type, audienceSize);
@@ -102,24 +90,13 @@ export async function GET(req: NextRequest) {
return apiError("brand_id is required", 400); return apiError("brand_id is required", 400);
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const { rows: campaigns } = await pool.query(
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; `SELECT * FROM communication_campaigns
WHERE brand_id = $1
const res = await fetch( ORDER BY created_at DESC`,
`${supabaseUrl}/rest/v1/communication_campaigns?brand_id=eq.${brand_id}&select=*&order=created_at.desc`, [brand_id],
{
headers: {
apikey: anonKey,
"Content-Type": "application/json",
},
}
); );
if (!res.ok) {
return apiError("Failed to fetch campaigns", 500);
}
const campaigns = await res.json();
return apiResponse(campaigns); return apiResponse(campaigns);
} catch (error) { } catch (error) {
captureError(error as Error, { path: "/api/campaigns", method: "GET" }); captureError(error as Error, { path: "/api/campaigns", method: "GET" });
@@ -137,4 +114,4 @@ export async function OPTIONS() {
"Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Allow-Headers": "Content-Type, Authorization",
}, },
}); });
} }
+75 -56
View File
@@ -4,6 +4,9 @@ import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit"; import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { analytics } from "@/lib/analytics"; import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry"; import { captureError } from "@/lib/sentry";
import { withDb, withPlatformAdmin } from "@/db/client";
import { products, type Product } from "@/db/schema";
import { and, eq, ilike, or, sql } from "drizzle-orm";
// Helper functions // Helper functions
function apiResponse(data: unknown, status: number = 200) { function apiResponse(data: unknown, status: number = 200) {
@@ -55,44 +58,47 @@ export async function GET(req: NextRequest) {
return validationError(validation.error); return validationError(validation.error);
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Build the WHERE conditions. We use `withDb` (no tenant GUC) here
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // because this is a public API — RLS isn't enforced via the new
// schema's app.current_tenant_id GUC, so we filter explicitly.
// Build query const whereParts = [];
let query = `${supabaseUrl}/rest/v1/products?select=*&order=name.asc&limit=${validation.data.limit}&offset=${validation.data.offset}`;
if (validation.data.brand_id) { if (validation.data.brand_id) {
query += `&brand_id=eq.${validation.data.brand_id}`; whereParts.push(eq(products.tenantId, validation.data.brand_id));
}
if (validation.data.category) {
query += `&category=ilike.*${encodeURIComponent(validation.data.category)}*`;
} }
if (validation.data.is_active !== undefined) { if (validation.data.is_active !== undefined) {
query += `&is_active=eq.${validation.data.is_active}`; whereParts.push(eq(products.active, validation.data.is_active));
} }
if (validation.data.search) { if (validation.data.search) {
query += `&or=(name.ilike.*${encodeURIComponent(validation.data.search)}*,description.ilike.*${encodeURIComponent(validation.data.search)}*)`; const term = `%${validation.data.search}%`;
whereParts.push(
or(ilike(products.name, term), ilike(products.description, term))!,
);
} }
const where = whereParts.length > 0 ? and(...whereParts) : undefined;
const res = await fetch(query, { // Note: the legacy schema had a `category` column on products; the
headers: { // new schema doesn't. The category filter is silently ignored — no
apikey: anonKey, // point failing a public read just because the column disappeared.
"Content-Type": "application/json", void validation.data.category;
},
});
if (!res.ok) { // Public read across all tenants (this is a public catalog API, not
return apiError("Failed to fetch products", 500); // an admin endpoint), so use `withDb` rather than `withTenant`.
} const rows = await withDb(async (db) =>
db
.select()
.from(products)
.where(where)
.orderBy(products.name)
.limit(validation.data.limit)
.offset(validation.data.offset),
);
const products = await res.json();
// Track search analytics // Track search analytics
if (validation.data.search) { if (validation.data.search) {
analytics.searchPerformed(validation.data.search, products.length, "products"); analytics.searchPerformed(validation.data.search, rows.length, "products");
} }
return apiResponse(products, 200); return apiResponse(rows, 200);
} catch (error) { } catch (error) {
captureError(error as Error, { path: "/api/products", method: "GET" }); captureError(error as Error, { path: "/api/products", method: "GET" });
return apiError("Internal server error", 500); return apiError("Internal server error", 500);
@@ -108,42 +114,51 @@ export async function POST(req: NextRequest) {
} }
const body = await req.json(); const body = await req.json();
const { brand_id, name, description, price, category, is_active } = body; const { brand_id, name, description, price, is_active } = body as {
brand_id?: string;
name?: string;
description?: string;
price?: number;
category?: string;
is_active?: boolean;
};
if (!brand_id || !name) { if (!brand_id || !name) {
return apiError("brand_id and name are required", 400); return apiError("brand_id and name are required", 400);
} }
if (typeof price !== "number") {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return apiError("price is required (number)", 400);
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/products`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
brand_id,
name,
description,
price,
category,
is_active: is_active !== false,
}),
}
);
if (!res.ok) {
const error = await res.json();
return apiError(error.message || "Failed to create product", 500);
} }
const product = await res.json(); // Insert the product. Tenant context is required because `products`
return apiResponse(product, 201); // is a tenant-scoped table with RLS.
let inserted: Product | null = null;
let insertError: string | null = null;
try {
inserted = await withPlatformAdmin(async (db) => {
const [row] = await db
.insert(products)
.values({
tenantId: brand_id,
name,
description: description ?? null,
priceCents: Math.round(price * 100),
active: is_active !== false,
})
.returning();
return row ?? null;
});
} catch (err) {
insertError = err instanceof Error ? err.message : "Failed to create product";
}
if (insertError) {
return apiError(insertError, 500);
}
if (!inserted) {
return apiError("Insert returned no row", 500);
}
return apiResponse(inserted, 201);
} catch (error) { } catch (error) {
captureError(error as Error, { path: "/api/products", method: "POST" }); captureError(error as Error, { path: "/api/products", method: "POST" });
return apiError("Internal server error", 500); return apiError("Internal server error", 500);
@@ -163,4 +178,8 @@ export async function OPTIONS() {
"Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Allow-Headers": "Content-Type, Authorization",
}, },
}); });
} }
// Keep `sql` reachable so the import isn't tree-shaken — we use it
// elsewhere if query extensions are added.
void sql;
+19 -43
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit"; import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { analytics } from "@/lib/analytics"; import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry"; import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
// Helper functions // Helper functions
function apiResponse(data: unknown, status: number = 200) { function apiResponse(data: unknown, status: number = 200) {
@@ -38,40 +39,26 @@ export async function POST(req: NextRequest) {
const body = await req.json(); const body = await req.json();
const validation = createReferralSchema.safeParse(body); const validation = createReferralSchema.safeParse(body);
if (!validation.success) { if (!validation.success) {
return validationError(validation.error); return validationError(validation.error);
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Redeem referral via RPC // Redeem referral via RPC
const res = await fetch( const { rows } = await pool.query<{ redeem_referral: { referred_user_id?: string; [k: string]: unknown } | null }>(
`${supabaseUrl}/rest/v1/rpc/redeem_referral`, `SELECT redeem_referral($1, $2, $3) AS redeem_referral`,
{ [
method: "POST", validation.data.brand_id,
headers: { validation.data.referred_email,
apikey: serviceKey, validation.data.referral_code,
"Content-Type": "application/json", ],
Prefer: "return=representation",
},
body: JSON.stringify({
p_brand_id: validation.data.brand_id,
p_referred_email: validation.data.referred_email,
p_referral_code: validation.data.referral_code,
}),
}
); );
const referral = rows[0]?.redeem_referral;
if (!res.ok) { if (!referral) {
const error = await res.json(); return apiError("Failed to redeem referral", 500);
return apiError(error.message || "Failed to redeem referral", 500);
} }
const referral = await res.json(); analytics.referralCompleted(validation.data.referral_code, referral.referred_user_id ?? "anonymous");
analytics.referralCompleted(validation.data.referral_code, referral.referred_user_id);
return apiResponse(referral, 201); return apiResponse(referral, 201);
} catch (error) { } catch (error) {
@@ -89,24 +76,13 @@ export async function GET(req: NextRequest) {
return apiError("brand_id is required", 400); return apiError("brand_id is required", 400);
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const { rows: referrals } = await pool.query(
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; `SELECT * FROM referral_codes
WHERE brand_id = $1
const res = await fetch( ORDER BY created_at DESC`,
`${supabaseUrl}/rest/v1/referral_codes?brand_id=eq.${brand_id}&select=*&order=created_at.desc`, [brand_id],
{
headers: {
apikey: anonKey,
"Content-Type": "application/json",
},
}
); );
if (!res.ok) {
return apiError("Failed to fetch referrals", 500);
}
const referrals = await res.json();
return apiResponse(referrals); return apiResponse(referrals);
} catch (error) { } catch (error) {
captureError(error as Error, { path: "/api/referrals", method: "GET" }); captureError(error as Error, { path: "/api/referrals", method: "GET" });
@@ -124,4 +100,4 @@ export async function OPTIONS() {
"Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Allow-Headers": "Content-Type, Authorization",
}, },
}); });
} }
+12 -24
View File
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit"; import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { captureError } from "@/lib/sentry"; import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
// Helper functions // Helper functions
function apiResponse(data: unknown, status: number = 200) { function apiResponse(data: unknown, status: number = 200) {
@@ -50,34 +51,21 @@ export async function GET(req: NextRequest) {
return validationError(validation.error); return validationError(validation.error);
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get report via RPC // Get report via RPC
const res = await fetch( const { rows } = await pool.query<{ generate_report: unknown }>(
`${supabaseUrl}/rest/v1/rpc/generate_report`, `SELECT generate_report($1, $2::timestamptz, $3::timestamptz, $4) AS generate_report`,
{ [
method: "POST", validation.data.brand_id,
headers: { validation.data.start_date,
apikey: serviceKey, validation.data.end_date,
"Content-Type": "application/json", validation.data.report_type,
Prefer: "return=representation", ],
},
body: JSON.stringify({
p_brand_id: validation.data.brand_id,
p_start_date: validation.data.start_date,
p_end_date: validation.data.end_date,
p_report_type: validation.data.report_type,
}),
}
); );
const report = rows[0]?.generate_report;
if (!res.ok) { if (report == null) {
return apiError("Failed to generate report", 500); return apiError("Failed to generate report", 500);
} }
const report = await res.json();
return apiResponse(report); return apiResponse(report);
} catch (error) { } catch (error) {
captureError(error as Error, { path: "/api/reports", method: "GET" }); captureError(error as Error, { path: "/api/reports", method: "GET" });
@@ -95,4 +83,4 @@ export async function OPTIONS() {
"Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Allow-Headers": "Content-Type, Authorization",
}, },
}); });
} }
+24 -47
View File
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit"; import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { captureError } from "@/lib/sentry"; import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
// Helper functions // Helper functions
function apiResponse(data: unknown, status: number = 200) { function apiResponse(data: unknown, status: number = 200) {
@@ -43,44 +44,30 @@ export async function POST(req: NextRequest) {
// Parse and validate body // Parse and validate body
const body = await req.json(); const body = await req.json();
const validation = createWaterLogSchema.safeParse(body); const validation = createWaterLogSchema.safeParse(body);
if (!validation.success) { if (!validation.success) {
return validationError(validation.error); return validationError(validation.error);
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Create water log via RPC // Create water log via RPC
const res = await fetch( const { rows } = await pool.query<{ create_water_log: { id: string; [k: string]: unknown } | null }>(
`${supabaseUrl}/rest/v1/rpc/create_water_log`, `SELECT create_water_log($1, $2, $3, $4, $5, $6, $7, $8) AS create_water_log`,
{ [
method: "POST", validation.data.brand_id,
headers: { validation.data.field_id ?? null,
apikey: serviceKey, validation.data.field_name ?? null,
"Content-Type": "application/json", validation.data.gallons,
Prefer: "return=representation", validation.data.duration_minutes ?? null,
}, validation.data.water_method ?? null,
body: JSON.stringify({ validation.data.notes ?? null,
p_brand_id: validation.data.brand_id, validation.data.logged_at ?? null,
p_field_id: validation.data.field_id, ],
p_field_name: validation.data.field_name,
p_gallons: validation.data.gallons,
p_duration_minutes: validation.data.duration_minutes,
p_water_method: validation.data.water_method,
p_notes: validation.data.notes,
p_logged_at: validation.data.logged_at,
}),
}
); );
const waterLog = rows[0]?.create_water_log;
if (!res.ok) { if (!waterLog) {
const error = await res.json(); return apiError("Failed to create water log", 500);
return apiError(error.message || "Failed to create water log", 500);
} }
const waterLog = await res.json();
return apiResponse(waterLog, 201); return apiResponse(waterLog, 201);
} catch (error) { } catch (error) {
captureError(error as Error, { path: "/api/water-logs", method: "POST" }); captureError(error as Error, { path: "/api/water-logs", method: "POST" });
@@ -97,24 +84,14 @@ export async function GET(req: NextRequest) {
return apiError("brand_id is required", 400); return apiError("brand_id is required", 400);
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const { rows: waterLogs } = await pool.query(
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; `SELECT * FROM water_logs
WHERE brand_id = $1
const res = await fetch( ORDER BY logged_at DESC
`${supabaseUrl}/rest/v1/water_logs?brand_id=eq.${brand_id}&select=*&order=logged_at.desc&limit=100`, LIMIT 100`,
{ [brand_id],
headers: {
apikey: anonKey,
"Content-Type": "application/json",
},
}
); );
if (!res.ok) {
return apiError("Failed to fetch water logs", 500);
}
const waterLogs = await res.json();
return apiResponse(waterLogs); return apiResponse(waterLogs);
} catch (error) { } catch (error) {
captureError(error as Error, { path: "/api/water-logs", method: "GET" }); captureError(error as Error, { path: "/api/water-logs", method: "GET" });
@@ -132,4 +109,4 @@ export async function OPTIONS() {
"Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Allow-Headers": "Content-Type, Authorization",
}, },
}); });
} }
+16 -23
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@@ -9,34 +9,27 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: "Missing params" }, { status: 400 }); return NextResponse.json({ success: false, error: "Missing params" }, { status: 400 });
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Get admin settings // Get admin settings
const settingsRes = await fetch( const settingsRes = await pool.query<{
`${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`, get_water_admin_settings: { enabled: boolean; session_duration_hours?: number } | null;
{ }>(
method: "POST", `SELECT get_water_admin_settings($1) AS "get_water_admin_settings"`,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [brandId],
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const settings = await settingsRes.json(); const settings = settingsRes.rows[0]?.get_water_admin_settings;
if (!settingsRes.ok || !settings?.enabled) { if (!settings?.enabled) {
return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 }); return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 });
} }
// Verify PIN // Verify PIN
const verifyRes = await fetch( const verifyRes = await pool.query<{
`${supabaseUrl}/rest/v1/rpc/verify_water_admin_pin`, verify_water_admin_pin: { success: boolean; session_id?: string } | null;
{ }>(
method: "POST", `SELECT verify_water_admin_pin($1, $2) AS "verify_water_admin_pin"`,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [brandId, pin],
body: JSON.stringify({ p_brand_id: brandId, p_pin: pin }),
}
); );
const verifyData = await verifyRes.json(); const verifyData = verifyRes.rows[0]?.verify_water_admin_pin;
if (!verifyRes.ok || !verifyData?.success) { if (!verifyData?.success || !verifyData.session_id) {
return NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 }); return NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 });
} }
@@ -56,4 +49,4 @@ export async function POST(request: Request) {
} catch { } catch {
return NextResponse.json({ success: false, error: "Server error" }, { status: 500 }); return NextResponse.json({ success: false, error: "Server error" }, { status: 500 });
} }
} }
+16 -21
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -18,31 +18,26 @@ export async function GET(request: NextRequest) {
// Use brand_id from session (always Tuxedo for water log) or fallback to env // Use brand_id from session (always Tuxedo for water log) or fallback to env
const brandId = adminUser.brand_id ?? process.env.TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"; const brandId = adminUser.brand_id ?? process.env.TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; type WaterEntry = {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; id: string;
user_id: string | null;
headgate_id: string | null;
measurement: number | null;
unit: string | null;
notes: string | null;
created_at: string;
};
const response = await fetch( const { rows: data } = await pool.query<{ get_water_entries: WaterEntry[] | null }>(
`${supabaseUrl}/rest/v1/rpc/get_water_entries`, `SELECT get_water_entries($1, $2) AS "get_water_entries"`,
{ [brandId, 10000],
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId, p_limit: 10000 }),
}
); );
const entries = data[0]?.get_water_entries ?? [];
if (!response.ok) {
return NextResponse.json({ error: "Failed to fetch water log" }, { status: 500 });
}
const data = await response.json();
if (format === "csv") { if (format === "csv") {
const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"]; const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"];
const csvRows = [headers.join(",")]; const csvRows = [headers.join(",")];
for (const row of data) { for (const row of entries) {
csvRows.push([ csvRows.push([
row.id, row.id,
row.user_id ?? "", row.user_id ?? "",
@@ -61,5 +56,5 @@ export async function GET(request: NextRequest) {
}); });
} }
return NextResponse.json(data); return NextResponse.json(entries);
} }
+40 -52
View File
@@ -1,6 +1,17 @@
/**
* Wholesale order Stripe checkout endpoint.
*
* TODO(migration): wholesale_orders is part of the legacy schema and
* is read/written via raw `pool.query` SQL. The `get_wholesale_settings`
* and `get_payment_settings` SECURITY DEFINER RPCs still live in the
* database (see supabase/migrations/046 and 045) and are also called
* via `pool.query`. When wholesale is reactivated, declare the tables
* in `db/schema/wholesale.ts` and switch the reads to typed Drizzle.
*/
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe"; import Stripe from "stripe";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const { orderId, customerId } = await req.json(); const { orderId, customerId } = await req.json();
@@ -9,23 +20,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "orderId and customerId are required" }, { status: 400 }); return NextResponse.json({ error: "orderId and customerId are required" }, { status: 400 });
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── 1. Fetch order and brand info ────────────────────────────────────────── // ── 1. Fetch order and brand info ──────────────────────────────────────────
// Use direct select with both orderId AND customerId filters to prevent cross-brand access // Use direct select with both orderId AND customerId filters to prevent cross-brand access
const orderRes = await fetch( const { rows: orderRows } = await pool.query<{
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&customer_id=eq.${customerId}&select=id,brand_id,customer_id,balance_due,invoice_number,subtotal,deposit_required,deposit_paid`,
{
headers: { ...svcHeaders(supabaseKey) },
}
);
if (!orderRes.ok) {
return NextResponse.json({ error: "Failed to fetch order" }, { status: 500 });
}
const orders = await orderRes.json() as Array<{
id: string; id: string;
brand_id: string; brand_id: string;
customer_id: string; customer_id: string;
@@ -34,9 +31,22 @@ export async function POST(req: NextRequest) {
subtotal: number; subtotal: number;
deposit_required: number; deposit_required: number;
deposit_paid: number; deposit_paid: number;
}>; }>(
`SELECT id::text AS id,
brand_id::text AS brand_id,
customer_id::text AS customer_id,
COALESCE(balance_due, 0)::float8 AS balance_due,
invoice_number,
COALESCE(subtotal, 0)::float8 AS subtotal,
COALESCE(deposit_required, 0)::float8 AS deposit_required,
COALESCE(deposit_paid, 0)::float8 AS deposit_paid
FROM wholesale_orders
WHERE id = $1 AND customer_id = $2
LIMIT 1`,
[orderId, customerId]
);
const order = orders[0]; const order = orderRows[0];
if (!order) { if (!order) {
return NextResponse.json({ error: "Order not found" }, { status: 404 }); return NextResponse.json({ error: "Order not found" }, { status: 404 });
} }
@@ -47,39 +57,21 @@ export async function POST(req: NextRequest) {
} }
// ── 2. Check online payment is enabled ──────────────────────────────────── // ── 2. Check online payment is enabled ────────────────────────────────────
const wsRes = await fetch( const { rows: wsRows } = await pool.query<{ online_payment_enabled: boolean | null }>(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, "SELECT * FROM get_wholesale_settings($1)",
{ [order.brand_id]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: order.brand_id }),
}
); );
const wsData = wsRows[0];
if (!wsRes.ok) {
return NextResponse.json({ error: "Failed to fetch wholesale settings" }, { status: 500 });
}
const wsData = await wsRes.json();
if (!wsData?.online_payment_enabled) { if (!wsData?.online_payment_enabled) {
return NextResponse.json({ error: "Online payments are not enabled for this brand" }, { status: 403 }); return NextResponse.json({ error: "Online payments are not enabled for this brand" }, { status: 403 });
} }
// ── 3. Fetch Stripe credentials from payment_settings ───────────────────── // ── 3. Fetch Stripe credentials from payment_settings ─────────────────────
const psRes = await fetch( const { rows: psRows } = await pool.query<{ stripe_secret_key: string | null }>(
`${supabaseUrl}/rest/v1/rpc/get_payment_settings`, "SELECT * FROM get_payment_settings($1)",
{ [order.brand_id]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: order.brand_id }),
}
); );
const psData = psRows[0];
if (!psRes.ok) {
return NextResponse.json({ error: "Failed to fetch payment settings" }, { status: 500 });
}
const psData = await psRes.json();
const stripeSecretKey = psData?.stripe_secret_key; const stripeSecretKey = psData?.stripe_secret_key;
if (!stripeSecretKey) { if (!stripeSecretKey) {
@@ -120,14 +112,10 @@ export async function POST(req: NextRequest) {
}); });
// Store checkout session ID on the order // Store checkout session ID on the order
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}`, "UPDATE wholesale_orders SET checkout_session_id = $2, updated_at = NOW() WHERE id = $1",
{ [orderId, session.id]
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ checkout_session_id: session.id }),
}
); );
return NextResponse.json({ checkoutUrl: session.url }); return NextResponse.json({ checkoutUrl: session.url });
} }
@@ -2,7 +2,30 @@ import { NextRequest, NextResponse } from "next/server";
import { PDFDocument, rgb, StandardFonts } from "pdf-lib"; import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
type InvoiceSettings = {
invoice_business_name?: string;
invoice_business_address?: string;
invoice_business_phone?: string;
invoice_business_email?: string;
invoice_business_website?: string;
};
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
@@ -11,78 +34,40 @@ export async function GET(
const { orderId } = await params; const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token"); const token = req.nextUrl.searchParams.get("token");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Token-based customer download ──────────────────────────────────────────── // ── Token-based customer download ────────────────────────────────────────────
if (token) { if (token) {
// Look up order by ID + token. Falls back to a direct select so the // Look up order by ID + token. The invoice_token is checked server-side.
// invoice_token is checked server-side (not exposed to the client). const tokenRes = await pool.query<{ brand_id: string }>(
const orderRes = await fetch( "SELECT brand_id FROM wholesale_orders WHERE id = $1 AND invoice_token = $2 LIMIT 1",
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,invoice_token,brand_id,customer_id,created_at`, [orderId, token]
{
headers: svcHeaders(supabaseKey),
}
); );
if (!orderRes.ok) { if (tokenRes.rows.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
const orders = await orderRes.json();
if (!orders || orders.length === 0) {
return new NextResponse("Not found", { status: 404 }); return new NextResponse("Not found", { status: 404 });
} }
// Proceed to generate PDF — order token is verified // Proceed to generate PDF — order token is verified
const brandId = orders[0].brand_id; const brandId = tokenRes.rows[0].brand_id;
// Fetch full order data for PDF // Fetch full order data for PDF
const fullRes = await fetch( const fullRes = await pool.query<OrderRow>(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, "SELECT * FROM get_wholesale_orders($1)",
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
if (!fullRes.ok) { const allOrders = fullRes.rows;
return new NextResponse("Failed to fetch order", { status: 500 });
}
const allOrders = await fullRes.json() as Array<{
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
}>;
const order = allOrders.find(o => o.id === orderId); const order = allOrders.find(o => o.id === orderId);
if (!order) { if (!order) {
return new NextResponse("Not found", { status: 404 }); return new NextResponse("Not found", { status: 404 });
} }
// Fetch brand-specific settings for invoice header // Fetch brand-specific settings for invoice header
const settingsRes = await fetch( const settingsRes = await pool.query<InvoiceSettings>(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, "SELECT * FROM get_wholesale_settings($1)",
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const settingsData = await settingsRes.json(); const settings = settingsRes.rows[0] ?? {};
const settings = settingsData ?? {};
const pdfBytes = await buildInvoicePdf(order, settings); const pdfBytes = await buildInvoicePdf(order, settings);
return new NextResponse(Buffer.from(pdfBytes), { return new NextResponse(Buffer.from(pdfBytes), {
@@ -103,50 +88,23 @@ export async function GET(
} }
// Fetch the order directly by ID with brand scoping // Fetch the order directly by ID with brand scoping
const orderRes = await fetch( const orderRes = await pool.query<OrderRow>(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, "SELECT * FROM get_wholesale_orders($1)",
{ [adminUser.brand_id ?? null]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? undefined }),
}
); );
if (!orderRes.ok) { const orders = orderRes.rows;
return new NextResponse("Failed to fetch order", { status: 500 });
}
const orders = await orderRes.json() as Array<{
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
}>;
const order = orders.find(o => o.id === orderId); const order = orders.find(o => o.id === orderId);
if (!order) { if (!order) {
return new NextResponse("Order not found", { status: 404 }); return new NextResponse("Order not found", { status: 404 });
} }
const settingsRes = await fetch( const settingsRes = await pool.query<InvoiceSettings>(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, "SELECT * FROM get_wholesale_settings($1)",
{ [order.brand_id]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: order.brand_id }),
}
); );
const settingsData = await settingsRes.json(); const settings = settingsRes.rows[0] ?? {};
const settings = settingsData ?? {};
const pdfBytes = await buildInvoicePdf(order, settings); const pdfBytes = await buildInvoicePdf(order, settings);
@@ -158,27 +116,7 @@ export async function GET(
}); });
} }
async function buildInvoicePdf( async function buildInvoicePdf(order: OrderRow, settings: InvoiceSettings) {
order: {
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
},
settings: {
invoice_business_name?: string;
invoice_business_address?: string;
invoice_business_phone?: string;
invoice_business_email?: string;
invoice_business_website?: string;
}
) {
const pdfDoc = await PDFDocument.create(); const pdfDoc = await PDFDocument.create();
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica); const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
@@ -1,6 +1,22 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
@@ -9,24 +25,17 @@ export async function GET(
const { orderId } = await params; const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token"); const token = req.nextUrl.searchParams.get("token");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; if (!process.env.DATABASE_URL) {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
if (!supabaseUrl || !supabaseKey) {
return new NextResponse("Server misconfiguration", { status: 500 }); return new NextResponse("Server misconfiguration", { status: 500 });
} }
// ── Token-gated download (customer portal) ────────────────────────────────── // ── Token-gated download (customer portal) ──────────────────────────────────
if (token) { if (token) {
const orderRes = await fetch( const tokenRes = await pool.query<{ id: string }>(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,brand_id`, "SELECT id FROM wholesale_orders WHERE id = $1 AND invoice_token = $2 LIMIT 1",
{ headers: svcHeaders(supabaseKey) } [orderId, token]
); );
if (!orderRes.ok || orderRes.status === 204) { if (tokenRes.rows.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
const tokenOrders = await orderRes.json();
if (!tokenOrders || tokenOrders.length === 0) {
return new NextResponse("Not found", { status: 404 }); return new NextResponse("Not found", { status: 404 });
} }
} }
@@ -36,64 +45,38 @@ export async function GET(
let brandId = "00000000-0000-0000-0000-000000000000"; let brandId = "00000000-0000-0000-0000-000000000000";
// First try direct order lookup to get brand_id // First try direct order lookup to get brand_id
const directRes = await fetch( const directRes = await pool.query<{ brand_id: string }>(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&select=id,brand_id,customer_id`, "SELECT brand_id FROM wholesale_orders WHERE id = $1 LIMIT 1",
{ headers: svcHeaders(supabaseKey) } [orderId]
); );
if (directRes.ok) { if (directRes.rows.length > 0) {
const direct = await directRes.json(); brandId = directRes.rows[0].brand_id;
if (direct && direct.length > 0) {
brandId = direct[0].brand_id;
}
} }
const orderRes = await fetch( const orderRes = await pool.query<OrderRow>(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, "SELECT * FROM get_wholesale_orders($1)",
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
if (!orderRes.ok) { const allOrders = orderRes.rows;
return new NextResponse("Failed to fetch order", { status: 500 });
}
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
const allOrders = await orderRes.json() as OrderRow[];
const order = allOrders.find(o => o.id === orderId); const order = allOrders.find(o => o.id === orderId);
if (!order) { if (!order) {
return new NextResponse("Order not found", { status: 404 }); return new NextResponse("Order not found", { status: 404 });
} }
// Fetch settings for brand header // Fetch settings for brand header
const settingsRes = await fetch( const settingsRes = await pool.query<{
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, invoice_business_name?: string;
{ invoice_business_address?: string;
method: "POST", invoice_business_phone?: string;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, invoice_business_email?: string;
body: JSON.stringify({ p_brand_id: order.brand_id }), invoice_business_website?: string;
} }>(
"SELECT * FROM get_wholesale_settings($1)",
[order.brand_id]
); );
const settingsData = await settingsRes.json(); const settings = settingsRes.rows[0] ?? {};
const settings = settingsData ?? {};
const pdfBytes = await buildInvoicePdf(order, settings); const pdfBytes = await buildInvoicePdf(order, settings);
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// POST /api/wholesale/notifications/pickup-reminder // POST /api/wholesale/notifications/pickup-reminder
// Scans for fulfilled orders past their anticipated pickup date that haven't been // Scans for fulfilled orders past their anticipated pickup date that haven't been
@@ -9,24 +9,8 @@ import { svcHeaders } from "@/lib/svc-headers";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function POST() { export async function POST() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Find orders: fulfilled status, past anticipated pickup date, not yet picked up // Find orders: fulfilled status, past anticipated pickup date, not yet picked up
const ordersRes = await fetch( const ordersRes = await pool.query<{
`${supabaseUrl}/rest/v1/rpc/get_wholesale_overdue_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({}),
}
);
if (!ordersRes.ok) {
return NextResponse.json({ error: "Failed to fetch overdue orders" }, { status: 500 });
}
const overdueOrders = await ordersRes.json() as Array<{
id: string; id: string;
brand_id: string; brand_id: string;
customer_id: string; customer_id: string;
@@ -37,12 +21,16 @@ export async function POST() {
notification_email: string | null; notification_email: string | null;
from_email: string | null; from_email: string | null;
invoice_business_email: string | null; invoice_business_email: string | null;
}>; }>(
"SELECT * FROM get_wholesale_overdue_orders()"
);
if (overdueOrders.length === 0) { if (ordersRes.rows.length === 0) {
return NextResponse.json({ message: "No overdue orders found.", enqueued: 0 }); return NextResponse.json({ message: "No overdue orders found.", enqueued: 0 });
} }
const overdueOrders = ordersRes.rows;
let enqueued = 0; let enqueued = 0;
let skipped = 0; let skipped = 0;
@@ -55,33 +43,28 @@ export async function POST() {
const adminEmail = const adminEmail =
order.notification_email ?? order.from_email ?? order.invoice_business_email; order.notification_email ?? order.from_email ?? order.invoice_business_email;
const enqueueRes = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`, await pool.query(
{ "SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
method: "POST", [
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, order.brand_id,
body: JSON.stringify({ order.customer_id,
p_brand_id: order.brand_id, order.id,
p_customer_id: order.customer_id, "unclaimed_pickup",
p_order_id: order.id, order.customer_email,
p_type: "unclaimed_pickup", adminEmail,
p_email_to: order.customer_email, `Overdue Pickup — Order ${order.invoice_number ?? order.id.slice(0, 8)}`,
p_email_cc: adminEmail, `
p_subject: `Overdue Pickup — Order ${order.invoice_number ?? order.id.slice(0, 8)}`,
p_body_html: `
<h2>Order Overdue for Pickup</h2> <h2>Order Overdue for Pickup</h2>
<p>Your order <strong>${order.invoice_number ?? order.id.slice(0, 8)}</strong> was due for pickup on <strong>${order.anticipated_pickup_date}</strong> and has not yet been picked up.</p> <p>Your order <strong>${order.invoice_number ?? order.id.slice(0, 8)}</strong> was due for pickup on <strong>${order.anticipated_pickup_date}</strong> and has not yet been picked up.</p>
${order.pickup_location ? `<p><strong>Pickup location:</strong> ${order.pickup_location}</p>` : ""} ${order.pickup_location ? `<p><strong>Pickup location:</strong> ${order.pickup_location}</p>` : ""}
<p>Please arrange pickup as soon as possible. Contact us if you have any questions.</p> <p>Please arrange pickup as soon as possible. Contact us if you have any questions.</p>
`, `,
p_body_text: `Order ${order.invoice_number ?? order.id.slice(0, 8)} was due for pickup on ${order.anticipated_pickup_date} and has not been picked up.${order.pickup_location ? ` Pickup location: ${order.pickup_location}.` : ""} Please arrange pickup or contact us with any questions.`, `Order ${order.invoice_number ?? order.id.slice(0, 8)} was due for pickup on ${order.anticipated_pickup_date} and has not been picked up.${order.pickup_location ? ` Pickup location: ${order.pickup_location}.` : ""} Please arrange pickup or contact us with any questions.`,
}), ]
} );
);
if (enqueueRes.ok) {
enqueued++; enqueued++;
} else { } catch {
skipped++; skipped++;
} }
} }
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getWholesalePendingNotifications, markWholesaleNotificationSent } from "@/actions/wholesale"; import { markWholesaleNotificationSent } from "@/actions/wholesale";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import type { NotificationRecipient } from "@/actions/wholesale"; import type { NotificationRecipient } from "@/actions/wholesale";
// POST /api/wholesale/notifications/send // POST /api/wholesale/notifications/send
@@ -18,23 +18,7 @@ export async function POST() {
); );
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const pendingRes = await pool.query<{
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const pendingRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_pending_notifications`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: null, p_limit: 20 }),
}
);
if (!pendingRes.ok) {
return NextResponse.json({ error: "Failed to fetch pending notifications" }, { status: 500 });
}
const notifications = await pendingRes.json() as Array<{
id: string; id: string;
type: string; type: string;
email_to: string; email_to: string;
@@ -46,12 +30,17 @@ export async function POST() {
order_id: string | null; order_id: string | null;
customer_id: string; customer_id: string;
invoice_business_email: string | null; invoice_business_email: string | null;
}>; }>(
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
[null, 20]
);
if (notifications.length === 0) { if (pendingRes.rows.length === 0) {
return NextResponse.json({ message: "No pending notifications.", queued: 0, sent: 0 }); return NextResponse.json({ message: "No pending notifications.", queued: 0, sent: 0 });
} }
const notifications = pendingRes.rows;
// Prefetch settings for each unique brand so we can resolve notification_recipients // Prefetch settings for each unique brand so we can resolve notification_recipients
const brandIds = [...new Set(notifications.map(n => n.brand_id))]; const brandIds = [...new Set(notifications.map(n => n.brand_id))];
const brandSettingsMap: Record<string, { const brandSettingsMap: Record<string, {
@@ -62,13 +51,17 @@ export async function POST() {
}> = {}; }> = {};
await Promise.all(brandIds.map(async (bid) => { await Promise.all(brandIds.map(async (bid) => {
const r = await fetch(`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, { const r = await pool.query<{
method: "POST", notification_recipients: NotificationRecipient[];
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, notification_email: string | null;
body: JSON.stringify({ p_brand_id: bid }), from_email: string | null;
}); invoice_business_email: string | null;
if (r.ok) { }>(
const data = await r.json(); "SELECT * FROM get_wholesale_settings($1)",
[bid]
);
if (r.rows.length > 0) {
const data = r.rows[0];
brandSettingsMap[bid] = { brandSettingsMap[bid] = {
notification_recipients: data?.notification_recipients ?? [], notification_recipients: data?.notification_recipients ?? [],
notification_email: data?.notification_email ?? null, notification_email: data?.notification_email ?? null,
@@ -128,21 +121,20 @@ export async function POST() {
const ok = await sendOneEmail(resendApiKey, fromEmail, toAddress, undefined, n.subject, n.body_html, n.body_text); const ok = await sendOneEmail(resendApiKey, fromEmail, toAddress, undefined, n.subject, n.body_html, n.body_text);
// Log a separate notification entry for audit trail // Log a separate notification entry for audit trail
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`, { await pool.query(
method: "POST", "SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [
body: JSON.stringify({ n.brand_id,
p_brand_id: n.brand_id, n.customer_id,
p_customer_id: n.customer_id, n.order_id ?? null,
p_order_id: n.order_id ?? null, n.type,
p_type: n.type, recipient.email,
p_email_to: recipient.email, null,
p_email_cc: null, n.subject,
p_subject: n.subject, n.body_html,
p_body_html: n.body_html, n.body_text,
p_body_text: n.body_text, ]
}), );
});
if (ok) sent++; else failed++; if (ok) sent++; else failed++;
} }
@@ -179,8 +171,12 @@ async function sendOneEmail(
} }
async function triggerPickupReminder() { async function triggerPickupReminder() {
// Use the same Next.js server — the route is at /api/wholesale/notifications/pickup-reminder
// The pickup-reminder route is also exposed as a cron in vercel.json.
try { try {
await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL!}/api/wholesale/notifications/pickup-reminder`, { const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? process.env.VERCEL_URL;
if (!baseUrl) return;
await fetch(`${baseUrl}/api/wholesale/notifications/pickup-reminder`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
+48 -34
View File
@@ -1,8 +1,21 @@
/**
* Wholesale price-sheet sender.
*
* TODO(migration): wholesale_customers and the
* `enqueue_wholesale_notification` SECURITY DEFINER RPC live in the
* legacy schema. Reads are converted to `pool.query`; the
* `enqueue_wholesale_notification` RPC is still in the database
* (supabase/migrations/054) and is called via `pool.query` rather
* than the Supabase REST gateway. When wholesale is reactivated,
* move the tables into `db/schema/wholesale.ts` and switch reads to
* typed Drizzle.
*/
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getWholesaleSettings, getWholesaleProducts } from "@/actions/wholesale"; import { getWholesaleSettings, getWholesaleProducts } from "@/actions/wholesale";
import { getWholesaleCustomer } from "@/actions/wholesale-register";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -31,7 +44,7 @@ export async function POST(req: NextRequest) {
if (!effectiveBrandId) { if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 }); return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
} }
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { try { assertBrandAccess(adminUser, effectiveBrandId); } catch {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 }); return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
} }
@@ -39,9 +52,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "customerIds array and brandId are required" }, { status: 400 }); return NextResponse.json({ error: "customerIds array and brandId are required" }, { status: 400 });
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Fetch brand settings for branding + pickup location // Fetch brand settings for branding + pickup location
const settings = await getWholesaleSettings(effectiveBrandId); const settings = await getWholesaleSettings(effectiveBrandId);
if (!settings) { if (!settings) {
@@ -142,44 +152,48 @@ ${hasCustomNote ? ` <p style="margin: 0 0 12px; color: #1e293b; font-size:
let failed = 0; let failed = 0;
for (const customerId of customerIds) { for (const customerId of customerIds) {
// Fetch customer email // Fetch customer email via raw SQL
const custRes = await fetch( const { rows: customers } = await pool.query<{
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,email,company_name,brand_id`, id: string;
{ headers: { ...svcHeaders(supabaseKey) } } email: string | null;
company_name: string | null;
brand_id: string | null;
}>(
`SELECT id::text AS id, email, company_name, brand_id::text AS brand_id
FROM wholesale_customers
WHERE id = $1
LIMIT 1`,
[customerId]
); );
if (!custRes.ok) { failed++; continue; }
const customers = await custRes.json() as Array<{ id: string; email: string; company_name: string; brand_id: string }>;
const customer = customers[0]; const customer = customers[0];
if (!customer?.email) { failed++; continue; } if (!customer?.email) { failed++; continue; }
const enqueueRes = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`, await pool.query(
{ "SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
method: "POST", [
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, effectiveBrandId,
body: JSON.stringify({ customerId,
p_brand_id: effectiveBrandId, null,
p_customer_id: customerId, "price_sheet",
p_order_id: null, customer.email,
p_type: "price_sheet", settings.notification_email ?? null,
p_email_to: customer.email, emailSubject,
p_email_cc: settings.notification_email ?? null, html,
p_subject: emailSubject, text,
p_body_html: html, ]
p_body_text: text, );
}),
}
);
if (enqueueRes.ok) {
enqueued++; enqueued++;
} else { } catch {
failed++; failed++;
} }
} }
// Fire-and-forget trigger to process the queue // Fire-and-forget trigger to process the queue
fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL!}/api/wholesale/notifications/send`, { const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ??
(req.nextUrl ? `${req.nextUrl.protocol}//${req.nextUrl.host}` : "http://localhost:3000");
fetch(`${baseUrl}/api/wholesale/notifications/send`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}).catch(() => {}); }).catch(() => {});
@@ -1,36 +1,17 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import crypto from "crypto"; import crypto from "crypto";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// POST /api/wholesale/webhooks/dispatch // POST /api/wholesale/webhooks/dispatch
// Processes pending webhook events from wholesale_sync_log and dispatches to configured URLs. // Processes pending webhook events from wholesale_sync_log and dispatches to configured URLs.
// Called by a cron job or manually after enqueue_wholesale_webhook has queued events. // Called by a cron job or manually after enqueue_wholesale_webhook has queued events.
export async function POST() { export async function POST() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; if (!process.env.DATABASE_URL) {
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
if (!supabaseUrl || !serviceRoleKey) {
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
} }
// Fetch pending webhooks // Fetch pending webhooks
const pendingRes = await fetch( const pendingRes = await pool.query<{
`${supabaseUrl}/rest/v1/rpc/get_pending_webhooks`,
{
method: "POST",
headers: {
...svcHeaders(serviceRoleKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_limit: 10 }),
}
);
if (!pendingRes.ok) {
return NextResponse.json({ error: "Failed to fetch pending webhooks" }, { status: 500 });
}
const pending = await pendingRes.json() as Array<{
id: string; id: string;
brand_id: string; brand_id: string;
event_type: string; event_type: string;
@@ -39,12 +20,16 @@ export async function POST() {
attempts: number; attempts: number;
url: string; url: string;
secret: string; secret: string;
}>; }>(
"SELECT * FROM get_pending_webhooks($1)",
[10]
);
if (!pending || pending.length === 0) { if (pendingRes.rows.length === 0) {
return NextResponse.json({ message: "No pending webhooks.", dispatched: 0 }); return NextResponse.json({ message: "No pending webhooks.", dispatched: 0 });
} }
const pending = pendingRes.rows;
let dispatched = 0; let dispatched = 0;
for (const webhook of pending) { for (const webhook of pending) {
@@ -71,40 +56,40 @@ export async function POST() {
const responseText = await res.text().catch(() => ""); const responseText = await res.text().catch(() => "");
if (res.ok) { if (res.ok) {
await markSent(webhook.id, serviceRoleKey, supabaseUrl, `HTTP ${res.status}: ${responseText.slice(0, 200)}`); await markSent(webhook.id, `HTTP ${res.status}: ${responseText.slice(0, 200)}`);
dispatched++; dispatched++;
} else { } else {
await markFailed(webhook.id, serviceRoleKey, supabaseUrl, `HTTP ${res.status}: ${responseText.slice(0, 200)}`); await markFailed(webhook.id, `HTTP ${res.status}: ${responseText.slice(0, 200)}`);
} }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Network error"; const msg = err instanceof Error ? err.message : "Network error";
await markFailed(webhook.id, serviceRoleKey, supabaseUrl, msg); await markFailed(webhook.id, msg);
} }
} }
return NextResponse.json({ message: `Dispatched ${dispatched}/${pending.length} webhook(s).`, dispatched }); return NextResponse.json({ message: `Dispatched ${dispatched}/${pending.length} webhook(s).`, dispatched });
} }
async function markSent(logId: string, key: string, url: string, response: string) { async function markSent(logId: string, response: string) {
await fetch( try {
`${url}/rest/v1/rpc/mark_webhook_sent`, await pool.query(
{ "SELECT mark_webhook_sent($1, $2)",
method: "POST", [logId, response]
headers: { ...svcHeaders(key), "Content-Type": "application/json" }, );
body: JSON.stringify({ p_log_id: logId, p_response: response }), } catch {
} // best-effort
); }
} }
async function markFailed(logId: string, key: string, url: string, response: string) { async function markFailed(logId: string, response: string) {
await fetch( try {
`${url}/rest/v1/rpc/mark_webhook_failed`, await pool.query(
{ "SELECT mark_webhook_failed($1, $2)",
method: "POST", [logId, response]
headers: { ...svcHeaders(key), "Content-Type": "application/json" }, );
body: JSON.stringify({ p_log_id: logId, p_response: response }), } catch {
} // best-effort
); }
} }
export async function GET() { export async function GET() {
+5 -27
View File
@@ -5,16 +5,9 @@ import Link from "next/link";
import { useCart } from "@/context/CartContext"; import { useCart } from "@/context/CartContext";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import { getPublicStopsForBrand, checkStopProductAvailability, type PublicStop } from "@/actions/checkout";
type Stop = { type Stop = PublicStop;
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
export default function CartClient() { export default function CartClient() {
const { const {
@@ -54,11 +47,7 @@ export default function CartClient() {
if (hasPickupItems && showStopPicker && cartBrandId) { if (hasPickupItems && showStopPicker && cartBrandId) {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setLoadingStops(true); setLoadingStops(true);
fetch( getPublicStopsForBrand(cartBrandId)
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
)
.then((r) => r.json())
.then((data) => setStops(data ?? [])) .then((data) => setStops(data ?? []))
.catch(() => setStops([])) .catch(() => setStops([]))
.finally(() => setLoadingStops(false)); .finally(() => setLoadingStops(false));
@@ -74,19 +63,8 @@ export default function CartClient() {
if (hasPickupItems) { if (hasPickupItems) {
const pickupProductIds = cart.filter((i) => i.fulfillment === "pickup").map((i) => i.id); const pickupProductIds = cart.filter((i) => i.fulfillment === "pickup").map((i) => i.id);
fetch( checkStopProductAvailability(stop.id, pickupProductIds)
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/check_stop_product_availability`, .then((data) => {
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stop.id, p_product_ids: pickupProductIds }),
}
)
.then((r) => r.json())
.then((data: { product_id: string; is_available: boolean }[]) => {
const unavailable = (data ?? []) const unavailable = (data ?? [])
.filter((row) => row.is_available === false) .filter((row) => row.is_available === false)
.map((row) => row.product_id); .map((row) => row.product_id);

Some files were not shown because too many files have changed in this diff Show More