Initial commit - Route Commerce platform
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)",
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()} | ${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",
|
||||
});
|
||||
}
|
||||
@@ -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(" | ");
|
||||
|
||||
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 — ${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"],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user