Initial commit - Route Commerce platform

This commit is contained in:
2026-06-01 19:40:55 +00:00
commit 53a9671461
617 changed files with 106132 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_messages) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { topic, brandId, brandName } = await req.json();
if (!topic) {
return NextResponse.json({ error: "topic is required" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client) {
return NextResponse.json({ error: "error" in aiResult ? aiResult.error : "AI not configured" }, { status: 503 });
}
const systemPrompt = `You are a campaign idea generator for Route Commerce, a B2B fresh produce wholesale platform.
Given a topic, generate exactly 3 campaign ideas for a produce brand.
Each idea should include: angle (one sentence describing the campaign approach), subject line (compelling, max 60 chars), body (3-4 sentences, persuasive, fits brand voice).
Return valid JSON: { "ideas": [{ "angle": "...", "subject": "...", "body": "..." }] }
Brand voice: friendly, transparent, emphasizes freshness and local origin.
Audience: wholesale produce buyers, restaurant owners, farm stand operators.`;
try {
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: `Topic: ${topic}\nBrand: ${brandName || "our produce brand"}` },
],
response_format: { type: "json_object" },
temperature: 0.8,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
const parsed = JSON.parse(content);
return NextResponse.json({ ideas: parsed.ideas ?? [] });
} catch (err) {
return NextResponse.json({ error: "Generation failed" }, { status: 500 });
}
}
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
import { svcHeaders } from "@/lib/svc-headers";
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.
Query types (each has a predefined SQL template):
1. "dormant" — customers who haven't ordered in X days (parameter: days, default 45)
2. "trending" — products ordered most in the last N days (parameter: days, default 30)
3. "top_customers" — top customers by order total in last N days (parameter: days, default 90)
4. "recent_orders" — orders created in last N days (parameter: days, default 14)
5. "at_risk" — customers who had orders in past 90 days but none in last 30 days
Return JSON with:
{
"queryType": "dormant" | "trending" | "top_customers" | "recent_orders" | "at_risk",
"days": number (or null for at_risk which always uses 30/90),
"explanation": "plain English explanation of what this query will return",
"fallback": "plain language result if AI analysis fails"
}`;
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_reports) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, nlQuery } = await req.json();
if (!brandId || !nlQuery) {
return NextResponse.json({ error: "Missing brandId or nlQuery" }, { status: 400 });
}
// Brand scoping: platform_admin passes explicit brandId; brand_admin limited to own brand
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const userMessage = `Brand: ${effectiveBrandId}
User asked: "${nlQuery}"
Classify this query and return JSON with queryType, days, explanation, and fallback.
Use "at_risk" for customers who had orders in past 90 days but none in last 30 days.
Use "dormant" for customers who haven't ordered in a while.
Use "trending" for product popularity questions.
Use "top_customers" for customer ranking questions.
Use "recent_orders" for recent order questions.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.2,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
const parsed = JSON.parse(content);
const queryType = parsed.queryType ?? "recent_orders";
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
let rpcName = "get_recent_orders_insights";
let rpcParams: Record<string, unknown> = { p_brand_id: effectiveBrandId, p_days: days };
if (queryType === "dormant") {
rpcName = "get_dormant_customers_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
} else if (queryType === "trending") {
rpcName = "get_trending_products_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
} else if (queryType === "top_customers") {
rpcName = "get_top_customers_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
} else if (queryType === "at_risk") {
rpcName = "get_at_risk_customers_insights";
rpcParams = { p_brand_id: 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[] = [];
if (dbResponse.ok) {
const data = await dbResponse.json();
results = Array.isArray(data) ? data.slice(0, 100) : [];
}
return NextResponse.json({
queryType,
explanation: parsed.explanation ?? "",
results,
count: results.length,
fallback: parsed.fallback ?? "",
nlQuery,
});
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
+82
View File
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_reports) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, productName, historicalData, stopName } = await req.json();
if (!brandId) {
return NextResponse.json({ error: "Missing brandId" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const SYSTEM = `You are a demand forecasting analyst for a B2B produce wholesale platform.
Given historical order data for a product or stop, predict future demand and recommend stock levels.
Return JSON with this exact shape:
{
"currentTrend": "description of recent demand pattern",
"prediction": {
"nextStopVolume": number,
"nextWeekVolume": number,
"confidence": "high" | "medium" | "low",
"confidenceReason": "why confidence is this level"
},
"recommendedStock": {
"units": number,
"reasoning": "why this level"
},
"seasonalFactors": ["factor 1", "factor 2"],
"riskFlags": ["concern 1"]
}`;
const userMessage = `Brand: ${effectiveBrandId}
Product: ${productName ?? "unknown"}
Stop: ${stopName ?? "general"}
Historical Order Data: ${JSON.stringify(historicalData ?? [])}
Analyze demand patterns and return JSON with currentTrend, prediction (nextStopVolume, nextWeekVolume, confidence, confidenceReason), recommendedStock (units, reasoning), seasonalFactors array, and riskFlags array.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.4,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
return NextResponse.json(JSON.parse(content));
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
+82
View File
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_reports) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, productName, currentPriceTiers, historicalSales } = await req.json();
if (!brandId) {
return NextResponse.json({ error: "Missing brandId" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const SYSTEM = `You are a pricing strategist for a B2B produce wholesale platform.
Given product sales data, recommend price adjustments with reasoning and estimated revenue impact.
Return JSON with exact shape:
{
"currentState": "brief description of current pricing situation",
"recommendations": [
{
"productName": "product name",
"currentPrice": number,
"suggestedPrice": number,
"direction": "increase" | "decrease" | "maintain",
"reasoning": "why this change",
"estimatedRevenueImpact": "±$X or 'minimal'"
}
],
"opportunities": ["high-level opportunity 1", "opportunity 2"],
"warnings": ["potential issue 1"]
}`;
const userMessage = `Brand: ${effectiveBrandId}
Product: ${productName ?? "unknown"}
Current Price Tiers: ${JSON.stringify(currentPriceTiers ?? [])}
Historical Sales (last 30-90 days): ${JSON.stringify(historicalSales ?? [])}
Analyze pricing and return JSON with currentState, recommendations array, opportunities, and warnings.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.4,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
return NextResponse.json(JSON.parse(content));
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_products) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { productName, category, price, unit, brandId } = await req.json();
if (!productName) {
return NextResponse.json({ error: "productName is required" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const systemPrompt = `You are a product copywriter for Route Commerce, a B2B fresh produce wholesale platform.
Given a product, write: a product name (can improve on input), a marketing description (2-3 sentences, persuasive, highlights freshness/origin/quality), image alt text (max 125 chars, SEO-friendly), and a brief pricing note if relevant.
Return valid JSON: { "name": "...", "description": "...", "altText": "...", "priceNote": "..." (or null) }
Brand voice: friendly, transparent, professional.
Audience: wholesale buyers, restaurant owners, farm stand operators.`;
try {
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content: [
`Product: ${productName}`,
category ? `Category: ${category}` : "",
price ? `Price: ${price}${unit ? ` ${unit}` : ""}` : "",
]
.filter(Boolean)
.join("\n"),
},
],
response_format: { type: "json_object" },
temperature: 0.7,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
const parsed = JSON.parse(content);
return NextResponse.json({
name: parsed.name ?? productName,
description: parsed.description ?? "",
altText: parsed.altText ?? "",
priceNote: parsed.priceNote ?? null,
});
} catch (err) {
return NextResponse.json({ error: "Generation failed" }, { status: 500 });
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
const REPORT_PROMPTS: Record<string, string> = {
"orders-by-stop": `This is an Orders by Stop report. Each row has: stop_name, city, state, date, order_count, gross_sales, pending_count, completed_count. Identify top-performing stops, underperforming stops, and patterns in pending vs completed orders.`,
"sales-by-product": `This is a Sales by Product report. Each row has: product_name, units_sold, gross_revenue, avg_price. Identify top sellers, underperformers, and pricing anomalies. Flag any products with unusually high/low average prices.`,
"fulfillment": `This is a Fulfillment Breakdown report. Each row has: fulfillment_type (pickup/shipping), order_count, revenue, pct_of_total. Analyze pickup vs shipping split and revenue distribution.`,
"pickup-status": `This is a Pickup Status report. Each row has: stop_name, city, date, total_orders, pending, completed, canceled. Identify stops with high pending rates, high cancellation rates, and completion speed.`,
"contact-growth": `This is a Contact Growth report. Each row has: date, new_contacts, imports, total. Identify growth trends, import spikes, and rate-of-change patterns.`,
"campaigns": `This is a Campaign Activity report. Each row has: campaign_name, status, campaign_type, sent_at, messages_logged. Identify which campaign types perform best, timing patterns, and engagement levels.`,
};
const SYSTEM = `You are a data analyst for a B2B produce wholesale distribution platform.
You analyze report data and return structured insights in plain English.
Always be specific and cite numbers from the data.
Format your response as JSON with this exact shape:
{
"summary": "2-3 sentence plain-English summary of what this report shows",
"keyInsights": ["insight 1", "insight 2", "insight 3"],
"suggestedActions": ["action 1", "action 2"]
}
Be direct and actionable. Do not use hedging language.`;
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_reports) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { reportType, dateRange, brandId, reportData } = await req.json();
if (!reportType || !reportData) {
return NextResponse.json({ error: "Missing reportType or reportData" }, { status: 400 });
}
// Brand scoping: platform_admin passes explicit brandId; brand_admin limited to own brand
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const prompt = REPORT_PROMPTS[reportType] ?? `This is a ${reportType} report. Analyze it.`;
// Build a compact sample of rows (max 20 for token efficiency)
const sampleRows = Array.isArray(reportData) ? reportData.slice(0, 20) : [];
const userMessage = `Date range: ${dateRange?.start ?? "unknown"} to ${dateRange?.end ?? "unknown"}
Brand: ${effectiveBrandId}
${prompt}
Data:
${JSON.stringify(sampleRows, null, 2)}
Return JSON with summary, keyInsights (array), and suggestedActions (array).`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.3,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
return NextResponse.json(JSON.parse(content));
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
+86
View File
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_reports) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, stops, startLocation } = await req.json();
if (!brandId || !stops || !Array.isArray(stops) || stops.length < 2) {
return NextResponse.json({ error: "Need at least 2 stops to optimize" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const SYSTEM = `You are a route optimization assistant for a B2B produce wholesale platform.
Given a list of stops with city/state/address and constraints, return an optimized delivery sequence.
Return JSON with this exact shape:
{
"optimizedSequence": [
{
"position": 1,
"stopName": "Stop A",
"city": "Greeley",
"state": "CO",
"reason": "why placed here (e.g., 'nearest to start point', 'time window constraint')"
}
],
"totalEstimatedDistance": "X miles",
"totalEstimatedDriveTime": "X hours Y minutes",
"warnings": ["issue 1"],
"suggestions": ["improvement 1"]
}`;
const stopsList = stops.map((s: { name?: string; city?: string; state?: string; address?: string; time_window?: string }, i: number) =>
`${i + 1}. ${s.name ?? "Stop"}${s.city ?? ""}, ${s.state ?? ""} ${s.address ? "(" + s.address + ")" : ""} ${s.time_window ? "[window: " + s.time_window + "]" : ""}`
).join("\n");
const userMessage = `Brand: ${effectiveBrandId}
Start location: ${startLocation ?? "First stop"}
Stops to sequence:
${stopsList}
Optimize the route for efficiency. Return JSON with optimizedSequence, totalEstimatedDistance, totalEstimatedDriveTime, warnings, and suggestions.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.3,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
return NextResponse.json(JSON.parse(content));
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_messages) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, stopId, stopName, stopDate, city, state, recentOrders, customerCount } = await req.json();
if (!brandId || !stopName) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const SYSTEM = `You are a marketing strategist for a B2B produce wholesale platform.
Given a stop's order history and customer data, recommend the optimal stop blast message.
Return JSON with exact shape:
{
"timingRecommendation": "e.g., 'Send 2 days before the stop, between 9-11am for best open rates'",
"subjectLine": "email subject line",
"bodyPreview": "2-3 sentence email body preview",
"audienceSize": "estimated number of recipients",
"audienceRecommendation": "who should receive this (e.g., 'all pickup customers for this stop' or 'customers who ordered from this stop in last 90 days')",
"contentAngles": [
{
"angle": "e.g., 'Freshness angle'",
"reasoning": "why this angle works for this stop"
},
{
"angle": "e.g., 'Urgency/discount angle'",
"reasoning": "why this angle works"
}
],
"warnings": ["potential issue 1"]
}`;
const userMessage = `Brand: ${effectiveBrandId}
Stop: ${stopName}
Date: ${stopDate ?? "unknown"}
City: ${city ?? ""}, ${state ?? ""}
Recent Orders: ${JSON.stringify(recentOrders ?? [])}
Customer Count: ${customerCount ?? "unknown"}
Analyze this stop's context and order history to recommend the optimal stop blast message.
Return JSON with timingRecommendation, subjectLine, bodyPreview, audienceSize, audienceRecommendation, contentAngles array, and warnings.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.4,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
return NextResponse.json(JSON.parse(content));
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const uid =
cookieStore.get("rc_auth_uid")?.value ??
cookieStore.get("rc_uid")?.value ??
null;
return NextResponse.json({ uid });
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
export async function GET() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
return NextResponse.json({ error: "Missing configuration" }, { status: 500 });
}
const response = await fetch(
`${supabaseUrl}/functions/v1/send-scheduled-campaigns`,
{ headers: { ...svcHeaders(supabaseKey!), "Content-Type": "application/json" } }
);
const text = await response.text();
if (!response.ok) {
return NextResponse.json({ error: "Failed to send campaigns", detail: text }, { status: 500 });
}
let results: unknown[] = [];
try {
const data = JSON.parse(text);
if (data && typeof data === "object") {
const d = data as Record<string, unknown>;
if (Array.isArray(d.results)) results = d.results as unknown[];
else if (Array.isArray(d.send_scheduled_campaigns)) results = d.send_scheduled_campaigns as unknown[];
else if (Array.isArray(data)) results = data as unknown[];
}
} catch { /* ignore */ }
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results });
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
export async function GET() {
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? "";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
if (!serviceKey || !supabaseUrl) {
return NextResponse.json({ error: "Missing env vars", serviceKey: !!serviceKey, supabaseUrl: !!supabaseUrl }, { status: 500 });
}
// Test 1: just a simple health endpoint that doesn't require the key
let healthResult = null;
try {
const res = await fetch(`${supabaseUrl}/rest/v1/`, {
headers: { apikey: serviceKey },
});
healthResult = { status: res.status, ok: res.ok };
} catch (e: any) {
healthResult = { error: e?.message };
}
// Test 2: try admin_users with POST (bypasses RLS select policies)
let adminResult = null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?select=id,user_id,role,email,display_name&limit=5`,
{
method: "GET",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
}
);
const body = await res.text();
let parsed = null;
try { parsed = JSON.parse(body); } catch { parsed = body; }
adminResult = { status: res.status, body: parsed };
} catch (e: any) {
adminResult = { error: e?.message };
}
return NextResponse.json({ healthResult, adminResult, serviceKeyLen: serviceKey.length });
}
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
let adminUsersStatus = "not_tried";
let adminUsersResult: string | null = null;
if (rcAuthUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (supabaseUrl && serviceKey) {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
adminUsersStatus = String(res.status);
const data = await res.json().catch(() => null);
adminUsersResult = JSON.stringify(data);
} catch (e) {
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
}
} else {
adminUsersStatus = "missing_env_vars";
}
} else {
adminUsersStatus = "no_rc_auth_uid_cookie";
}
return NextResponse.json({
cookies: {
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 10)}...` : null,
rc_uid: rcUid ? `${rcUid.slice(0, 10)}...` : null,
rc_access_token: rcAccessToken ? "present" : null,
all_cookie_names: allCookies.map(c => c.name),
},
admin_users: {
status: adminUsersStatus,
result: adminUsersResult,
},
});
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
return NextResponse.json({
received_cookies: allCookies.map(c => ({ name: c.name, value_len: c.value.length })),
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 8)}...` : "MISSING",
rc_uid: rcUid ? `${rcUid.slice(0, 8)}...` : "MISSING",
raw_rc_auth_uid: rcAuthUid ?? null,
});
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
export async function GET() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const nodeEnv = process.env.NODE_ENV;
const result = {
NODE_ENV: nodeEnv,
NEXT_PUBLIC_SUPABASE_URL: url ? `${url.substring(0, 40)}... (SET)` : "MISSING",
NEXT_PUBLIC_SUPABASE_ANON_KEY: key ? `${key.substring(0, 20)}... (SET)` : "MISSING",
SUPABASE_SERVICE_ROLE_KEY: serviceKey ? `${serviceKey.substring(0, 20)}... (SET)` : "MISSING",
supabaseClientCanCreate: false as boolean,
error: null as string | null,
};
if (url && key) {
try {
const { createClient } = await import("@supabase/supabase-js");
const client = createClient(url, key);
result.supabaseClientCanCreate = true;
} catch (e: any) {
result.error = e?.message ?? String(e);
}
} else {
result.error = "Missing env vars";
}
return NextResponse.json(result, { status: 200 });
}
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
export async function GET() {
// Test the REST call directly from this endpoint
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uid = "c023efb3-e3ef-4156-bed6-d17b92ea8aca";
let restResult = null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
const data = await res.json().catch(() => null);
restResult = { status: res.status, data, keyLen: serviceKey.length };
} catch (e: any) {
restResult = { error: e?.message };
}
const adminUser = await getAdminUser();
return NextResponse.json({
adminUser: adminUser ? {
id: adminUser.id,
user_id: adminUser.user_id,
role: adminUser.role,
brand_id: adminUser.brand_id,
active: adminUser.active,
} : null,
restResult,
supabaseUrl: supabaseUrl ? "SET" : "MISSING",
});
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
ts: new Date().toISOString(),
deployment: "debug-hello",
msg: "hello from latest build"
});
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function GET() {
const cookieStore = await cookies();
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
if (!uid) {
return NextResponse.json({ error: "No uid cookie found" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) {
return NextResponse.json({
uid,
error: "Missing env vars",
supabaseUrl: supabaseUrl ?? "MISSING",
serviceKeyPresent: !!serviceKey,
});
}
// Exact same lookup as getAdminUser
const lookupRes = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
let adminUsers: unknown[] = [];
let lookupOk = lookupRes.ok;
let lookupStatus = lookupRes.status;
let lookupData: unknown = null;
if (lookupRes.ok) {
lookupData = await lookupRes.json().catch(() => []);
adminUsers = Array.isArray(lookupData) ? lookupData : [];
} else {
lookupData = await lookupRes.text().catch(() => "unknown error");
}
if (adminUsers.length > 0) {
return NextResponse.json({
uid,
result: "found",
adminUser: adminUsers[0],
});
}
// Try auto-create
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) {
return NextResponse.json({ uid, result: "invalid_uuid" });
}
const postRes = await fetch(`${supabaseUrl}/rest/v1/admin_users`, {
method: "POST",
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({
user_id: uid, role: "platform_admin", brand_id: null, active: true,
can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true,
can_manage_settings: true, must_change_password: false
}),
});
return NextResponse.json({
uid,
result: "auto_created",
lookupOk,
lookupStatus,
adminUsersFound: adminUsers.length,
postStatus: postRes.status,
postOk: postRes.ok,
postData: postRes.ok ? await postRes.json().catch(() => null) : await postRes.text().catch(() => null),
});
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
response.cookies.set("dev_session", "platform_admin", {
path: "/",
sameSite: "lax",
httpOnly: false,
});
return response;
}
export async function GET() {
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
response.cookies.set("dev_session", "platform_admin", {
path: "/",
sameSite: "lax",
httpOnly: false,
});
return response;
}
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
import { sendAbandonedCartEmail, type AbandonedCart } from "@/actions/email-automation/abandoned-cart";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const BRAND_IDS = [
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" },
{ 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 }> {
let sent = 0, failed = 0, skipped = 0;
// 1. Detect newly abandoned wholesale carts
const detectRes = await fetch(
`${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
const activeRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_abandoned_carts`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!activeRes.ok) {
return { sent, failed, skipped };
}
const activeData = await activeRes.json();
const row = Array.isArray(activeData) ? activeData[0] : activeData;
const activeCarts: AbandonedCart[] = row?.carts ?? [];
// 4. Send emails
for (const cart of activeCarts) {
const nextStep = cart.sequence_step + 1;
if (nextStep > 3) { skipped++; continue; }
const result = await sendAbandonedCartEmail(cart, nextStep, brandId);
if (result.success) {
sent++;
} else {
failed++;
}
}
return { sent, failed, skipped };
}
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret) {
return NextResponse.json({ error: "Server misconfiguration: CRON_SECRET not set" }, { status: 500 });
}
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const results: Record<string, { sent: number; failed: number; skipped: number }> = {};
for (const brand of BRAND_IDS) {
try {
results[brand.name] = await processAbandonedCartsForBrand(brand.id, brand.name);
} catch {
results[brand.name] = { sent: 0, failed: 0, skipped: 0 };
}
}
const total = Object.values(results).reduce((acc, r) => ({ sent: acc.sent + r.sent, failed: acc.failed + r.failed, skipped: acc.skipped + r.skipped }), { sent: 0, failed: 0, skipped: 0 });
return NextResponse.json({ ok: true, results, ...total });
}
export async function GET() {
return NextResponse.json({
status: "ok",
path: "/api/email-automation/abandoned-cart",
brands: BRAND_IDS.map(b => ({ id: b.id, name: b.name })),
resend_configured: Boolean(process.env.RESEND_API_KEY),
cron_secret_configured: Boolean(process.env.CRON_SECRET),
});
}
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
import { sendWelcomeEmail, type WelcomeSequenceEntry } from "@/actions/email-automation/welcome-sequence";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const BRAND_IDS = [
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" },
{ id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct" },
];
async function processWelcomeSequencesForBrand(brandId: string): Promise<{ sent: number; failed: number; skipped: number }> {
let sent = 0, failed = 0, skipped = 0;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_welcome_sequence`,
{
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 };
}
const data = await res.json();
const row = Array.isArray(data) ? data[0] : data;
const entries: WelcomeSequenceEntry[] = row?.entries ?? [];
for (const entry of entries) {
const nextStep = entry.sequence_step + 1;
if (nextStep > 4) { skipped++; continue; }
if (entry.status === "unsubscribed" || entry.status === "bounced") { skipped++; continue; }
const result = await sendWelcomeEmail(entry, nextStep);
if (result.success) {
sent++;
} else {
failed++;
}
}
return { sent, failed, skipped };
}
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret) {
return NextResponse.json({ error: "Server misconfiguration: CRON_SECRET not set" }, { status: 500 });
}
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const results: Record<string, { sent: number; failed: number; skipped: number }> = {};
for (const brand of BRAND_IDS) {
try {
results[brand.name] = await processWelcomeSequencesForBrand(brand.id);
} catch {
results[brand.name] = { sent: 0, failed: 0, skipped: 0 };
}
}
const total = Object.values(results).reduce((acc, r) => ({ sent: acc.sent + r.sent, failed: acc.failed + r.failed, skipped: acc.skipped + r.skipped }), { sent: 0, failed: 0, skipped: 0 });
return NextResponse.json({ ok: true, results, ...total });
}
export async function GET() {
return NextResponse.json({
status: "ok",
path: "/api/email-automation/welcome-sequence",
brands: BRAND_IDS.map(b => ({ id: b.id, name: b.name })),
resend_configured: Boolean(process.env.RESEND_API_KEY),
cron_secret_configured: Boolean(process.env.CRON_SECRET),
});
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
const DEV_ROLES = ["platform_admin", "brand_admin", "store_employee"];
export async function GET(request: Request) {
const url = new URL(request.url);
const role = url.searchParams.get("role") ?? "platform_admin";
const safeRole = DEV_ROLES.includes(role) ? role : "platform_admin";
const origin = url.origin;
const response = NextResponse.redirect(new URL("/admin", origin));
const cookieOptions = {
path: "/",
maxAge: 60 * 60 * 24 * 30,
sameSite: "lax" as const,
};
response.cookies.set("dev_session", safeRole, {
...cookieOptions,
httpOnly: false,
});
response.cookies.set("rc_auth_uid", DEV_ADMIN_UID, {
...cookieOptions,
httpOnly: false,
});
return response;
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
export async function POST(request: Request) {
const formData = await request.formData();
const email = formData.get("email") as string;
if (!email) {
return NextResponse.json({ error: "Email is required." }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
}
const origin = (await headers()).get("origin") ?? "http://localhost:3000";
const res = await fetch(`${supabaseUrl}/auth/v1/recover`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseAnonKey,
},
body: JSON.stringify({ email, redirectTo: `${origin}/auth/callback` }),
});
// Always return 200 to avoid email enumeration — Supabase may still send or not
if (!res.ok) {
const data = await res.json().catch(() => null);
return NextResponse.json({ error: data?.message ?? "Failed to send reset link." }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,179 @@
import { NextResponse } from "next/server";
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
import { supabase } from "@/lib/supabase";
export async function GET() {
const brandSlug = "indian-river-direct";
const { data: brand } = await supabase
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
.single();
if (!brand?.id) {
return new NextResponse("Brand not found", { status: 404 });
}
const { data: stops } = await supabase
.from("stops")
.select("*")
.eq("brand_id", brand.id)
.eq("active", true)
.order("date", { ascending: true });
const { data: settings } = await supabase
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
.single();
const pdfBytes = await buildProfessionalSchedulePdf({
brandName: brand.name,
stops: stops ?? [],
logoUrl: settings?.logo_url ?? null,
contactEmail: settings?.email ?? null,
contactPhone: settings?.phone ?? null,
footerNotes: settings?.schedule_pdf_notes ?? null,
});
return new NextResponse(Buffer.from(pdfBytes), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${brand.name.replace(/\s+/g, "-")}-Schedule.pdf"`,
},
});
}
async function buildProfessionalSchedulePdf({
brandName,
stops,
logoUrl,
contactEmail,
contactPhone,
footerNotes,
}: {
brandName: string;
stops: Array<{ city: string; state: string; date: string; time: string; location: string }>;
logoUrl: string | null;
contactEmail: string | null;
contactPhone: string | null;
footerNotes: string | null;
}) {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([612, 792]);
const { width, height } = page.getSize();
const margin = 50;
const helvetica = await page.doc.embedFont(StandardFonts.Helvetica);
const helveticaBold = await page.doc.embedFont(StandardFonts.HelveticaBold);
const colWidths = [130, 80, 80, 210];
const headers = ["City / State", "Date", "Time", "Location"];
let y = height - margin;
// ── Header block ─────────────────────────────────────────────────────────
page.drawText(brandName, { x: margin, y, font: helveticaBold, size: 18, color: rgb(0.1, 0.1, 0.1) });
y -= 10;
page.drawText("Stop Schedule", { x: margin, y, font: helvetica, size: 11, color: rgb(0.4, 0.4, 0.4) });
y -= 8;
const stopLabel = stops.length === 0
? "No upcoming stops"
: stops.length === 1
? "1 upcoming stop"
: `${stops.length} upcoming stops`;
page.drawText(stopLabel, { x: margin, y, font: helvetica, size: 9, color: rgb(0.5, 0.5, 0.5) });
y -= 18;
// ── Logo (top-right) ────────────────────────────────────────────────────
if (logoUrl) {
try {
const logoResponse = await fetch(logoUrl);
if (logoResponse.ok) {
const logoBuffer = Buffer.from(await logoResponse.arrayBuffer());
const ext = logoUrl.split(".").pop()?.toLowerCase() ?? "";
const embedFn = ext === "png" ? pdfDoc.embedPng : pdfDoc.embedJpg;
const logoImage = await embedFn(logoBuffer);
const logoH = 36;
const logoW = Math.min(120, logoImage.width * (logoH / logoImage.height));
page.drawImage(logoImage, {
x: width - margin - logoW,
y: height - margin - logoH,
width: logoW,
height: logoH,
});
}
} catch {
// logo fetch failed — skip
}
}
// ── Divider ─────────────────────────────────────────────────────────────
page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 1, color: rgb(0.2, 0.2, 0.2) });
y -= 14;
// ── Table headers ────────────────────────────────────────────────────────
let x = margin;
for (let i = 0; i < headers.length; i++) {
page.drawText(headers[i], { x, y, font: helveticaBold, size: 9, color: rgb(0.3, 0.3, 0.3) });
x += colWidths[i];
}
y -= 8;
page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 0.5, color: rgb(0.75, 0.75, 0.75) });
y -= 16;
// ── Stop rows ───────────────────────────────────────────────────────────
if (stops.length === 0) {
page.drawText("No upcoming stops scheduled.", { x: margin, y, font: helvetica, size: 10, color: rgb(0.5, 0.5, 0.5) });
y -= 20;
} else {
for (const stop of stops) {
x = margin;
const cols = [stop.city + ", " + stop.state, stop.date, stop.time, stop.location];
for (let i = 0; i < cols.length; i++) {
page.drawText(cols[i], { x, y, font: helvetica, size: 9, color: rgb(0.15, 0.15, 0.15) });
x += colWidths[i];
}
y -= 20;
if (y < margin + 40) {
const newPage = pdfDoc.addPage([612, 792]);
y = newPage.getSize().height - margin;
}
}
}
// ── Bottom divider ───────────────────────────────────────────────────────
y -= 4;
page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 0.5, color: rgb(0.8, 0.8, 0.8) });
y -= 14;
// ── Footer notes ────────────────────────────────────────────────────────
if (footerNotes) {
page.drawText(footerNotes, { x: margin, y, font: helvetica, size: 8, color: rgb(0.45, 0.45, 0.45) });
y -= 14;
}
// ── Contact line ───────────────────────────────────────────────────────
const contactParts = [];
if (contactEmail) contactParts.push(contactEmail);
if (contactPhone) contactParts.push(contactPhone);
if (contactParts.length > 0) {
page.drawText(contactParts.join(" | "), { x: margin, y, font: helvetica, size: 8, color: rgb(0.4, 0.4, 0.4) });
y -= 12;
}
// ── Generated date ──────────────────────────────────────────────────────
page.drawText(`Generated ${new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`, {
x: width - margin - 160,
y: 16,
font: helvetica,
size: 8,
color: rgb(0.6, 0.6, 0.6),
});
return pdfDoc.save();
}
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { getAIProviderSettings, setAIProviderSettings } from "@/actions/integrations/ai-providers";
import { getAdminUser } from "@/lib/admin-permissions";
export async function GET(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_settings) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { searchParams } = new URL(req.url);
let brandId = searchParams.get("brandId");
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
brandId = effectiveBrandId;
const settings = await getAIProviderSettings(brandId);
return NextResponse.json(settings);
}
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_settings) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, provider, apiKey, orgId, model, customEndpoint } = await req.json();
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const result = await setAIProviderSettings(effectiveBrandId, { provider, apiKey, orgId, model, customEndpoint });
if (!result.success) return NextResponse.json({ error: result.error }, { status: 500 });
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
}
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { getAIClient } from "@/actions/integrations/ai-providers";
import { getAdminUser } from "@/lib/admin-permissions";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_settings) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId } = await req.json();
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const result = await getAIClient(effectiveBrandId);
if ("error" in result && !result.client) {
return NextResponse.json({ error: result.error }, { status: 503 });
}
const { client, provider, model } = result as { client: unknown; provider: string; model: string };
// Anthropic uses a different API
if (provider === "anthropic") {
const anthropicClient = client as { messages?: { create: (args: unknown) => unknown } };
if (!anthropicClient?.messages) {
return NextResponse.json({ error: "Invalid Anthropic client" }, { status: 500 });
}
const response = await anthropicClient.messages.create({
model,
max_tokens: 10,
messages: [{ role: "user", content: "Hi" }],
});
if (!response) throw new Error("No response");
return NextResponse.json({
success: true,
message: `Connected to ${provider} (model: ${model})`,
provider,
model,
});
}
// Google Gemini uses a different API
if (provider === "google") {
const googleClient = client as { models?: { generateContent: (model: string, input: unknown) => unknown } };
if (!googleClient?.models) {
return NextResponse.json({ error: "Invalid Google AI client" }, { status: 500 });
}
const response = await googleClient.models.generateContent(model, "Hi");
if (!response) throw new Error("No response");
return NextResponse.json({
success: true,
message: `Connected to ${provider} (model: ${model})`,
provider,
model,
});
}
// OpenAI, xAI, and custom OpenAI-compatible endpoints
const clientObj = client as { chat?: { completions?: { create: (args: unknown) => unknown } } };
if (!clientObj?.chat?.completions) {
return NextResponse.json({ error: "Invalid AI client — not an OpenAI-compatible SDK" }, { status: 500 });
}
const response = await clientObj.chat.completions.create({
model,
messages: [{ role: "user", content: "Hi" }],
max_tokens: 5,
});
if (!response) throw new Error("No response");
return NextResponse.json({
success: true,
message: `Connected to ${provider} (model: ${model})`,
provider,
model,
});
} catch (err) {
return NextResponse.json({
error: "Connection test failed. Check your API key and endpoint.",
}, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST(request: Request) {
const { email, password } = await request.json().catch(() => ({}));
if (!email || !password) {
return NextResponse.json({ error: "Email and password are required." }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
}
const authRes = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { "Content-Type": "application/json", apikey: supabaseAnonKey },
body: JSON.stringify({ email, password }),
});
const authData = await authRes.json().catch(() => null);
if (!authRes.ok || !authData?.access_token) {
const msg = authData?.error_description ?? authData?.error ?? "Invalid credentials.";
return NextResponse.json({ ok: false, error: msg }, { status: 401 });
}
const userId = authData.user?.id ?? authData.user_id;
if (!userId) {
return NextResponse.json({ ok: false, error: "Auth succeeded but user ID missing." }, { status: 500 });
}
// Set cookie + return JSON — client reads this and navigates
const isProd = process.env.NODE_ENV === "production";
const response = NextResponse.json({ ok: true });
response.cookies.set("rc_auth_uid", userId, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
secure: isProd,
});
return response;
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST() {
const cookieStore = await cookies();
// Clear all auth cookies (new + legacy)
cookieStore.delete("rc_access_token");
cookieStore.delete("rc_uid");
cookieStore.delete("rc_auth_uid");
cookieStore.delete("rc_auth_token");
return NextResponse.json({ success: true });
}
+67
View File
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
export async function GET(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_reports) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { searchParams } = new URL(req.url);
const format = searchParams.get("format") ?? "json";
const brandId = searchParams.get("brand_id") ?? adminUser.brand_id;
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 (!response.ok) {
return NextResponse.json({ error: "Failed to fetch report data" }, { status: 500 });
}
const data = await response.json();
if (format === "csv") {
const rows = data.orders ?? [];
const headers = ["id", "customer_name", "customer_email", "status", "subtotal", "created_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(","));
}
return new NextResponse(csvRows.join("\n"), {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="orders-${new Date().toISOString().slice(0, 10)}.csv"`,
},
});
}
return NextResponse.json(data);
}
+125
View File
@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { svcHeaders } from "@/lib/svc-headers";
// Resend webhook events: email.delivered, email.opened, email.clicked,
// email.bounced, email.failed, email.unsubscribed
type ResendEvent = {
type: string;
data: {
email_id: string;
from: string;
to: string;
subject?: string;
created_at: string;
delivered_at?: string;
opened_at?: string;
clicked_at?: string;
bounced_at?: string;
bounce_reason?: string;
};
};
function verifyResendSignature(body: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get("x-resend-signature") ?? "";
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET;
// Verify signature if secret is configured
if (webhookSecret) {
const valid = verifyResendSignature(rawBody, signature, webhookSecret);
if (!valid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
}
let event: ResendEvent;
try {
event = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { type, data } = event;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Look up by customer_email + subject (event_id is not populated by send_campaign)
// Filter to recent logs (last 7 days) to avoid stale matches
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
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 });
}
@@ -0,0 +1,175 @@
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();
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const brandId = searchParams.get("brandId");
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
if (!brandId || !startDate || !endDate) {
return new Response("Missing brandId, startDate, or endDate", { status: 400 });
}
const lotsRaw = await adminFetchJson("get_lots_for_period", {
p_brand_id: brandId,
p_start_date: startDate,
p_end_date: endDate,
});
const lots: LotRow[] = (lotsRaw ?? []) as LotRow[];
if (!lots || lots.length === 0) {
return new Response("No lots found for this period", { status: 404 });
}
const lotIds = lots.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,${lots.length}`);
lines.push("");
const totalQty = lots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0);
const totalUsed = lots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0);
const crops = [...new Set(lots.map((l) => l.crop_type))];
const units = [...new Set(lots.map((l) => l.yield_unit ?? "lbs"))];
const primaryUnit = units.length === 1 ? units[0] : "lbs";
lines.push("SUMMARY");
lines.push(`Total Lots Harvested,${lots.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 lots) {
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(",");
}
@@ -0,0 +1,162 @@
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;
}
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
}
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"`,
},
});
}
@@ -0,0 +1,206 @@
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();
}
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 });
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"`,
},
});
}
// 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"`,
},
});
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST(request: Request) {
const { userId } = await request.json().catch(() => ({}));
if (!userId) {
return NextResponse.json({ error: "Missing userId" }, { status: 400 });
}
const cookieStore = await cookies();
const isProd = process.env.NODE_ENV === "production";
cookieStore.set("rc_auth_uid", userId, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: isProd ? "strict" : "lax",
secure: isProd,
});
return NextResponse.json({ ok: true });
}
+133
View File
@@ -0,0 +1,133 @@
import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
import { cookies } from "next/headers";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const error = searchParams.get("error");
const origin = new URL(request.url).origin;
if (error) {
return NextResponse.redirect(
new URL(`/admin/settings/payments?error=square_oauth_denied&reason=${encodeURIComponent(error)}`, request.url)
);
}
if (!code || !state) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_oauth_missing_params", request.url)
);
}
// Decode brand_id from state
let brandId: string | null = null;
try {
const decoded = JSON.parse(Buffer.from(state, "base64").toString("utf-8"));
brandId = decoded?.brandId ?? null;
} catch {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_oauth_invalid_state", request.url)
);
}
if (!brandId) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_oauth_missing_state", request.url)
);
}
// Exchange code for access token
const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID;
const appSecret = process.env.SQUARE_APP_SECRET;
const env = process.env.SQUARE_ENVIRONMENT ?? "sandbox";
if (!appId || !appSecret) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_credentials_not_configured", request.url)
);
}
const tokenUrl = env === "production"
? "https://connect.squareup.com/v2/oauth2/token"
: "https://connect.squareupsandbox.com/v2/oauth2/token";
const redirectUri = `${origin}/api/square/oauth/callback`;
let accessToken: string | null = null;
let locationId: string | null = null;
try {
const tokenResponse = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Square-Version": "2025-01-16",
},
body: JSON.stringify({
client_id: appId,
client_secret: appSecret,
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
});
const tokenData = await tokenResponse.json();
if (!tokenResponse.ok || tokenData.access_token) {
accessToken = tokenData.access_token ?? null;
locationId = tokenData.location_id ?? null;
} else {
return NextResponse.redirect(
new URL(`/admin/settings/payments?error=square_token_exchange_failed`, request.url)
);
}
} catch (err) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_token_exchange_error", request.url)
);
}
if (!accessToken) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_no_access_token", request.url)
);
}
// Store token + location_id in payment_settings via upsert
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try {
const upsertResponse = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({
p_brand_id: brandId,
p_provider: "square",
p_square_access_token: accessToken,
p_square_location_id: locationId,
}),
}
);
if (!upsertResponse.ok) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_token_save_failed", request.url)
);
}
} catch (err) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_token_save_error", request.url)
);
}
return NextResponse.redirect(
new URL("/admin/settings/payments?square_connected=true", request.url)
);
}
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
export async function GET(req: Request) {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.brand_id) {
return NextResponse.redirect(new URL("/admin/settings/payments?error=unauthorized", req.url));
}
if (!adminUser.can_manage_settings) {
return NextResponse.redirect(new URL("/admin/settings/payments?error=forbidden", req.url));
}
const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID;
const env = process.env.SQUARE_ENVIRONMENT ?? "sandbox";
if (!appId) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_app_id_not_configured", req.url)
);
}
const baseUrl = env === "production"
? "https://connect.squareup.com"
: "https://connect.squareupsandbox.com";
const origin = new URL(req.url).origin;
const redirectUri = `${origin}/api/square/oauth/callback`;
// Encode brand_id in state so callback knows which brand to associate
const state = Buffer.from(JSON.stringify({ brandId: adminUser.brand_id })).toString("base64");
const params = new URLSearchParams({
client_id: appId,
scope: [
"ITEMS_READ",
"ITEMS_WRITE",
"ORDERS_READ",
"ORDERS_WRITE",
"INVENTORY_READ",
"INVENTORY_WRITE",
"PAYMENTS_READ",
"PAYMENTS_WRITE",
].join(" "),
response_type: "code",
redirect_uri: redirectUri,
state,
});
return NextResponse.redirect(`${baseUrl}/oauth2/authorize?${params}`);
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { syncProductsToSquare, syncProductsFromSquare } from "@/actions/square-products";
import { syncOrdersFromSquare } from "@/actions/square-orders";
import { getPaymentSettings } from "@/actions/payments";
import { svcHeaders } from "@/lib/svc-headers";
export async function POST(request: Request) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: { brandId?: string; type?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const brandId = body.brandId;
if (!brandId) {
return NextResponse.json({ error: "brandId required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const type = body.type ?? "all";
const results: Record<string, { synced: number; errors: string[] }> = {};
// Load settings once for reuse
const settingsResult = await getPaymentSettings(brandId);
const settings = settingsResult.success ? settingsResult.settings : null;
if (!settings?.square_access_token) {
return NextResponse.json({ error: "Square not connected" }, { status: 400 });
}
const syncErrors: string[] = [];
if (type === "products" || type === "all") {
// Sync both directions: RC → Square and Square → RC
const [toResult, fromResult] = await Promise.all([
syncProductsToSquare(brandId),
syncProductsFromSquare(brandId),
]);
results.products_to_square = { synced: toResult.synced, errors: toResult.errors };
results.products_from_square = { synced: fromResult.synced, errors: fromResult.errors };
syncErrors.push(...toResult.errors, ...fromResult.errors);
}
if (type === "orders" || type === "all") {
const orderResult = await syncOrdersFromSquare(brandId);
results.orders = { synced: orderResult.synced, errors: orderResult.errors };
syncErrors.push(...orderResult.errors);
}
// 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;
await fetch(`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_square_sync_enabled: true,
p_square_inventory_mode: settings?.square_inventory_mode ?? "none",
}),
});
return NextResponse.json({
success: syncErrors.length === 0,
results,
error: syncErrors.length > 0 ? syncErrors[0] : null,
});
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { syncProductsToSquare } from "@/actions/square-products";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.can_manage_settings) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const brandId = searchParams.get("brand_id");
if (!brandId) {
return NextResponse.json({ error: "brand_id required" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const claimRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/claim_square_sync_queue`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", "Prefer": "return=representation" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!claimRes.ok) {
const errText = await claimRes.text();
return NextResponse.json({ error: `Failed to claim queue: ${errText}` }, { status: 500 });
}
const entries = await claimRes.json();
if (!entries || entries.length === 0 || entries[0] === null) {
return NextResponse.json({ processed: 0, message: "No pending entries" });
}
const entry = entries[0];
const result = await syncProductsToSquare(entry.brand_id);
const newStatus = result.success ? "done" : "failed";
const lastError = result.errors.length > 0 ? result.errors[0] : null;
await fetch(
`${supabaseUrl}/rest/v1/rpc/update_square_sync_timestamp`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: entry.brand_id,
p_error: lastError,
}),
}
);
await fetch(
`${supabaseUrl}/rest/v1/square_sync_queue?id=eq.${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({
entry_id: entry.id,
status: newStatus,
synced: result.synced,
errors: result.errors,
});
}
+179
View File
@@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from "next/server";
import { parseStopCSV } from "@/lib/csv-parsers";
import type { ParsedStopRow } from "@/lib/csv-parsers";
import { getAdminUser } from "@/lib/admin-permissions";
export type ParsedStop = Omit<ParsedStopRow, "_rowIndex" | "_warnings">;
type RequestBody = {
/** Raw file content (CSV text). AI parsing is attempted if useAI=true. */
text: string;
brandId: string;
/** If true and OPENAI_API_KEY is set, parse unstructured text with AI. */
useAI?: boolean;
};
const AI_MODEL = "gpt-4o-mini";
async function parseWithAI(text: string, brandId: string): Promise<{
stops: ParsedStop[];
warnings: string[];
}> {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error("OPENAI_API_KEY is not configured. Use CSV format for reliable parsing.");
}
const systemPrompt = `You are a schedule extraction assistant. Given raw schedule text, extract all stop entries.
Return a JSON array where each entry has:
- city: city name (required)
- state: 2-letter state code (required)
- location: descriptive location or address (required)
- date: the stop date in YYYY-MM-DD format if stated, otherwise ""
- time: the pickup time range if stated, otherwise ""
- address: full street address if present, otherwise omit
- zip: ZIP code if present, otherwise omit
- notes: any notes (parking, instructions) if present, otherwise omit
If a row lacks required fields (city, state, location), omit it and add a warning.`;
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: AI_MODEL,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: `Extract stops from this schedule:\n\n${text.slice(0, 8000)}` },
],
response_format: { type: "json_object" },
temperature: 0.1,
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI API error: ${res.status}${err}`);
}
const data = await res.json();
const content: unknown = JSON.parse(data.choices[0]?.message?.content ?? "{}");
// Handle both { stops: [...] } and [ ... ] responses
let rawStops: unknown[] = [];
if (Array.isArray(content)) {
rawStops = content;
} else if (content && typeof content === "object") {
const obj = content as Record<string, unknown>;
if (Array.isArray(obj.stops)) rawStops = obj.stops;
else if (Array.isArray(obj.entries)) rawStops = obj.entries;
else if (Array.isArray(obj.rows)) rawStops = obj.rows;
}
const stops: ParsedStop[] = [];
const warnings: string[] = [];
for (const row of rawStops) {
if (!row || typeof row !== "object") continue;
const r = row as Record<string, unknown>;
const city = String(r.city ?? "").trim();
const state = String(r.state ?? "").trim();
const location = String(r.location ?? "").trim();
if (!city || !state || !location) {
warnings.push(`Skipped row: missing required fields — ${JSON.stringify(row)}`);
continue;
}
stops.push({
city,
state,
location,
date: String(r.date ?? "").trim(),
time: String(r.time ?? "").trim(),
address: r.address ? String(r.address).trim() : undefined,
zip: r.zip ? String(r.zip).trim() : undefined,
notes: r.notes ? String(r.notes).trim() : undefined,
});
}
return { stops, warnings };
}
export async function POST(req: Request) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_stops) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
let body: RequestBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { text, brandId, useAI } = body;
if (!text || typeof text !== "string") {
return NextResponse.json({ error: "text is required" }, { status: 400 });
}
if (!brandId || typeof brandId !== "string") {
return NextResponse.json({ error: "brandId is required" }, { status: 400 });
}
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
// Always try CSV first
const csvResult = parseStopCSV(text);
if (csvResult.success && csvResult.rows.length > 0) {
const stops: ParsedStop[] = csvResult.rows.map((r) => ({
city: r.city,
state: r.state,
location: r.location,
date: r.date,
time: r.time,
address: r.address,
zip: r.zip,
notes: r.notes,
}));
const warnings = [
...csvResult.errors.map((e) => `Row ${e.row}: ${e.error}`),
...csvResult.rows.flatMap((r) => r._warnings),
];
return NextResponse.json({ stops, warnings, source: "csv" });
}
// CSV failed or empty — try AI if enabled
if (useAI) {
try {
const { stops, warnings } = await parseWithAI(text, brandId);
return NextResponse.json({ stops, warnings, source: "ai" });
} catch (err) {
const msg = err instanceof Error ? err.message : "AI parsing failed";
return NextResponse.json(
{ error: msg, stops: [], warnings: [] },
{ status: 422 }
);
}
}
// No AI and CSV didn't work
const errorMsg = csvResult.success
? "No stops found in file. Check that columns include: city, state, location, date, time."
: csvResult.error;
return NextResponse.json({ error: errorMsg, stops: [], warnings: [] }, { status: 422 });
}
+466
View File
@@ -0,0 +1,466 @@
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sendPastDueNotification } from "@/lib/billing";
import { svcHeaders } from "@/lib/svc-headers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Feature flag key for each add-on Stripe price ID
const PRICE_TO_FEATURE: Record<string, string> = {
[process.env.STRIPE_PRICE_HARVEST_REACH ?? ""]: "harvest_reach",
[process.env.STRIPE_PRICE_WHOLESALE_PORTAL ?? ""]: "wholesale_portal",
[process.env.STRIPE_PRICE_WATER_LOG ?? ""]: "water_log",
[process.env.STRIPE_PRICE_AI_TOOLS ?? ""]: "ai_tools",
[process.env.STRIPE_PRICE_SQUARE_SYNC ?? ""]: "square_sync",
[process.env.STRIPE_PRICE_SMS_CAMPAIGNS ?? ""]: "sms_campaigns",
};
const PLAN_PRICE_TO_TIER: Record<string, string> = {
[process.env.STRIPE_PRICE_STARTER ?? ""]: "starter",
[process.env.STRIPE_PRICE_FARM ?? ""]: "farm",
[process.env.STRIPE_PRICE_ENTERPRISE ?? ""]: "enterprise",
};
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!sig || !webhookSecret) {
return new NextResponse("Missing signature or webhook secret", { status: 400 });
}
let event: Stripe.Event;
try {
event = Stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Webhook verification failed";
return new NextResponse(`Webhook Error: ${message}`, { status: 400 });
}
// ── Subscription events ─────────────────────────────────────────────────────
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutCompleted(session, req);
}
if (event.type === "customer.subscription.updated") {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdated(subscription);
}
if (event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeleted(subscription);
}
if (event.type === "invoice.payment_succeeded") {
const invoice = event.data.object as Stripe.Invoice;
await handleInvoicePaid(invoice);
}
if (event.type === "invoice.payment_failed") {
const invoice = event.data.object as Stripe.Invoice;
await handleInvoiceFailed(invoice);
}
return new NextResponse("OK", { status: 200 });
}
// ── Subscription handlers ──────────────────────────────────────────────────────
async function handleCheckoutCompleted(session: Stripe.Checkout.Session, req: NextRequest) {
const brandId = session.metadata?.brand_id;
if (!brandId || session.mode !== "subscription") return;
const subscriptionId = typeof session.subscription === "string"
? session.subscription
: session.subscription?.id;
if (!subscriptionId) return;
// Update brand subscription tracking
await updateBrandSubscription(brandId, subscriptionId, "active");
// Also handle wholesale deposit payments via same event
const orderId = session.metadata?.order_id;
const customerId = session.metadata?.customer_id;
const paymentBrandId = session.metadata?.brand_id;
if (orderId) {
const paymentIntentId =
typeof session.payment_intent === "string"
? session.payment_intent
: session.payment_intent?.id ?? null;
const amountPaid = session.amount_total ? session.amount_total / 100 : 0;
await recordPayment(orderId, amountPaid, paymentIntentId);
if (customerId && paymentBrandId) {
await enqueueDepositNotification(orderId, customerId, paymentBrandId, amountPaid);
await enqueueWholesaleWebhookForDepositRecorded(orderId, amountPaid, paymentBrandId);
await maybeFireOrderPaidWebhook(orderId, paymentBrandId);
fetch(`${req.nextUrl.origin}/api/wholesale/notifications/send`, { method: "POST" }).catch(() => {});
}
}
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const brandId = subscription.metadata?.brand_id;
if (!brandId) {
// Try to find brand via customer
const brand = await findBrandByStripeCustomer(subscription.customer as string);
if (brand) {
await updateBrandSubscription(brand.id, subscription.id, subscription.status);
}
return;
}
await updateBrandSubscription(brandId, subscription.id, subscription.status);
await syncAddonFeatures(subscription);
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const brandId = subscription.metadata?.brand_id;
if (!brandId) {
const brand = await findBrandByStripeCustomer(subscription.customer as string);
if (brand) {
await clearBrandSubscription(brand.id);
await disableAllAddons(brand.id);
}
return;
}
await clearBrandSubscription(brandId);
await disableAllAddons(brandId);
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
// Update period end on successful payment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const subscriptionId = (invoice as any).subscription as string | null;
if (!subscriptionId) return;
const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
if (!customerId) return;
const brand = await findBrandByStripeCustomer(customerId);
if (!brand) return;
const periodEnd = new Date((invoice.lines.data[0]?.period?.end ?? 0) * 1000);
await updateBrandPeriodEnd(brand.id, periodEnd);
}
async function handleInvoiceFailed(invoice: Stripe.Invoice) {
const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
if (!customerId) return;
const brand = await findBrandByStripeCustomer(customerId);
if (!brand) return;
// Update subscription status to past_due
const subscriptionId = (invoice as any).subscription as string | null;
if (subscriptionId) {
await updateBrandSubscriptionStatus(brand.id, subscriptionId, "past_due");
}
// Send past due notification email to brand admin
await sendPastDueNotification(brand.id);
}
// ── Feature sync helpers ───────────────────────────────────────────────────────
async function syncAddonFeatures(subscription: Stripe.Subscription) {
const brandId = subscription.metadata?.brand_id;
if (!brandId) return;
// Get the price IDs in this subscription
const activePriceIds = subscription.items.data.map(item => item.price.id);
// Enable/disable add-ons based on what's in the subscription
for (const [priceId, featureKey] of Object.entries(PRICE_TO_FEATURE)) {
if (!priceId) continue;
const isActive = activePriceIds.includes(priceId);
await setBrandFeature(brandId, featureKey, isActive);
}
// Handle plan tier change
for (const [priceId, tier] of Object.entries(PLAN_PRICE_TO_TIER)) {
if (!priceId) continue;
if (activePriceIds.includes(priceId)) {
await updatePlanTier(brandId, tier);
}
}
}
async function setBrandFeature(brandId: string, featureKey: string, enabled: boolean) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_feature_key: featureKey, p_enabled: enabled }),
}
);
}
async function updatePlanTier(brandId: string, planTier: string) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
await fetch(
`${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_plan_tier: planTier }),
}
);
}
// ── DB update helpers ──────────────────────────────────────────────────────────
async function updateBrandSubscription(brandId: string, subscriptionId: string, status: string) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_subscription_id: subscriptionId,
p_status: status,
p_current_period_end: null,
}),
}
);
}
async function updateBrandPeriodEnd(brandId: string, periodEnd: Date) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_subscription_id: null,
p_status: null,
p_current_period_end: periodEnd.toISOString(),
}),
}
);
}
async function clearBrandSubscription(brandId: string) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_subscription_id: "",
p_status: "canceled",
p_current_period_end: null,
}),
}
);
}
async function updateBrandSubscriptionStatus(brandId: string, subscriptionId: string, status: string) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_subscription_id: subscriptionId,
p_status: status,
p_current_period_end: null,
}),
}
);
}
async function disableAllAddons(brandId: string) {
const ADDON_KEYS = ["harvest_reach", "wholesale_portal", "water_log", "ai_tools", "square_sync", "sms_campaigns"];
for (const key of ADDON_KEYS) {
await setBrandFeature(brandId, key, false);
}
}
async function findBrandByStripeCustomer(customerId: string): Promise<{ id: string } | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?stripe_customer_id=eq.${customerId}&select=id&limit=1`,
{ ...svcHeaders(supabaseKey) }
);
if (!res.ok) return null;
const brands = await res.json() as Array<{ id: string }>;
return brands[0] ?? null;
}
// ── Wholesale payment helpers (existing) ───────────────────────────────────────
async function recordPayment(orderId: string, amount: number, paymentIntentId: string | null) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
await fetch(
`${supabaseUrl}/rest/v1/rpc/record_wholesale_payment`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({
p_order_id: orderId,
p_amount: amount,
p_stripe_payment_intent_id: paymentIntentId,
}),
}
);
}
async function enqueueDepositNotification(orderId: string, customerId: string, brandId: string, amount: number) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!orderRes.ok) return;
const orders = await orderRes.json() as Array<{ id: string; invoice_number: string | null; company_name: string; subtotal: number }>;
const order = orders.find(o => o.id === orderId);
if (!order) return;
const custRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=email,company_name`,
{ ...svcHeaders(supabaseKey) }
);
if (!custRes.ok) return;
const customers = await custRes.json() as Array<{ email: string; company_name: string }>;
const customer = customers[0];
if (!customer?.email) return;
const adminEmail = await getAdminEmail(brandId);
await fetch(
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_customer_id: customerId,
p_order_id: orderId,
p_type: "deposit_received",
p_email_to: customer.email,
p_email_cc: adminEmail ?? null,
p_subject: `Deposit Received — Order ${order.invoice_number ?? orderId.slice(0, 8)}`,
p_body_html: `<h2>Deposit Received</h2><p>Hi ${customer.company_name},</p><p>We have received your payment of <strong>$${amount.toFixed(2)}</strong> toward order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong>.</p>`,
p_body_text: `Deposit of $${amount.toFixed(2)} received for order ${order.invoice_number ?? orderId.slice(0, 8)}. Thank you!`,
}),
}
);
if (adminEmail) {
await fetch(
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_customer_id: customerId,
p_order_id: orderId,
p_type: "deposit_received",
p_email_to: adminEmail,
p_subject: `[Admin] Deposit Received — Order ${order.invoice_number ?? orderId.slice(0, 8)}`,
p_body_html: `<h2>Deposit Received</h2><p>A payment of <strong>$${amount.toFixed(2)}</strong> has been received from <strong>${customer.company_name}</strong> for order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong>.</p>`,
p_body_text: `Deposit of $${amount.toFixed(2)} received from ${customer.company_name} for order ${order.invoice_number ?? orderId.slice(0, 8)}.`,
}),
}
);
}
}
async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId: string) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_event_type: "deposit_recorded",
p_order_id: orderId,
p_brand_id: brandId,
p_payload: { order_id: orderId, amount, source: "stripe_checkout" },
}),
}).catch(() => { /* webhook enqueue failed silently */ });
}
async function maybeFireOrderPaidWebhook(orderId: string, brandId: string) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&select=balance_due,invoice_number`,
{ ...svcHeaders(supabaseKey) }
);
if (!res.ok) return;
const orders = await res.json() as Array<{ balance_due: number; invoice_number: string | null }>;
const order = orders[0];
if (!order) return;
if (Number(order.balance_due) <= 0) {
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_event_type: "order_paid",
p_order_id: orderId,
p_brand_id: brandId,
p_payload: { order_id: orderId, brand_id: brandId, invoice_number: order.invoice_number },
}),
}).catch(() => { /* webhook enqueue failed silently */ });
}
}
async function getAdminEmail(brandId: string): Promise<string | null> {
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_wholesale_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) return null;
const data = await res.json();
return data?.notification_email ?? data?.from_email ?? data?.invoice_business_email ?? null;
}
+43
View File
@@ -0,0 +1,43 @@
import { createClient } from "@supabase/supabase-js";
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "";
export async function GET() {
// Try creating supabase client — if it throws, capture the exact error
let status: string;
let canCreate = false;
let errMessage = "";
if (!url || !key) {
status = "MISSING_ENV_VARS";
errMessage = `url=${url ? "SET" : "MISSING"}, key=${key ? "SET" : "MISSING"}`;
} else {
try {
createClient(url, key);
canCreate = true;
status = "OK";
} catch (e: any) {
errMessage = e?.message ?? String(e);
status = "CREATE_CLIENT_FAILED";
}
}
const body = JSON.stringify({
status,
canCreate,
errMessage,
envVars: {
NODE_ENV: process.env.NODE_ENV,
urlSet: !!url,
urlPrefix: url ? url.substring(0, 30) : null,
keySet: !!key,
keyPrefix: key ? key.substring(0, 10) : null,
},
}, null, 2);
return new Response(body, {
status: status === "OK" ? 200 : 500,
headers: { "Content-Type": "application/json" },
});
}
+52
View File
@@ -0,0 +1,52 @@
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 });
}
}
+264
View File
@@ -0,0 +1,264 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getTimeTrackingSettings, getTimeTrackingWorkers, getWorkerTimeLogs } from "@/actions/time-tracking";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
type LogEntry = {
id: string;
worker_id: string;
worker_name: string;
task_id: string | null;
task_name: string;
clock_in: string;
clock_out: string | null;
lunch_break_minutes: number;
notes: string | null;
submitted_via: string;
total_minutes: number;
created_at: string;
brandId: string;
};
function formatDate(iso: string): string {
return new Date(iso).toISOString().slice(0, 10);
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
function csvRow(values: (string | number | boolean | null)[]): string {
return values.map(v => `"${String(v ?? "").replace(/"/g, '""')}"`).join(",");
}
// Determine current pay period from settings
function getPayPeriodDates(
startDay: number,
lengthDays: number
): { start: string; end: string } {
const now = new Date();
const currentDayOfWeek = now.getDay(); // 0=Sun
const daysSinceStartDay = (currentDayOfWeek - startDay + 7) % 7;
const periodStart = new Date(now);
periodStart.setDate(now.getDate() - daysSinceStartDay - (lengthDays - 1));
const periodEnd = new Date(periodStart);
periodEnd.setDate(periodStart.getDate() + lengthDays - 1);
return {
start: formatDate(periodStart.toISOString()),
end: formatDate(periodEnd.toISOString()),
};
}
type ExportType = "quickbooks" | "payroll" | "detailed" | "summary";
export async function GET(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url);
const exportType = (searchParams.get("type") ?? "detailed") as ExportType;
const brandFilter = searchParams.get("brand") ?? null; // "tuxedo" | "ird" | null
const startDate = searchParams.get("start") ?? "";
const endDate = searchParams.get("end") ?? "";
const includeOvertime = searchParams.get("overtime") !== "false";
const includeNotes = searchParams.get("notes") !== "false";
// Determine brand IDs to export
let brandIds: string[] = [];
if (brandFilter === "tuxedo") brandIds = [TUXEDO_BRAND_ID];
else if (brandFilter === "ird") brandIds = [IRD_BRAND_ID];
else if (adminUser.brand_id) brandIds = [adminUser.brand_id];
else brandIds = [TUXEDO_BRAND_ID, IRD_BRAND_ID];
// Collect all logs across brands
let allLogs: LogEntry[] = [];
let allSettings: Awaited<ReturnType<typeof getTimeTrackingSettings>>[] = [];
let allWorkers: Awaited<ReturnType<typeof getTimeTrackingWorkers>>[] = [];
for (const brandId of brandIds) {
const [workers, settings, logs] = await Promise.all([
getTimeTrackingWorkers(brandId),
getTimeTrackingSettings(brandId),
getWorkerTimeLogs(brandId, {
start: startDate || undefined,
end: endDate || undefined,
limit: 5000,
}),
]);
allWorkers.push(workers);
allSettings.push(settings);
allLogs = allLogs.concat((logs as LogEntry[]).map(l => ({ ...l, brandId })));
}
// Sort by clock_in
allLogs.sort((a, b) => new Date(a.clock_in).getTime() - new Date(b.clock_in).getTime());
// Default to current pay period if no dates given
const defaultPeriod = getPayPeriodDates(0, 7);
const start = startDate || defaultPeriod.start;
const end = endDate || defaultPeriod.end;
// Filter by date range
allLogs = allLogs.filter(l => {
const d = formatDate(l.clock_in);
return d >= start && d <= end;
});
// Helper to compute overtime for a log entry
function computeOvertime(log: (typeof allLogs)[0], settings: NonNullable<typeof allSettings[0]>) {
if (!includeOvertime) return { regularHours: 0, overtimeHours: 0 };
const totalHours = log.total_minutes / 60;
const dailyThreshold = Number(settings.daily_overtime_threshold);
const weeklyThreshold = Number(settings.weekly_overtime_threshold);
// Compute which threshold was hit
const overtimeHours = Math.max(0, totalHours - dailyThreshold);
const regularHours = totalHours - overtimeHours;
return { regularHours, overtimeHours };
}
let csv = "";
let filename = "";
if (exportType === "quickbooks") {
// QuickBooks Time import format
// Columns: Employee Name, Date, Clock In, Clock Out, Hours, Task/Service Item, Overtime Hours, Notes
const headers = ["Employee Name", "Date", "Clock In", "Clock Out", "Hours", "Task / Service Item", "Overtime Hours", "Notes"];
const rows = allLogs.map((log, i) => {
const worker = allWorkers.flat().find(w => w.id === log.worker_id);
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
const { regularHours, overtimeHours } = settings ? computeOvertime(log, settings) : { regularHours: 0, overtimeHours: 0 };
const totalHours = log.total_minutes / 60;
return [
log.worker_name,
formatDate(log.clock_in),
formatTime(log.clock_in),
log.clock_out ? formatTime(log.clock_out) : "",
totalHours.toFixed(2),
log.task_name,
overtimeHours > 0 ? overtimeHours.toFixed(2) : "",
log.notes ?? "",
];
});
csv = [headers, ...rows].map(csvRow).join("\n");
filename = `time-tracking-quickbooks-${start}-to-${end}.csv`;
}
else if (exportType === "payroll") {
// Standard payroll export — grouped by worker with totals
// Columns: Worker Name, Role, Total Hours, Regular Hours, Overtime Hours, Days Worked, Tasks
const workerMap = new Map<string, {
name: string; role: string; totalMin: number; days: Set<string>; tasks: Set<string>;
dailyOTMin: number; weeklyOTMin: number;
}>();
for (const log of allLogs) {
if (!workerMap.has(log.worker_id)) {
const w = allWorkers.flat().find(w => w.id === log.worker_id);
workerMap.set(log.worker_id, {
name: log.worker_name,
role: w?.role ?? "worker",
totalMin: 0, days: new Set(), tasks: new Set(),
dailyOTMin: 0, weeklyOTMin: 0,
});
}
const entry = workerMap.get(log.worker_id)!;
entry.totalMin += log.total_minutes;
entry.days.add(formatDate(log.clock_in));
entry.tasks.add(log.task_name);
}
const headers = ["Worker Name", "Role", "Days Worked", "Total Hours", "Regular Hours", "Overtime Hours", "Tasks"];
const rows = [...workerMap.values()].map(e => {
const totalH = e.totalMin / 60;
const otH = Math.max(0, totalH - 40); // weekly OT at 40h
const regH = totalH - otH;
return [
e.name,
e.role,
e.days.size,
totalH.toFixed(2),
regH.toFixed(2),
otH > 0 ? otH.toFixed(2) : "",
[...e.tasks].join("; "),
];
});
csv = [headers, ...rows].map(csvRow).join("\n");
filename = `time-tracking-payroll-${start}-to-${end}.csv`;
}
else if (exportType === "summary") {
// Pay period summary — one row per worker per pay period
const brandName = allSettings[0]?.brand_name ?? "Farm";
const headers = ["Brand", "Worker Name", "Pay Period Start", "Pay Period End", "Total Hours", "Daily OT Hours", "Weekly OT Hours", "Status"];
const workerMap = new Map<string, {
brandName: string; name: string; totalMin: number; dailyOT: number; weeklyOT: number;
}>();
for (const log of allLogs) {
if (!workerMap.has(log.worker_id)) {
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
workerMap.set(log.worker_id, {
brandName: settings?.brand_name ?? brandName,
name: log.worker_name,
totalMin: 0, dailyOT: 0, weeklyOT: 0,
});
}
const entry = workerMap.get(log.worker_id)!;
const totalH = log.total_minutes / 60;
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
if (settings) {
const dailyThr = Number(settings.daily_overtime_threshold);
const weeklyThr = Number(settings.weekly_overtime_threshold);
if (totalH > dailyThr) entry.dailyOT += (totalH - dailyThr);
if (totalH > weeklyThr) entry.weeklyOT += (totalH - weeklyThr);
}
entry.totalMin += log.total_minutes;
}
const rows = [...workerMap.values()].map(e => {
const totalH = e.totalMin / 60;
return [
e.brandName,
e.name,
start, end,
totalH.toFixed(2),
e.dailyOT > 0 ? e.dailyOT.toFixed(2) : "",
e.weeklyOT > 0 ? e.weeklyOT.toFixed(2) : "",
e.weeklyOT > 0 ? "OVERTIME" : totalH > 40 ? "NEAR OT" : "OK",
];
});
csv = [headers, ...rows].map(csvRow).join("\n");
filename = `time-tracking-summary-${start}-to-${end}.csv`;
}
else {
// Detailed audit log
const headers = [
"Worker Name", "Task", "Date", "Clock In", "Clock Out",
"Lunch (min)", "Total Minutes", "Hours", "Overtime Hours",
"Submitted Via", "Notes", "Brand",
];
const rows = allLogs.map(log => {
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
const { regularHours, overtimeHours } = settings ? computeOvertime(log, settings) : { regularHours: 0, overtimeHours: 0 };
return [
log.worker_name,
log.task_name,
formatDate(log.clock_in),
formatTime(log.clock_in),
log.clock_out ? formatTime(log.clock_out) : "",
log.lunch_break_minutes,
log.total_minutes,
(log.total_minutes / 60).toFixed(2),
overtimeHours > 0 ? overtimeHours.toFixed(2) : "",
log.submitted_via,
log.notes ?? "",
allSettings.find(s => s && s.brand_id === log.brandId)?.brand_name ?? "",
];
});
csv = [headers, ...rows].map(csvRow).join("\n");
filename = `time-tracking-detailed-${start}-to-${end}.csv`;
}
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+291
View File
@@ -0,0 +1,291 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getTimeTrackingSettings } from "@/actions/time-tracking";
import { checkAndNotifyOvertime } from "@/actions/time-tracking/notifications";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
// ── Resend Email ───────────────────────────────────────────────────────────────
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
const FROM_EMAIL = process.env.FROM_EMAIL ?? "Route Commerce <no-reply@routecommerce.com>";
interface ResendResponse {
id: string;
to: string[];
created_at: string;
}
async function sendResendEmail(to: string[], subject: string, html: string, text: string): Promise<{ success: boolean; message_id?: string; error?: string }> {
if (!RESEND_API_KEY) {
return { success: false, error: "RESEND_API_KEY not configured" };
}
try {
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ from: FROM_EMAIL, to, subject, html, text }),
});
if (!res.ok) {
const err = await res.text();
return { success: false, error: err };
}
const data = (await res.json()) as ResendResponse;
return { success: true, message_id: data.id };
} catch (e) {
return { success: false, error: String(e) };
}
}
// ── Twilio SMS ────────────────────────────────────────────────────────────────
const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID ?? "";
const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN ?? "";
const TWILIO_PHONE_NUMBER = process.env.TWILIO_PHONE_NUMBER ?? "";
interface TwilioMessage {
sid: string;
status: string;
error_code?: number;
error_message?: string;
}
async function sendTwilioSms(to: string, body: string): Promise<{ success: boolean; sid?: string; error?: string }> {
if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN || !TWILIO_PHONE_NUMBER) {
return { success: false, error: "Twilio not configured (missing credentials)" };
}
try {
const credentials = Buffer.from(`${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}`).toString("base64");
const res = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Messages.json`,
{
method: "POST",
headers: {
Authorization: `Basic ${credentials}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
To: to,
From: TWILIO_PHONE_NUMBER,
Body: body,
}),
}
);
if (!res.ok) {
const err = await res.json();
return { success: false, error: err.message ?? res.statusText };
}
const data = (await res.json()) as TwilioMessage;
return { success: data.status !== "failed", sid: data.sid, error: data.error_message };
} catch (e) {
return { success: false, error: String(e) };
}
}
// ── Email template builder ────────────────────────────────────────────────────
function buildOvertimeAlertEmail(params: {
brandName: string;
workerName: string;
triggerType: string;
dailyHours: number;
dailyThreshold: number;
weeklyHours: number;
weeklyThreshold: number;
adminUrl: string;
}): { subject: string; html: string; text: string } {
const { brandName, workerName, triggerType, dailyHours, dailyThreshold, weeklyHours, weeklyThreshold, adminUrl } = params;
const triggerLabel: Record<string, string> = {
daily_approaching: "Daily Overtime Approaching",
daily_reached: "Daily Overtime Threshold Reached",
weekly_approaching: "Weekly Overtime Approaching",
weekly_reached: "Weekly Overtime Threshold Reached",
};
const label = triggerLabel[triggerType] ?? triggerType;
const isReached = triggerType.endsWith("_reached");
const subject = `[Time Tracking Alert] ${brandName}${label}`;
const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f0f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#fafafa">
<div style="max-width:560px;margin:0 auto;padding:32px 16px">
<div style="background:#1c1917;border-radius:16px;padding:28px 32px;margin-bottom:24px">
<p style="margin:0 0 4px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#a8a29e">Time Tracking Alert</p>
<h1 style="margin:0;font-size:22px;font-weight:800;color:#ffffff">${label}</h1>
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,.6)">${brandName}</p>
</div>
<div style="background:#18181b;border-radius:16px;padding:28px 32px;border:1px solid #27272a">
<table style="width:100%;border-collapse:collapse;font-size:14px">
<tr><td style="padding:6px 0;color:#a8a29e">Worker</td><td style="padding:6px 0;color:#fafafa;font-weight:600;text-align:right">${workerName}</td></tr>
<tr><td style="padding:6px 0;color:#a8a29e;border-top:1px solid #27272a">Alert</td><td style="padding:6px 0;color:#f87171;font-weight:600;text-align:right">${isReached ? "⚠️ REACHED" : "Approaching"}</td></tr>
<tr><td style="padding:6px 0;color:#a8a29e;border-top:1px solid #27272a">Daily Hours</td><td style="padding:6px 0;color:#fafafa;font-weight:600;text-align:right">${dailyHours.toFixed(1)}h / ${dailyThreshold.toFixed(0)}h limit</td></tr>
<tr><td style="padding:6px 0;color:#a8a29e;border-top:1px solid #27272a">Weekly Hours</td><td style="padding:6px 0;color:#fafafa;font-weight:600;text-align:right">${weeklyHours.toFixed(1)}h / ${weeklyThreshold.toFixed(0)}h limit</td></tr>
</table>
${isReached ? `<div style="margin-top:20px;padding:16px;background:#7f1d1d;border-radius:10px;font-size:13px;color:#fca5a5">Worker has exceeded the overtime threshold. Review and confirm hours before payroll processing.</div>` : ""}
<a href="${adminUrl}" style="display:inline-block;margin-top:20px;background:#16a34a;color:#fff;text-decoration:none;font-weight:700;font-size:14px;padding:12px 28px;border-radius:8px">View in Time Tracking</a>
</div>
<div style="text-align:center;margin-top:24px">
<p style="margin:0;font-size:12px;color:#52525b">Route Commerce Time Tracking · ${new Date().toLocaleDateString()}</p>
</div>
</div>
</body>
</html>`;
const text = `[TIME TRACKING ALERT] ${label}${brandName}
Worker: ${workerName}
Alert: ${isReached ? "THRESHOLD REACHED" : "Approaching"}
Daily: ${dailyHours.toFixed(1)}h / ${dailyThreshold.toFixed(0)}h
Weekly: ${weeklyHours.toFixed(1)}h / ${weeklyThreshold.toFixed(0)}h
View: ${adminUrl}`;
return { subject, html, text };
}
function buildPeriodSummaryEmail(params: {
brandName: string;
totalHours: number;
workerCount: number;
periodStart: string;
periodEnd: string;
adminUrl: string;
}): { subject: string; html: string; text: string } {
const { brandName, totalHours, workerCount, periodStart, periodEnd, adminUrl } = params;
const subject = `[Time Tracking] ${brandName} Pay Period Summary — ${periodStart} to ${periodEnd}`;
const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f0f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#fafafa">
<div style="max-width:560px;margin:0 auto;padding:32px 16px">
<div style="background:#1c1917;border-radius:16px;padding:28px 32px;margin-bottom:24px">
<p style="margin:0 0 4px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#a8a29e">Weekly Summary</p>
<h1 style="margin:0;font-size:22px;font-weight:800;color:#ffffff">${brandName} — Pay Period Report</h1>
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,.6)">${periodStart}${periodEnd}</p>
</div>
<div style="background:#18181b;border-radius:16px;padding:28px 32px;border:1px solid #27272a;text-align:center">
<p style="margin:0 0 4px;font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#71717a">Total Hours This Period</p>
<p style="margin:0;font-size:48px;font-weight:800;color:#fafafa">${totalHours.toFixed(1)}h</p>
<p style="margin:8px 0 0;font-size:13px;color:#a8a29e">${workerCount} active worker${workerCount !== 1 ? "s" : ""}</p>
</div>
<a href="${adminUrl}" style="display:inline-block;margin-top:20px;background:#16a34a;color:#fff;text-decoration:none;font-weight:700;font-size:14px;padding:12px 28px;border-radius:8px">View Full Report</a>
</div>
</body>
</html>`;
const text = `[PAY PERIOD SUMMARY] ${brandName}\n\nPeriod: ${periodStart}${periodEnd}\nTotal Hours: ${totalHours.toFixed(1)}h\nActive Workers: ${workerCount}\n\nView: ${adminUrl}`;
return { subject, html, text };
}
// ── Main dispatch handler ─────────────────────────────────────────────────────
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json().catch(() => ({}));
const { brandId, workerId, workerName, dailyHours, weeklyHours } = body as {
brandId?: string;
workerId?: string;
workerName?: string;
dailyHours?: number;
weeklyHours?: number;
};
// Use provided brand or default to Tuxedo
const effectiveBrandId = brandId ?? TUXEDO_BRAND_ID;
// ── Trigger notification check ──────────────────────────────────────────
if (workerId && workerName && typeof dailyHours === "number" && typeof weeklyHours === "number") {
// Called after a clock-in/out event — check and log triggers
const result = await checkAndNotifyOvertime(effectiveBrandId, workerId, workerName, dailyHours, weeklyHours);
if (!result.sent) {
return NextResponse.json({ sent: false, reason: result.message });
}
// Fetch settings to get recipients
const settings = await getTimeTrackingSettings(effectiveBrandId);
if (!settings) return NextResponse.json({ sent: false, reason: "Settings not found" });
const emails = settings.notification_emails ?? [];
const smsNumbers = settings.notification_sms_numbers ?? [];
const brandName = settings.brand_name ?? "Farm";
// ── Send emails ───────────────────────────────────────────────────────
if (emails.length > 0) {
const emailParams = buildOvertimeAlertEmail({
brandName,
workerName,
triggerType: result.trigger_type ?? "daily_approaching",
dailyHours,
dailyThreshold: Number(settings.daily_overtime_threshold),
weeklyHours,
weeklyThreshold: Number(settings.weekly_overtime_threshold),
adminUrl: `https://route-commerce-platform.vercel.app/admin/time-tracking`,
});
// Update log entry with email delivery attempt
await sendResendEmail(emails, emailParams.subject, emailParams.html, emailParams.text);
}
// ── Send SMS ────────────────────────────────────────────────────────────
if (smsNumbers.length > 0) {
const smsBody = `[${brandName} Time Tracking] ALERT: ${workerName}${result.trigger_type?.replace("_", " ")}. Hours: ${dailyHours.toFixed(1)}d / ${weeklyHours.toFixed(1)}w. Check admin panel.`;
for (const num of smsNumbers) {
await sendTwilioSms(num, smsBody);
}
}
return NextResponse.json({
sent: true,
trigger: result.trigger_type,
emails_sent: emails,
sms_sent: smsNumbers,
});
}
// ── Period summary (no worker context) ───────────────────────────────────
if (brandId === "summary") {
const settings = await getTimeTrackingSettings(TUXEDO_BRAND_ID);
if (!settings) return NextResponse.json({ error: "Settings not found" });
const emails = settings.notification_emails ?? [];
if (emails.length === 0) return NextResponse.json({ sent: false, reason: "no recipients" });
const emailParams = buildPeriodSummaryEmail({
brandName: settings.brand_name ?? "Farm",
totalHours: 0, // caller should compute from logs
workerCount: 0,
periodStart: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10),
periodEnd: new Date().toISOString().slice(0, 10),
adminUrl: `https://route-commerce-platform.vercel.app/admin/time-tracking`,
});
const result = await sendResendEmail(emails, emailParams.subject, emailParams.html, emailParams.text);
return NextResponse.json({ sent: result.success, message_id: result.message_id, error: result.error });
}
return NextResponse.json({ error: "Invalid request — pass workerId + dailyHours + weeklyHours or brandId=summary" }, { status: 400 });
}
// ── GET: health check ─────────────────────────────────────────────────────────
export async function GET() {
return NextResponse.json({
status: "ok",
resend_configured: Boolean(RESEND_API_KEY),
twilio_configured: Boolean(TWILIO_ACCOUNT_SID),
twilio_number: TWILIO_PHONE_NUMBER ? "(set)" : "(not set)",
});
}
+190
View File
@@ -0,0 +1,190 @@
import { NextResponse } from "next/server";
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
import { supabase } from "@/lib/supabase";
type Settings = {
logo_url: string | null;
contact_email: string | null;
contact_phone: string | null;
schedule_pdf_notes: string | null;
brand_name: string | null;
};
export async function GET() {
const brandSlug = "tuxedo";
const { data: brand } = await supabase
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
.single();
if (!brand?.id) {
return new NextResponse("Brand not found", { status: 404 });
}
const { data: stops } = await supabase
.from("stops")
.select("*")
.eq("brand_id", brand.id)
.eq("active", true)
.order("date", { ascending: true });
const { data: settings } = await supabase
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
.single();
const pdfBytes = await buildProfessionalSchedulePdf({
brandName: brand.name,
stops: stops ?? [],
logoUrl: settings?.logo_url ?? null,
contactEmail: settings?.email ?? null,
contactPhone: settings?.phone ?? null,
footerNotes: settings?.schedule_pdf_notes ?? null,
});
return new NextResponse(Buffer.from(pdfBytes), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${brand.name.replace(/\s+/g, "-")}-Schedule.pdf"`,
},
});
}
async function buildProfessionalSchedulePdf({
brandName,
stops,
logoUrl,
contactEmail,
contactPhone,
footerNotes,
}: {
brandName: string;
stops: Array<{ city: string; state: string; date: string; time: string; location: string }>;
logoUrl: string | null;
contactEmail: string | null;
contactPhone: string | null;
footerNotes: string | null;
}) {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([612, 792]);
const { width, height } = page.getSize();
const margin = 50;
const helvetica = await page.doc.embedFont(StandardFonts.Helvetica);
const helveticaBold = await page.doc.embedFont(StandardFonts.HelveticaBold);
const colWidths = [130, 80, 80, 210];
const headers = ["City / State", "Date", "Time", "Location"];
let y = height - margin;
// ── Header block ─────────────────────────────────────────────────────────
// Brand name
page.drawText(brandName, { x: margin, y, font: helveticaBold, size: 18, color: rgb(0.1, 0.1, 0.1) });
y -= 10;
// Tagline
page.drawText("Stop Schedule", { x: margin, y, font: helvetica, size: 11, color: rgb(0.4, 0.4, 0.4) });
y -= 8;
// Stop count
const stopLabel = stops.length === 0
? "No upcoming stops"
: stops.length === 1
? "1 upcoming stop"
: `${stops.length} upcoming stops`;
page.drawText(stopLabel, { x: margin, y, font: helvetica, size: 9, color: rgb(0.5, 0.5, 0.5) });
y -= 18;
// ── Logo (top-right) ────────────────────────────────────────────────────
if (logoUrl) {
try {
const logoResponse = await fetch(logoUrl);
if (logoResponse.ok) {
const logoBuffer = Buffer.from(await logoResponse.arrayBuffer());
const ext = logoUrl.split(".").pop()?.toLowerCase() ?? "";
const embedFn = ext === "png" ? pdfDoc.embedPng : pdfDoc.embedJpg;
const logoImage = await embedFn(logoBuffer);
const logoH = 36;
const logoW = Math.min(120, logoImage.width * (logoH / logoImage.height));
page.drawImage(logoImage, {
x: width - margin - logoW,
y: height - margin - logoH,
width: logoW,
height: logoH,
});
}
} catch {
// logo fetch failed — skip embedding
}
}
// ── Divider ─────────────────────────────────────────────────────────────
page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 1, color: rgb(0.2, 0.2, 0.2) });
y -= 14;
// ── Table headers ────────────────────────────────────────────────────────
let x = margin;
for (let i = 0; i < headers.length; i++) {
page.drawText(headers[i], { x, y, font: helveticaBold, size: 9, color: rgb(0.3, 0.3, 0.3) });
x += colWidths[i];
}
y -= 8;
page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 0.5, color: rgb(0.75, 0.75, 0.75) });
y -= 16;
// ── Stop rows ───────────────────────────────────────────────────────────
if (stops.length === 0) {
page.drawText("No upcoming stops scheduled.", { x: margin, y, font: helvetica, size: 10, color: rgb(0.5, 0.5, 0.5) });
y -= 20;
} else {
for (const stop of stops) {
x = margin;
const cols = [stop.city + ", " + stop.state, stop.date, stop.time, stop.location];
for (let i = 0; i < cols.length; i++) {
page.drawText(cols[i], { x, y, font: helvetica, size: 9, color: rgb(0.15, 0.15, 0.15) });
x += colWidths[i];
}
y -= 20;
if (y < margin + 40) {
const newPage = pdfDoc.addPage([612, 792]);
y = newPage.getSize().height - margin;
}
}
}
// ── Bottom divider ───────────────────────────────────────────────────────
y -= 4;
page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 0.5, color: rgb(0.8, 0.8, 0.8) });
y -= 14;
// ── Footer notes ────────────────────────────────────────────────────────
if (footerNotes) {
page.drawText(footerNotes, { x: margin, y, font: helvetica, size: 8, color: rgb(0.45, 0.45, 0.45) });
y -= 14;
}
// ── Contact line ───────────────────────────────────────────────────────
const contactParts = [];
if (contactEmail) contactParts.push(contactEmail);
if (contactPhone) contactParts.push(contactPhone);
if (contactParts.length > 0) {
page.drawText(contactParts.join(" | "), { x: margin, y, font: helvetica, size: 8, color: rgb(0.4, 0.4, 0.4) });
y -= 12;
}
// ── Generated date ──────────────────────────────────────────────────────
page.drawText(`Generated ${new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`, {
x: width - margin - 160,
y: 16,
font: helvetica,
size: 8,
color: rgb(0.6, 0.6, 0.6),
});
return pdfDoc.save();
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { svcHeaders } from "@/lib/svc-headers";
export async function POST(request: Request) {
try {
const { brandId, pin } = await request.json();
if (!brandId || !pin) {
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
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 NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 });
}
// Verify PIN
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 NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 });
}
// Create session cookie
const sessionId = verifyData.session_id;
const cookieStore = await cookies();
const durationHours = settings.session_duration_hours ?? 4;
cookieStore.set("wl_admin_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: durationHours * 3600,
path: "/",
});
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ success: false, error: "Server error" }, { status: 500 });
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
export async function GET(request: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_water_log) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const format = searchParams.get("format") ?? "json";
// 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 supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
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: 10000 }),
}
);
if (!response.ok) {
return NextResponse.json({ error: "Failed to fetch water log" }, { status: 500 });
}
const data = await response.json();
if (format === "csv") {
const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"];
const csvRows = [headers.join(",")];
for (const row of data) {
csvRows.push([
row.id,
row.user_id ?? "",
row.headgate_id ?? "",
row.measurement ?? "",
row.unit ?? "",
`"${(row.notes ?? "").replace(/"/g, '""')}"`,
row.created_at ?? "",
].join(","));
}
return new NextResponse(csvRows.join("\n"), {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="water-log-${new Date().toISOString().slice(0, 10)}.csv"`,
},
});
}
return NextResponse.json(data);
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { svcHeaders } from "@/lib/svc-headers";
export async function POST(request: Request) {
try {
// Validate session — irrigators use wl_session, admins use wl_admin_session
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value ?? cookieStore.get("wl_admin_session")?.value;
if (!sessionId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file") as File | null;
const bucket = formData.get("bucket") as string | null;
if (!file || !bucket) {
return NextResponse.json({ error: "Missing file or bucket" }, { status: 400 });
}
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json({ error: "File too large (max 5MB)" }, { status: 400 });
}
if (!["image/jpeg", "image/png"].includes(file.type)) {
return NextResponse.json({ error: "Only JPG or PNG allowed" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabasePat = process.env.SUPABASE_PAT!;
const ext = file.type === "image/jpeg" ? "jpg" : "png";
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const arrayBuffer = await file.arrayBuffer();
const uint8 = new Uint8Array(arrayBuffer);
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${bucket}/${fileName}`,
{
method: "POST",
headers: {
...svcHeaders(supabasePat),
"Content-Type": file.type,
"x-upsert": "true",
},
body: uint8,
}
);
if (!uploadRes.ok) {
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${fileName}`;
return NextResponse.json({ url: publicUrl });
} catch (err) {
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
+112
View File
@@ -0,0 +1,112 @@
import { NextResponse } from "next/server";
import QRCode from "qrcode";
function escHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
}
// Derive a short "code" from the headgate name or token for the label
function deriveCode(name: string, token: string): string {
// Use name if it looks like "Hubbard" or "North Field Gate 1"
// Strip spaces, take first 6 chars of name in uppercase, append -1
const namePart = name.replace(/\s+/g, "-").toUpperCase().slice(0, 6);
return `${namePart}-1`;
}
export async function POST(request: Request) {
try {
const { token, name } = await request.json() as { token: string; name?: string };
if (!token) {
return new Response("Missing token", { status: 400 });
}
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
const url = `${baseUrl}/water?h=${escHtml(token)}`;
const code = name ? deriveCode(name, token) : token.slice(0, 8).toUpperCase();
const labelName = name ?? "Water Log";
const dataUrl = await QRCode.toDataURL(url, {
width: 360,
margin: 2,
color: { dark: "#000000", light: "#ffffff" },
errorCorrectionLevel: "H", // highest for physical signs
});
// Clean label design: name at top, code below, big QR, tiny URL
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>QR Label — ${escHtml(labelName)}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
@page { size: 3in 5in; margin: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #ffffff;
}
.label {
width: 3in;
padding: 0.35in 0.3in;
text-align: center;
}
.headgate-name {
font-size: 26pt;
font-weight: 900;
color: #000000;
letter-spacing: -0.02em;
line-height: 1.1;
margin-bottom: 4px;
}
.code {
font-size: 11pt;
font-weight: 600;
color: #555555;
letter-spacing: 0.06em;
margin-bottom: 18px;
}
.qr {
display: flex;
justify-content: center;
margin-bottom: 14px;
}
.qr img {
width: 2.1in;
height: 2.1in;
display: block;
}
.url {
font-size: 7pt;
color: #aaaaaa;
word-break: break-all;
line-height: 1.4;
}
@media print {
body { background: #fff; }
.label { padding: 0; }
}
</style>
</head>
<body>
<div class="label">
<div class="headgate-name">${escHtml(labelName)}</div>
<div class="code">${escHtml(code)}</div>
<div class="qr"><img src="${dataUrl}" alt="QR Code" /></div>
<div class="url">${escHtml(url)}</div>
</div>
<script>window.onload = () => { window.print(); }</script>
</body>
</html>`;
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
} catch {
return new Response("Server error", { status: 500 });
}
}
+151
View File
@@ -0,0 +1,151 @@
import { NextResponse } from "next/server";
import QRCode from "qrcode";
function escHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
}
function deriveCode(name: string, token: string): string {
const namePart = name.replace(/\s+/g, "-").toUpperCase().slice(0, 6);
return `${namePart}-1`;
}
export async function POST(request: Request) {
try {
const { headgates, baseUrl } = await request.json() as {
headgates: Array<{ name: string; token: string }>;
baseUrl?: string;
};
if (!headgates?.length) {
return new Response("No headgates provided", { status: 400 });
}
const siteUrl = baseUrl ?? process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
const qrDataUrls: string[] = [];
for (const hg of headgates) {
const url = `${siteUrl}/water?h=${hg.token}`;
const dataUrl = await QRCode.toDataURL(url, {
width: 280,
margin: 2,
color: { dark: "#000000", light: "#ffffff" },
errorCorrectionLevel: "H",
});
qrDataUrls.push(dataUrl);
}
const rows = headgates.map((hg, i) => `
<div class="card">
<div class="name">${escHtml(hg.name)}</div>
<div class="code">${escHtml(deriveCode(hg.name, hg.token))}</div>
<div class="qr"><img src="${qrDataUrls[i]}" alt="QR for ${escHtml(hg.name)}" /></div>
<div class="url">${escHtml(`${siteUrl}/water?h=${hg.token}`)}</div>
</div>
`).join("\n");
return new Response(buildPrintSheet(headgates.length, rows), {
headers: {
"Content-Type": "text/html",
"Cache-Control": "no-store",
},
});
} catch {
return new Response("Server error", { status: 500 });
}
}
function buildPrintSheet(count: number, rows: string): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Water Log QR Codes</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
padding: 20px;
background: #ffffff;
}
.header {
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 2px solid #000;
display: flex;
align-items: baseline;
justify-content: space-between;
}
.header h1 {
font-size: 16pt;
font-weight: 900;
color: #000;
letter-spacing: -0.01em;
}
.header span {
font-size: 10pt;
color: #666;
}
/* 2-column grid for landscape letter, 3-column for larger sheets */
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.card {
border: 2px solid #000000;
padding: 16px 12px 12px;
text-align: center;
background: #ffffff;
page-break-inside: avoid;
}
.name {
font-size: 18pt;
font-weight: 900;
color: #000000;
letter-spacing: -0.02em;
line-height: 1.1;
margin-bottom: 2px;
}
.code {
font-size: 9pt;
font-weight: 700;
color: #555555;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 10px;
}
.qr {
display: flex;
justify-content: center;
margin-bottom: 8px;
}
.qr img {
width: 1.65in;
height: 1.65in;
display: block;
}
.url {
font-size: 6.5pt;
color: #aaaaaa;
word-break: break-all;
line-height: 1.35;
}
@media print {
@page { size: letter landscape; margin: 0.4in; }
body { padding: 0; }
.grid { gap: 14px; }
.card { border: 1.5pt solid #000; padding: 12px 10px 10px; }
}
</style>
</head>
<body>
<div class="header">
<h1>Water Log QR Codes</h1>
<span>${new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} — ${count} headgate${count !== 1 ? "s" : ""}</span>
</div>
<div class="grid">${rows}</div>
<script>window.onload = () => { window.print(); }</script>
</body>
</html>`;
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import QRCode from "qrcode";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const token = searchParams.get("token");
const size = parseInt(searchParams.get("size") ?? "300");
const margin = parseInt(searchParams.get("margin") ?? "2");
if (!token) {
return new Response("Missing token", { status: 400 });
}
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
const url = `${baseUrl}/water?h=${token}`;
try {
const buffer = await QRCode.toBuffer(url, {
type: "png",
width: size,
margin,
color: { dark: "#1a1a1a", light: "#ffffff" },
errorCorrectionLevel: "M",
});
return new Response(new Uint8Array(buffer), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400",
},
});
} catch {
return new Response("QR generation failed", { status: 500 });
}
}
+133
View File
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { svcHeaders } from "@/lib/svc-headers";
export async function POST(req: NextRequest) {
const { orderId, customerId } = await req.json();
if (!orderId || !customerId) {
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<{
id: string;
brand_id: string;
customer_id: string;
balance_due: number;
invoice_number: string | null;
subtotal: number;
deposit_required: number;
deposit_paid: number;
}>;
const order = orders[0];
if (!order) {
return NextResponse.json({ error: "Order not found" }, { status: 404 });
}
const { balance_due } = order;
if (balance_due <= 0) {
return NextResponse.json({ error: "No balance due on this order" }, { status: 400 });
}
// ── 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 }),
}
);
if (!wsRes.ok) {
return NextResponse.json({ error: "Failed to fetch wholesale settings" }, { status: 500 });
}
const wsData = await wsRes.json();
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 }),
}
);
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;
if (!stripeSecretKey) {
return NextResponse.json({ error: "Stripe is not configured for this brand" }, { status: 500 });
}
// ── 4. Create Stripe Checkout Session ──────────────────────────────────────
const stripe = new Stripe(stripeSecretKey);
const origin = req.headers.get("origin") ?? process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000";
const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: `Wholesale Order ${order.invoice_number ?? orderId.slice(0, 8)}`,
description:
balance_due >= order.deposit_required
? "Deposit payment"
: "Balance payment",
},
unit_amount: Math.round(balance_due * 100), // convert to cents
},
quantity: 1,
},
],
mode: "payment",
success_url: `${origin}/wholesale/payment/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/wholesale/portal?tab=orders`,
metadata: {
order_id: orderId,
customer_id: customerId,
brand_id: order.brand_id,
},
});
// 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 }),
}
);
return NextResponse.json({ checkoutUrl: session.url });
}
@@ -0,0 +1,290 @@
import { NextRequest, NextResponse } from "next/server";
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
import { getAdminUser } from "@/lib/admin-permissions";
import { formatDate } from "@/lib/format-date";
import { svcHeaders } from "@/lib/svc-headers";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ orderId: string }> }
) {
const { orderId } = await params;
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 ────────────────────────────────────────────
if (token) {
// Look up order by ID + token. Falls back to a direct select so the
// invoice_token is checked server-side (not exposed to the client).
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,invoice_token,brand_id,customer_id,created_at`,
{
headers: svcHeaders(supabaseKey),
}
);
if (!orderRes.ok) {
return new NextResponse("Not found", { status: 404 });
}
const orders = await orderRes.json();
if (!orders || orders.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
// Proceed to generate PDF — order token is verified
const brandId = orders[0].brand_id;
// Fetch full order data for PDF
const fullRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!fullRes.ok) {
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);
if (!order) {
return new NextResponse("Not found", { status: 404 });
}
// Fetch brand-specific settings for invoice header
const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
const settingsData = await settingsRes.json();
const settings = settingsData ?? {};
const pdfBytes = await buildInvoicePdf(order, settings);
return new NextResponse(Buffer.from(pdfBytes), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="invoice-${order.invoice_number ?? orderId.slice(0, 8)}.pdf"`,
},
});
}
// ── Admin / no-token path — requires admin auth + brand scoping ─────────────
const adminUser = await getAdminUser();
if (!adminUser) {
return new NextResponse("Unauthorized", { status: 401 });
}
if (!adminUser.can_manage_orders && !adminUser.can_manage_reports) {
return new NextResponse("Forbidden", { status: 403 });
}
// Fetch the order directly by ID with brand scoping
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? undefined }),
}
);
if (!orderRes.ok) {
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);
if (!order) {
return new NextResponse("Order not found", { status: 404 });
}
const settingsRes = 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 settingsData = await settingsRes.json();
const settings = settingsData ?? {};
const pdfBytes = await buildInvoicePdf(order, settings);
return new NextResponse(Buffer.from(pdfBytes), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="invoice-${order.invoice_number ?? orderId.slice(0, 8)}.pdf"`,
},
});
}
async function buildInvoicePdf(
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 helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const page = pdfDoc.addPage([612, 792]);
const { width, height } = page.getSize();
const margin = 50;
let y = height - margin;
const text = (font: typeof helvetica, size: number, content: string, x: number, lineHeight?: number) => {
page.drawText(content, { x, y, font, size });
y -= (lineHeight ?? size * 1.4);
};
const drawLine = () => {
page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 0.5, color: rgb(0.8, 0.8, 0.8) });
y -= 10;
};
const businessName = settings.invoice_business_name || "Wholesale Portal";
const businessAddress = settings.invoice_business_address;
const businessPhone = settings.invoice_business_phone;
const businessEmail = settings.invoice_business_email;
const businessWebsite = settings.invoice_business_website;
// Header
text(helveticaBold, 18, businessName, margin);
y -= 8;
if (businessAddress) {
const lines = businessAddress.split("\n");
for (const line of lines) {
text(helvetica, 9, line, margin);
}
}
if (businessPhone) text(helvetica, 9, businessPhone, margin);
if (businessEmail) text(helvetica, 9, businessEmail, margin);
if (businessWebsite) text(helvetica, 9, businessWebsite, margin);
y -= 4;
text(helvetica, 10, `Invoice: ${order.invoice_number ?? ""}`, margin);
text(helvetica, 10, `Date: ${formatDate(new Date(order.created_at))}`, margin);
if (order.anticipated_pickup_date) {
text(helvetica, 10, `Pickup: ${order.anticipated_pickup_date}`, margin);
}
y -= 10;
drawLine();
// Bill To
text(helveticaBold, 11, "Bill To:", margin);
text(helvetica, 10, order.company_name, margin);
if (order.contact_name) text(helvetica, 10, order.contact_name, margin);
text(helvetica, 10, order.email, margin);
y -= 10;
drawLine();
// Column headers
const colX = { product: margin, qty: 340, price: 400, total: 480 };
text(helveticaBold, 9, "Product", colX.product);
text(helveticaBold, 9, "Qty", colX.qty);
text(helveticaBold, 9, "Price", colX.price);
text(helveticaBold, 9, "Total", colX.total);
y += 4;
drawLine();
// Line items
for (const item of order.items) {
const lineTotal = typeof item.line_total === "number" ? item.line_total : parseFloat(item.line_total) || 0;
const qty = typeof item.quantity === "number" ? item.quantity : parseFloat(item.quantity) || 0;
const unitPrice = typeof item.unit_price === "number" ? item.unit_price : parseFloat(item.unit_price) || 0;
text(helvetica, 9, (item.product_name ?? "Product").slice(0, 40), colX.product);
text(helvetica, 9, qty.toString(), colX.qty);
text(helvetica, 9, `$${unitPrice.toFixed(2)}`, colX.price);
text(helvetica, 9, `$${lineTotal.toFixed(2)}`, colX.total);
y -= 18;
}
y -= 5;
drawLine();
// Totals
const subtotal = typeof order.subtotal === "number" ? order.subtotal : parseFloat(order.subtotal) || 0;
const depositPaid = typeof order.deposit_paid === "number" ? order.deposit_paid : parseFloat(order.deposit_paid) || 0;
const balanceDue = typeof order.balance_due === "number" ? order.balance_due : parseFloat(order.balance_due) || 0;
y -= 8;
text(helvetica, 10, "Subtotal:", 340);
text(helvetica, 10, `$${subtotal.toFixed(2)}`, 480);
y -= 16;
text(helvetica, 10, "Deposit Paid:", 340);
text(helvetica, 10, `$${depositPaid.toFixed(2)}`, 480);
y -= 16;
page.drawText("Balance Due:", { x: 340, y, font: helveticaBold, size: 11 });
page.drawText(`$${balanceDue.toFixed(2)}`, { x: 480, y, font: helveticaBold, size: 11 });
y -= 30;
drawLine();
// Footer
text(helvetica, 8, "Thank you for your wholesale order.", margin);
y -= 6;
text(helvetica, 8, "Questions? Contact us at the email on file.", margin);
return pdfDoc.save();
}
@@ -0,0 +1,309 @@
import { NextRequest, NextResponse } from "next/server";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
import { svcHeaders } from "@/lib/svc-headers";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ orderId: string }> }
) {
const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
if (!supabaseUrl || !supabaseKey) {
return new NextResponse("Server misconfiguration", { status: 500 });
}
// ── Token-gated download (customer portal) ──────────────────────────────────
if (token) {
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,brand_id`,
{ headers: svcHeaders(supabaseKey) }
);
if (!orderRes.ok || orderRes.status === 204) {
return new NextResponse("Not found", { status: 404 });
}
const tokenOrders = await orderRes.json();
if (!tokenOrders || tokenOrders.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
}
// ── Fetch full order + settings ────────────────────────────────────────────
// Use the RPC for brand-scoped fetch (works for both token + admin paths)
let brandId = "00000000-0000-0000-0000-000000000000";
// First try direct order lookup to get brand_id
const directRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&select=id,brand_id,customer_id`,
{ headers: svcHeaders(supabaseKey) }
);
if (directRes.ok) {
const direct = await directRes.json();
if (direct && direct.length > 0) {
brandId = direct[0].brand_id;
}
}
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!orderRes.ok) {
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);
if (!order) {
return new NextResponse("Order not found", { status: 404 });
}
// Fetch settings for brand header
const settingsRes = 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 settingsData = await settingsRes.json();
const settings = settingsData ?? {};
const pdfBytes = await buildInvoicePdf(order, settings);
return new NextResponse(Buffer.from(pdfBytes), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="invoice-${order.invoice_number ?? orderId.slice(0, 8)}.pdf"`,
},
});
}
async function buildInvoicePdf(
order: {
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;
},
settings: {
invoice_business_name?: string;
invoice_business_address?: string;
invoice_business_phone?: string;
invoice_business_email?: string;
invoice_business_website?: string;
}
): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.create();
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const page = pdfDoc.addPage([612, 792]);
const { width } = page.getSize();
const margin = 50;
const rightEdge = width - margin;
let y = page.getSize().height - margin;
const col = {
product: margin,
qty: 370,
price: 440,
total: 510,
};
function drawText(
text: string,
x: number,
font: typeof helveticaBold,
size: number,
color = rgb(0, 0, 0)
) {
page.drawText(text, { x, y, font, size, color });
y -= size * 1.5;
}
function measureWidth(text: string, font: typeof helvetica, size: number) {
return font.widthOfTextAtSize(text, size);
}
function rightAlign(text: string, font: typeof helvetica, size: number, atX: number) {
const w = measureWidth(text, font, size);
return atX - w;
}
// ── Header ────────────────────────────────────────────────────────────────
const businessName = settings.invoice_business_name || "Wholesale Portal";
drawText(businessName, margin, helveticaBold, 18);
y -= 4;
if (settings.invoice_business_address) {
for (const line of settings.invoice_business_address.split("\n")) {
drawText(line, margin, helvetica, 9);
}
}
if (settings.invoice_business_phone) {
drawText(settings.invoice_business_phone, margin, helvetica, 9);
}
if (settings.invoice_business_email) {
drawText(settings.invoice_business_email, margin, helvetica, 9);
}
if (settings.invoice_business_website) {
drawText(settings.invoice_business_website, margin, helvetica, 9);
}
y -= 12;
// ── Invoice meta block (right side) ─────────────────────────────────────
const invoiceNum = order.invoice_number ?? "—";
const invoiceDate = new Date(order.created_at).toLocaleDateString("en-US", {
year: "numeric", month: "long", day: "numeric",
});
const rightX = 370;
let ry = page.getSize().height - margin;
page.drawText(`Invoice:`, { x: rightX, y: ry, font: helveticaBold, size: 10 });
page.drawText(invoiceNum, { x: rightX + 65, y: ry, font: helvetica, size: 10 });
ry -= 15;
page.drawText(`Date:`, { x: rightX, y: ry, font: helveticaBold, size: 10 });
page.drawText(invoiceDate, { x: rightX + 65, y: ry, font: helvetica, size: 10 });
ry -= 15;
if (order.anticipated_pickup_date) {
page.drawText(`Pickup:`, { x: rightX, y: ry, font: helveticaBold, size: 10 });
page.drawText(order.anticipated_pickup_date, { x: rightX + 65, y: ry, font: helvetica, size: 10 });
}
y = ry - 10;
// ── Divider ──────────────────────────────────────────────────────────────
page.drawLine({
start: { x: margin, y }, end: { x: rightEdge, y },
thickness: 0.5, color: rgb(0.85, 0.85, 0.85),
});
y -= 20;
// ── Bill To ──────────────────────────────────────────────────────────────
drawText("Bill To:", margin, helveticaBold, 10);
y += 4;
drawText(order.company_name, margin, helvetica, 10);
if (order.contact_name) {
drawText(order.contact_name, margin, helvetica, 10);
}
drawText(order.customer_email, margin, helvetica, 10);
if (order.customer_phone) {
drawText(order.customer_phone, margin, helvetica, 10);
}
y -= 15;
// ── Column headers ────────────────────────────────────────────────────────
page.drawLine({
start: { x: margin, y }, end: { x: rightEdge, y },
thickness: 0.5, color: rgb(0.85, 0.85, 0.85),
});
y -= 4;
page.drawText("Product", { x: col.product, y, font: helveticaBold, size: 9 });
page.drawText("Qty", { x: col.qty, y, font: helveticaBold, size: 9 });
page.drawText("Unit Price", { x: col.price, y, font: helveticaBold, size: 9 });
page.drawText("Total", { x: col.total, y, font: helveticaBold, size: 9 });
y -= 18;
page.drawLine({
start: { x: margin, y }, end: { x: rightEdge, y },
thickness: 0.5, color: rgb(0.85, 0.85, 0.85),
});
y -= 8;
// ── Line items ───────────────────────────────────────────────────────────
for (const item of order.items) {
const lineTotal = Number(item.line_total) || 0;
const qty = Number(item.quantity) || 0;
const unitPrice = Number(item.unit_price) || 0;
const name = (item.product_name ?? "Product").substring(0, 42);
page.drawText(name, { x: col.product, y, font: helvetica, size: 9 });
page.drawText(qty.toString(), { x: col.qty, y, font: helvetica, size: 9 });
page.drawText(`$${unitPrice.toFixed(2)}`, { x: col.price, y, font: helvetica, size: 9 });
page.drawText(`$${lineTotal.toFixed(2)}`, { x: col.total, y, font: helvetica, size: 9 });
y -= 16;
}
y -= 8;
page.drawLine({
start: { x: margin, y }, end: { x: rightEdge, y },
thickness: 0.5, color: rgb(0.85, 0.85, 0.85),
});
// ── Totals ────────────────────────────────────────────────────────────────
const subtotal = Number(order.subtotal) || 0;
const depositPaid = Number(order.deposit_paid) || 0;
const balanceDue = Number(order.balance_due) || 0;
y -= 16;
const labelX = 370;
const valueX = 510;
function drawRightLabel(label: string, value: string, font: typeof helvetica, size: number, isBold = false) {
page.drawText(label, { x: labelX, y, font: isBold ? helveticaBold : font, size });
const vw = measureWidth(value, font, size);
page.drawText(value, { x: valueX - vw, y, font: isBold ? helveticaBold : font, size });
y -= size * 1.8;
}
drawRightLabel("Subtotal:", `$${subtotal.toFixed(2)}`, helvetica, 10);
drawRightLabel("Deposit Paid:", `$${depositPaid.toFixed(2)}`, helvetica, 10);
y -= 4;
drawRightLabel("Balance Due:", `$${balanceDue.toFixed(2)}`, helveticaBold, 11, true);
y -= 15;
page.drawLine({
start: { x: margin, y }, end: { x: rightEdge, y },
thickness: 0.5, color: rgb(0.85, 0.85, 0.85),
});
// ── Footer ──────────────────────────────────────────────────────────────
y -= 20;
const footerLines = [
"Thank you for your wholesale order.",
"Questions? Contact us at the email on file.",
`Generated ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
];
for (const line of footerLines) {
page.drawText(line, { x: margin, y, font: helvetica, size: 8, color: rgb(0.45, 0.45, 0.45) });
y -= 12;
}
return pdfDoc.save();
}
+114
View File
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_orders) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const { orders, brandId } = body as {
orders: Array<{
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
anticipated_pickup_date: string | null;
items: Array<{ product_name: string; quantity: number; unit_type: string; line_total: number }>;
subtotal: number;
}>;
brandId?: string;
};
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (adminUser.role === "brand_admin" && brandId && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
if (!orders || orders.length === 0) {
return NextResponse.json({ error: "No orders provided" }, { status: 400 });
}
// Build HTML manifest for print
const rows = orders.map((o, i) => {
const itemsHtml = o.items.map(item => `
<tr>
<td style="padding:4px;border-bottom:1px solid #e5e7eb;">${i + 1}</td>
<td style="padding:4px;border-bottom:1px solid #e5e7eb;">${o.invoice_number ?? o.id.slice(0, 8)}</td>
<td style="padding:4px;border-bottom:1px solid #e5e7eb;">${o.company_name}</td>
<td style="padding:4px;border-bottom:1px solid #e5e7eb;">${item.product_name ?? ""}</td>
<td style="padding:4px;border-bottom:1px solid #e5e7eb;text-align:right;">${item.quantity} ${item.unit_type ?? ""}</td>
<td style="padding:4px;border-bottom:1px solid #e5e7eb;text-align:right;">$${(typeof item.line_total === "number" ? item.line_total : parseFloat(item.line_total as string) || 0).toFixed(2)}</td>
<td style="padding:4px;border-bottom:1px solid #e5e7eb;">${o.anticipated_pickup_date ?? "—"}</td>
</tr>
`).join("");
return itemsHtml;
}).join("");
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Load Manifest</title>
<style>
body { font-family: Helvetica, Arial, sans-serif; margin: 24px; color: #1f2937; }
h1 { font-size: 20px; margin-bottom: 4px; }
.meta { color: #6b7280; font-size: 12px; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th { background: #f3f4f6; padding: 6px 4px; text-align: left; border: 1px solid #d1d5db; }
td { padding: 4px; }
.footer { margin-top: 24px; font-size: 11px; color: #9ca3af; }
@media print { body { margin: 0; } }
</style>
</head>
<body>
<h1>Load Manifest</h1>
<p class="meta">Generated: ${new Date().toLocaleString()} &nbsp;|&nbsp; ${orders.length} order(s)</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Invoice</th>
<th>Customer</th>
<th>Product</th>
<th style="text-align:right;">Qty</th>
<th style="text-align:right;">Total</th>
<th>Pickup Date</th>
</tr>
</thead>
<tbody>
${orders.map((o, i) => {
const itemsHtml = o.items.map(item => `
<tr>
<td style="padding:4px;">${i + 1}</td>
<td style="padding:4px;">${o.invoice_number ?? o.id.slice(0, 8)}</td>
<td style="padding:4px;">${o.company_name}</td>
<td style="padding:4px;">${item.product_name ?? ""}</td>
<td style="padding:4px;text-align:right;">${item.quantity} ${item.unit_type ?? ""}</td>
<td style="padding:4px;text-align:right;">$${(typeof item.line_total === "number" ? item.line_total : parseFloat(item.line_total as string) || 0).toFixed(2)}</td>
<td style="padding:4px;">${o.anticipated_pickup_date ?? "—"}</td>
</tr>
`).join("");
return itemsHtml;
}).join("")}
</tbody>
</table>
<div class="footer">
Route Commerce Wholesale Portal — Load Manifest
</div>
<script>window.print();</script>
</body>
</html>`;
return new NextResponse(html, {
headers: {
"Content-Type": "text/html",
"Content-Disposition": "inline; filename=manifest.html",
},
});
}
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
// POST /api/wholesale/notifications/pickup-reminder
// Scans for fulfilled orders past their anticipated pickup date that haven't been
// picked up, and enqueues unclaimed_pickup notifications for each.
// Designed to be called daily via cron or from the notification send loop.
export const dynamic = "force-dynamic";
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
const ordersRes = await fetch(
`${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;
brand_id: string;
customer_id: string;
invoice_number: string | null;
anticipated_pickup_date: string;
pickup_location: string | null;
customer_email: string;
notification_email: string | null;
from_email: string | null;
invoice_business_email: string | null;
}>;
if (overdueOrders.length === 0) {
return NextResponse.json({ message: "No overdue orders found.", enqueued: 0 });
}
let enqueued = 0;
let skipped = 0;
for (const order of overdueOrders) {
if (!order.customer_email) {
skipped++;
continue;
}
const adminEmail =
order.notification_email ?? order.from_email ?? order.invoice_business_email;
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: order.brand_id,
p_customer_id: order.customer_id,
p_order_id: order.id,
p_type: "unclaimed_pickup",
p_email_to: order.customer_email,
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>
<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>` : ""}
<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.`,
}),
}
);
if (enqueueRes.ok) {
enqueued++;
} else {
skipped++;
}
}
return NextResponse.json({
message: `Pickup reminder scan complete.`,
overdue_count: overdueOrders.length,
enqueued,
skipped,
});
}
@@ -0,0 +1,195 @@
import { NextResponse } from "next/server";
import { getWholesalePendingNotifications, markWholesaleNotificationSent } from "@/actions/wholesale";
import { svcHeaders } from "@/lib/svc-headers";
import type { NotificationRecipient } from "@/actions/wholesale";
// POST /api/wholesale/notifications/send
// Processes pending customer notifications and dispatches copies to all active
// notification_recipients for each brand.
// Falls back to notification_email / from_email if no active recipients are configured.
export async function POST() {
const resendApiKey = process.env.RESEND_API_KEY;
if (!resendApiKey) {
return NextResponse.json(
{ message: "RESEND_API_KEY not configured — notifications are queued but not sent.", queued: 0, sent: 0 },
{ status: 200 }
);
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
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;
type: string;
email_to: string;
email_cc: string | null;
subject: string;
body_html: string | null;
body_text: string | null;
brand_id: string;
order_id: string | null;
customer_id: string;
invoice_business_email: string | null;
}>;
if (notifications.length === 0) {
return NextResponse.json({ message: "No pending notifications.", queued: 0, sent: 0 });
}
// Prefetch settings for each unique brand so we can resolve notification_recipients
const brandIds = [...new Set(notifications.map(n => n.brand_id))];
const brandSettingsMap: Record<string, {
notification_recipients: NotificationRecipient[];
notification_email: string | null;
from_email: string | null;
invoice_business_email: string | null;
}> = {};
await Promise.all(brandIds.map(async (bid) => {
const r = await fetch(`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: bid }),
});
if (r.ok) {
const data = await r.json();
brandSettingsMap[bid] = {
notification_recipients: data?.notification_recipients ?? [],
notification_email: data?.notification_email ?? null,
from_email: data?.from_email ?? null,
invoice_business_email: data?.invoice_business_email ?? null,
};
}
}));
let sent = 0;
let failed = 0;
for (const n of notifications) {
if (!n.email_to) {
await markWholesaleNotificationSent(n.id, "No email_to address");
failed++;
continue;
}
const settings = brandSettingsMap[n.brand_id];
const activeRecipients = (settings?.notification_recipients ?? [])
.filter(r => r.active);
// ── Fallback admin email if no active recipients ───────────────────────────
const fallbackAdminEmail =
settings?.notification_email
?? settings?.from_email
?? settings?.invoice_business_email
?? null;
const fromEmail = n.invoice_business_email ?? "wholesale@routecommerce.com";
// ── Send to customer ───────────────────────────────────────────────────────
const customerOk = await sendOneEmail(resendApiKey, fromEmail, n.email_to, n.email_cc ?? undefined, n.subject, n.body_html, n.body_text);
if (customerOk) {
await markWholesaleNotificationSent(n.id);
sent++;
} else {
await markWholesaleNotificationSent(n.id, "Resend error");
failed++;
continue;
}
// ── Send to notification recipients ───────────────────────────────────────
const recipients = activeRecipients.length > 0
? activeRecipients
: fallbackAdminEmail ? [{ email: fallbackAdminEmail, name: undefined, active: true }] : [];
for (const recipient of recipients) {
// Skip the customer email if they happen to also be a recipient (dedup)
if (recipient.email === n.email_to) continue;
const toAddress = recipient.name
? `"${recipient.name}" <${recipient.email}>`
: recipient.email;
const ok = await sendOneEmail(resendApiKey, fromEmail, toAddress, undefined, n.subject, n.body_html, n.body_text);
// Log a separate notification entry for audit trail
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: n.brand_id,
p_customer_id: n.customer_id,
p_order_id: n.order_id ?? null,
p_type: n.type,
p_email_to: recipient.email,
p_email_cc: null,
p_subject: n.subject,
p_body_html: n.body_html,
p_body_text: n.body_text,
}),
});
if (ok) sent++; else failed++;
}
}
await triggerPickupReminder();
return NextResponse.json({
message: `Processed ${notifications.length} notification(s).`,
sent,
failed,
});
}
async function sendOneEmail(
apiKey: string,
from: string,
to: string,
cc: string | undefined,
subject: string,
html: string | null,
text: string | null
): Promise<boolean> {
try {
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ from, to, cc: cc ?? undefined, subject, html: html ?? undefined, text: text ?? undefined }),
});
return res.ok;
} catch {
return false;
}
}
async function triggerPickupReminder() {
try {
await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL!}/api/wholesale/notifications/pickup-reminder`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
} catch {}
}
export async function GET() {
const resendApiKey = process.env.RESEND_API_KEY;
return NextResponse.json({
email_provider: resendApiKey ? "resend" : "not_configured",
});
}
+188
View File
@@ -0,0 +1,188 @@
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";
export const dynamic = "force-dynamic";
// POST /api/wholesale/price-sheet
// Generates an HTML price sheet and enqueues it as a price_sheet notification
// for one or more wholesale customers.
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_settings && !adminUser.can_manage_products) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const { customerIds, brandId, subject, customNote } = body as {
customerIds: string[];
brandId: string;
subject?: string;
customNote?: string;
};
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
if (!customerIds?.length || !effectiveBrandId) {
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) {
return NextResponse.json({ error: "Brand settings not found" }, { status: 404 });
}
// Fetch products for this brand
const products = await getWholesaleProducts(effectiveBrandId);
if (products.length === 0) {
return NextResponse.json({ error: "No products found for this brand" }, { status: 404 });
}
const brandName = settings.invoice_business_name ?? "Wholesale";
const pickupLocation = settings.pickup_location;
const generatedDate = new Date().toLocaleDateString("en-US", {
year: "numeric", month: "long", day: "numeric"
});
const emailSubject = subject?.trim() || `${brandName} Wholesale Price Sheet — ${generatedDate}`;
const hasCustomNote = Boolean(customNote?.trim());
// Build product rows HTML
const productRows = products
.filter(p => p.availability === "available")
.map(p => {
const tiersHtml = p.price_tiers
.map(t => {
const max = t.max_qty === 0 ? "+" : `-${t.max_qty}`;
return `${t.min_qty}${max}: $${Number(t.price).toFixed(2)}`;
})
.join("&nbsp;|&nbsp;");
return `
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 10px 12px; font-weight: 500; color: #1e293b;">${p.name}</td>
<td style="padding: 10px 12px; color: #64748b; font-size: 13px;">${p.description ?? "—"}</td>
<td style="padding: 10px 12px; color: #64748b; font-size: 13px; text-align: center;">${p.unit_type}</td>
<td style="padding: 10px 12px; color: #1e293b; font-size: 13px; text-align: right; font-family: monospace;">${tiersHtml}</td>
</tr>`;
})
.join("");
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>${brandName} Wholesale Price Sheet</title>
</head>
<body style="margin: 0; padding: 0; background: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<div style="max-width: 700px; margin: 30px auto; background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<!-- Header -->
<div style="background: #1e293b; padding: 28px 36px;">
<h1 style="margin: 0; color: #ffffff; font-size: 22px; font-weight: 700;">${brandName}</h1>
<p style="margin: 6px 0 0; color: #94a3b8; font-size: 14px;">Wholesale Price Sheet &mdash; ${generatedDate}</p>
${pickupLocation ? `<p style="margin: 8px 0 0; color: #94a3b8; font-size: 13px;"><strong>Pickup:</strong> ${pickupLocation}</p>` : ""}
</div>
<!-- Intro -->
<div style="padding: 20px 36px 0; border-bottom: 1px solid #f1f5f9;">
${hasCustomNote ? ` <p style="margin: 0 0 12px; color: #1e293b; font-size: 14px; font-weight: 600; line-height: 1.5;">${customNote!.trim()}</p>` : ""}
<p style="margin: ${hasCustomNote ? "0 0 12px" : "0 0 12px"}; color: #475569; font-size: 14px; line-height: 1.5;">
Thank you for your wholesale account. Below is our current product availability and wholesale pricing.
Prices shown are per unit. Contact us if you have questions about placing an order.
</p>
</div>
<!-- Product Table -->
<div style="padding: 20px 36px;">
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<thead>
<tr style="background: #f8fafc;">
<th style="padding: 8px 12px; text-align: left; color: #475569; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Product</th>
<th style="padding: 8px 12px; text-align: left; color: #475569; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Description</th>
<th style="padding: 8px 12px; text-align: center; color: #475569; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Unit</th>
<th style="padding: 8px 12px; text-align: right; color: #475569; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Price Tiers</th>
</tr>
</thead>
<tbody>${productRows}</tbody>
</table>
</div>
<!-- Footer -->
<div style="padding: 16px 36px; background: #f8fafc; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.5;">
Prices subject to change. This price sheet was generated on ${generatedDate}. Contact your wholesale representative with any questions.
</p>
</div>
</div>
</body>
</html>`;
const text = `${brandName} Wholesale Price Sheet — ${generatedDate}\n${"=".repeat(50)}\n${hasCustomNote ? `\n${customNote!.trim()}\n\n` : ""}${products.filter(p => p.availability === "available").map(p => {
const tiers = p.price_tiers.map(t => `${t.min_qty}${t.max_qty === 0 ? "+" : `-${t.max_qty}`}: $${Number(t.price).toFixed(2)}`).join(", ");
return `${p.name} (${p.unit_type})\n ${tiers}`;
}).join("\n\n")}\n\nGenerated ${generatedDate}. Prices subject to change.`;
let enqueued = 0;
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) } }
);
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) {
enqueued++;
} else {
failed++;
}
}
// Fire-and-forget trigger to process the queue
fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL!}/api/wholesale/notifications/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
}).catch(() => {});
return NextResponse.json({ enqueued, failed, total: customerIds.length });
}
@@ -0,0 +1,115 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { svcHeaders } from "@/lib/svc-headers";
// POST /api/wholesale/webhooks/dispatch
// 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.
export async function POST() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
if (!supabaseUrl || !serviceRoleKey) {
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
// Fetch pending webhooks
const pendingRes = await fetch(
`${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;
brand_id: string;
event_type: string;
order_id: string | null;
payload: Record<string, unknown> | null;
attempts: number;
url: string;
secret: string;
}>;
if (!pending || pending.length === 0) {
return NextResponse.json({ message: "No pending webhooks.", dispatched: 0 });
}
let dispatched = 0;
for (const webhook of pending) {
const payload = webhook.payload ?? {};
const payloadString = JSON.stringify(payload);
// Build HMAC-SHA256 signature: HMAC(secret, payload_string)
const signature = crypto
.createHmac("sha256", webhook.secret)
.update(payloadString)
.digest("hex");
try {
const res = await fetch(webhook.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Signature": `sha256=${signature}`,
"X-Webhook-Event": webhook.event_type,
},
body: payloadString,
});
const responseText = await res.text().catch(() => "");
if (res.ok) {
await markSent(webhook.id, serviceRoleKey, supabaseUrl, `HTTP ${res.status}: ${responseText.slice(0, 200)}`);
dispatched++;
} else {
await markFailed(webhook.id, serviceRoleKey, supabaseUrl, `HTTP ${res.status}: ${responseText.slice(0, 200)}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : "Network error";
await markFailed(webhook.id, serviceRoleKey, supabaseUrl, msg);
}
}
return NextResponse.json({ message: `Dispatched ${dispatched}/${pending.length} webhook(s).`, dispatched });
}
async function markSent(logId: string, key: string, url: string, response: string) {
await fetch(
`${url}/rest/v1/rpc/mark_webhook_sent`,
{
method: "POST",
headers: { ...svcHeaders(key), "Content-Type": "application/json" },
body: JSON.stringify({ p_log_id: logId, p_response: response }),
}
);
}
async function markFailed(logId: string, key: string, url: string, response: string) {
await fetch(
`${url}/rest/v1/rpc/mark_webhook_failed`,
{
method: "POST",
headers: { ...svcHeaders(key), "Content-Type": "application/json" },
body: JSON.stringify({ p_log_id: logId, p_response: response }),
}
);
}
export async function GET() {
return NextResponse.json({
description: "POST to process pending webhook events",
events: ["order_created", "order_fulfilled", "deposit_recorded", "order_paid"],
});
}