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.
This commit is contained in:
@@ -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 { eq, desc } from "drizzle-orm";
|
||||
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) {
|
||||
const adminUser = await getAdminUser();
|
||||
@@ -13,47 +26,67 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
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) {
|
||||
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 (brandId) {
|
||||
try { assertBrandAccess(adminUser, brandId); } catch {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
);
|
||||
|
||||
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") {
|
||||
const rows = data.orders ?? [];
|
||||
const headers = ["id", "customer_name", "customer_email", "status", "subtotal", "created_at"];
|
||||
const headers = ["id", "customer_name", "customer_email", "status", "total_cents", "placed_at"];
|
||||
const csvRows = [headers.join(",")];
|
||||
for (const row of rows) {
|
||||
csvRows.push([
|
||||
row.id,
|
||||
`"${(row.customer_name ?? "").replace(/"/g, '""')}"`,
|
||||
row.customer_email ?? "",
|
||||
row.status ?? "",
|
||||
row.subtotal ?? 0,
|
||||
row.created_at ?? "",
|
||||
].join(","));
|
||||
for (const r of rows) {
|
||||
csvRows.push(
|
||||
[
|
||||
r.id,
|
||||
`"${(r.customerName ?? "").replace(/"/g, '""')}"`,
|
||||
r.customerEmail ?? "",
|
||||
r.status ?? "",
|
||||
r.totalCents ?? 0,
|
||||
r.placedAt instanceof Date ? r.placedAt.toISOString() : "",
|
||||
].join(","),
|
||||
);
|
||||
}
|
||||
return new NextResponse(csvRows.join("\n"), {
|
||||
headers: {
|
||||
@@ -63,5 +96,5 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
return NextResponse.json({ orders: rows });
|
||||
}
|
||||
|
||||
@@ -1,96 +1,10 @@
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
|
||||
|
||||
interface LotRow {
|
||||
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;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
// TODO(migration): the route-trace feature was retired from the SaaS rebuild.
|
||||
// The `harvest_lots` / `harvest_lot_events` tables and the
|
||||
// `get_harvest_lots` / `get_events_for_lots` / `get_harvest_lot_detail`
|
||||
// SECURITY DEFINER RPCs no longer exist in `db/schema/`. This route is
|
||||
// stubbed to return an empty compliance payload so the admin/trace UI
|
||||
// gracefully degrades. If route-trace comes back, re-introduce the
|
||||
// tables in `db/schema/` and replace this stub with a real Drizzle query.
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -99,106 +13,26 @@ export async function GET(request: Request) {
|
||||
const endDate = searchParams.get("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(
|
||||
JSON.stringify({
|
||||
lots: [],
|
||||
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" } }
|
||||
JSON.stringify({ error: "Missing brandId, startDate, or endDate" }),
|
||||
{ status: 400, 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(
|
||||
JSON.stringify({
|
||||
lots: complianceLots,
|
||||
summary,
|
||||
lots: [],
|
||||
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" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,9 @@
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
|
||||
|
||||
type LotRow = {
|
||||
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();
|
||||
}
|
||||
// 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
|
||||
// CSV report produced from the `get_harvest_lots` SECURITY DEFINER RPC,
|
||||
// which no longer exists. The stub returns a "feature not configured"
|
||||
// body so callers that link straight to this URL still get a meaningful
|
||||
// response (rather than a generic 500).
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -51,132 +12,17 @@ export async function GET(request: Request) {
|
||||
const endDate = searchParams.get("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
|
||||
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("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"`,
|
||||
return new Response(
|
||||
"Route-trace feature not configured — FSMA reports are unavailable in the SaaS rebuild.\n",
|
||||
{
|
||||
status: 501,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
function esc(values: string[]): string {
|
||||
return values.map((v) => `"${v.replace(/"/g, '""')}"`).join(",");
|
||||
}
|
||||
@@ -1,162 +1,45 @@
|
||||
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
||||
import QRCode from "qrcode";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
|
||||
|
||||
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;
|
||||
}
|
||||
// TODO(migration): the route-trace feature was retired from the SaaS rebuild.
|
||||
// `get_harvest_lot_detail` no longer exists in `db/schema/`. This route
|
||||
// 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
|
||||
// we return a tiny placeholder PDF. The `StickerPreviewModal` consumer
|
||||
// already handles the empty state.
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
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 });
|
||||
|
||||
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
|
||||
if (!lotId) {
|
||||
return new Response("Missing lotId", { status: 400 });
|
||||
}
|
||||
|
||||
for (let i = 0; i < copies; i++) {
|
||||
const page = pdfDoc.addPage([LABEL_W, LABEL_H * 2 + GAP]);
|
||||
const y = LABEL_H * 2 + GAP - (i + 1) * LABEL_H;
|
||||
|
||||
// White background for pure black-on-white thermal printing
|
||||
page.drawRectangle({ x: 0, y, width: LABEL_W, height: LABEL_H, color: WHITE });
|
||||
|
||||
const LEFT = 10;
|
||||
const RIGHT_X = LABEL_W - QR_SIZE - 6;
|
||||
const TOP = y + LABEL_H - 8;
|
||||
const QR_Y = y + 6;
|
||||
|
||||
function drawLine(text: string, x: number, yPos: number, sz: number, isBold = false) {
|
||||
const f = isBold ? fontBold : font;
|
||||
page.drawText(text, { x, y: yPos, size: sz, font: f, color: BLACK });
|
||||
}
|
||||
|
||||
function yPos(row: number, baseSize: number) {
|
||||
return TOP - row * (baseSize + 2);
|
||||
}
|
||||
|
||||
const dataSize = size === "4x3" ? 8.5 : 7;
|
||||
const rowH = dataSize + 3;
|
||||
|
||||
// ─── Brand header ───────────────────────────────────────────
|
||||
drawLine("ROUTE TRACE", LEFT, yPos(0, 6), 6, true);
|
||||
|
||||
// ─── Lot number — dominant ───────────────────────────────────
|
||||
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"`,
|
||||
},
|
||||
// Minimal 1-page PDF saying the feature is retired. Hand-built so the
|
||||
// route doesn't need to instantiate pdf-lib at all (the route is
|
||||
// reached only by the StickerPreviewModal when route-trace data is
|
||||
// missing, which the SaaS rebuild always is).
|
||||
const pdfBody = `%PDF-1.4
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj
|
||||
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
|
||||
4 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
|
||||
5 0 obj<</Length 70>>stream
|
||||
BT /F1 10 Tf 10 70 Td (Route-trace feature not configured.) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000056 00000 n
|
||||
0000000104 00000 n
|
||||
0000000192 00000 n
|
||||
0000000253 00000 n
|
||||
trailer<</Size 6/Root 1 0 R>>
|
||||
startxref
|
||||
368
|
||||
%%EOF`;
|
||||
return new Response(pdfBody, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,206 +1,32 @@
|
||||
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
|
||||
|
||||
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();
|
||||
}
|
||||
// TODO(migration): the route-trace feature was retired from the SaaS rebuild.
|
||||
// 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
|
||||
// CSV body indicating the feature is unavailable so direct links from
|
||||
// the admin UI degrade gracefully.
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const lotId = searchParams.get("lotId");
|
||||
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 (!lot) return new Response("Lot not found", { status: 404 });
|
||||
|
||||
const orders = await getLotOrders(lotId);
|
||||
|
||||
if (format === "csv") {
|
||||
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"`,
|
||||
if (format === "pdf") {
|
||||
return new Response(
|
||||
"Route-trace feature not configured — PDF trace reports are unavailable.\n",
|
||||
{
|
||||
status: 501,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
// PDF report
|
||||
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);
|
||||
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"`,
|
||||
},
|
||||
// CSV — return a single header row that downstream parsers can detect.
|
||||
const csv = "error,message\nretired,Route-trace feature not configured\n";
|
||||
return new Response(csv, {
|
||||
status: 501,
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 Stripe from "stripe";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
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 });
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// ── 1. Fetch order and brand info ──────────────────────────────────────────
|
||||
// Use direct select with both orderId AND customerId filters to prevent cross-brand access
|
||||
const orderRes = await fetch(
|
||||
`${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<{
|
||||
const { rows: orderRows } = await pool.query<{
|
||||
id: string;
|
||||
brand_id: string;
|
||||
customer_id: string;
|
||||
@@ -34,9 +31,22 @@ export async function POST(req: NextRequest) {
|
||||
subtotal: number;
|
||||
deposit_required: 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) {
|
||||
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 ────────────────────────────────────
|
||||
const wsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: order.brand_id }),
|
||||
}
|
||||
const { rows: wsRows } = await pool.query<{ online_payment_enabled: boolean | null }>(
|
||||
"SELECT * FROM get_wholesale_settings($1)",
|
||||
[order.brand_id]
|
||||
);
|
||||
|
||||
if (!wsRes.ok) {
|
||||
return NextResponse.json({ error: "Failed to fetch wholesale settings" }, { status: 500 });
|
||||
}
|
||||
|
||||
const wsData = await wsRes.json();
|
||||
const wsData = wsRows[0];
|
||||
if (!wsData?.online_payment_enabled) {
|
||||
return NextResponse.json({ error: "Online payments are not enabled for this brand" }, { status: 403 });
|
||||
}
|
||||
|
||||
// ── 3. Fetch Stripe credentials from payment_settings ─────────────────────
|
||||
const psRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_payment_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: order.brand_id }),
|
||||
}
|
||||
const { rows: psRows } = await pool.query<{ stripe_secret_key: string | null }>(
|
||||
"SELECT * FROM get_payment_settings($1)",
|
||||
[order.brand_id]
|
||||
);
|
||||
|
||||
if (!psRes.ok) {
|
||||
return NextResponse.json({ error: "Failed to fetch payment settings" }, { status: 500 });
|
||||
}
|
||||
|
||||
const psData = await psRes.json();
|
||||
const psData = psRows[0];
|
||||
const stripeSecretKey = psData?.stripe_secret_key;
|
||||
|
||||
if (!stripeSecretKey) {
|
||||
@@ -120,14 +112,10 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
|
||||
// Store checkout session ID on the order
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ checkout_session_id: session.id }),
|
||||
}
|
||||
await pool.query(
|
||||
"UPDATE wholesale_orders SET checkout_session_id = $2, updated_at = NOW() WHERE id = $1",
|
||||
[orderId, session.id]
|
||||
);
|
||||
|
||||
return NextResponse.json({ checkoutUrl: session.url });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { getWholesaleSettings, getWholesaleProducts } from "@/actions/wholesale";
|
||||
import { getWholesaleCustomer } from "@/actions/wholesale-register";
|
||||
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";
|
||||
|
||||
@@ -31,7 +44,7 @@ export async function POST(req: NextRequest) {
|
||||
if (!effectiveBrandId) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -39,9 +52,6 @@ export async function POST(req: NextRequest) {
|
||||
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
|
||||
const settings = await getWholesaleSettings(effectiveBrandId);
|
||||
if (!settings) {
|
||||
@@ -142,44 +152,48 @@ ${hasCustomNote ? ` <p style="margin: 0 0 12px; color: #1e293b; font-size:
|
||||
let failed = 0;
|
||||
|
||||
for (const customerId of customerIds) {
|
||||
// Fetch customer email
|
||||
const custRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,email,company_name,brand_id`,
|
||||
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||
// Fetch customer email via raw SQL
|
||||
const { rows: customers } = await pool.query<{
|
||||
id: string;
|
||||
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];
|
||||
if (!customer?.email) { failed++; continue; }
|
||||
|
||||
const enqueueRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: effectiveBrandId,
|
||||
p_customer_id: customerId,
|
||||
p_order_id: null,
|
||||
p_type: "price_sheet",
|
||||
p_email_to: customer.email,
|
||||
p_email_cc: settings.notification_email ?? null,
|
||||
p_subject: emailSubject,
|
||||
p_body_html: html,
|
||||
p_body_text: text,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (enqueueRes.ok) {
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
[
|
||||
effectiveBrandId,
|
||||
customerId,
|
||||
null,
|
||||
"price_sheet",
|
||||
customer.email,
|
||||
settings.notification_email ?? null,
|
||||
emailSubject,
|
||||
html,
|
||||
text,
|
||||
]
|
||||
);
|
||||
enqueued++;
|
||||
} else {
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}).catch(() => {});
|
||||
|
||||
Reference in New Issue
Block a user