migrate: replace Supabase REST with Drizzle/pg in water-log/time-tracking/reports/etc (wave 4)

This commit is contained in:
2026-06-07 03:05:00 +00:00
parent 01198111ea
commit b8317a200e
16 changed files with 346 additions and 545 deletions
+15 -21
View File
@@ -1,7 +1,7 @@
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";
import { pool } from "@/lib/db";
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.
@@ -82,40 +82,34 @@ Use "recent_orders" for recent order questions.`;
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 };
let rpcArgs: unknown[] = [effectiveBrandId, days];
if (queryType === "dormant") {
rpcName = "get_dormant_customers_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
rpcArgs = [effectiveBrandId, days];
} else if (queryType === "trending") {
rpcName = "get_trending_products_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
rpcArgs = [effectiveBrandId, days];
} else if (queryType === "top_customers") {
rpcName = "get_top_customers_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
rpcArgs = [effectiveBrandId, days];
} else if (queryType === "at_risk") {
rpcName = "get_at_risk_customers_insights";
rpcParams = { p_brand_id: effectiveBrandId };
rpcArgs = [effectiveBrandId];
}
const dbResponse = await fetch(
`${supabaseUrl}/rest/v1/rpc/${rpcName}`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey!), "Content-Type": "application/json" },
body: JSON.stringify(rpcParams),
}
);
let results: unknown[] = [];
if (dbResponse.ok) {
const data = await dbResponse.json();
try {
const { rows } = await pool.query(
`SELECT ${rpcName}(${rpcArgs.map((_, i) => `$${i + 1}`).join(", ")}) AS result`,
rpcArgs,
);
const data = rows[0]?.result;
results = Array.isArray(data) ? data.slice(0, 100) : [];
} catch {
results = [];
}
return NextResponse.json({
@@ -129,4 +123,4 @@ Use "recent_orders" for recent order questions.`;
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
}
+23 -46
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { emailLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -44,44 +45,31 @@ export async function POST(req: NextRequest) {
// Parse and validate body
const body = await req.json();
const validation = createCampaignSchema.safeParse(body);
if (!validation.success) {
return validationError(validation.error);
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Create campaign via RPC
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_campaign`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
p_brand_id: validation.data.brand_id,
p_name: validation.data.name,
p_subject: validation.data.subject,
p_content: validation.data.content,
p_type: validation.data.type,
p_segment_id: validation.data.segment_id,
p_contact_ids: validation.data.contact_ids,
p_scheduled_at: validation.data.scheduled_at,
}),
}
const { rows: campaignRows } = await pool.query<{ create_campaign: { id: string; [k: string]: unknown } | null }>(
`SELECT create_campaign($1, $2, $3, $4, $5, $6, $7::uuid[], $8) AS create_campaign`,
[
validation.data.brand_id,
validation.data.name,
validation.data.subject,
validation.data.content,
validation.data.type,
validation.data.segment_id ?? null,
validation.data.contact_ids ?? null,
validation.data.scheduled_at ?? null,
],
);
if (!res.ok) {
const error = await res.json();
return apiError(error.message || "Failed to create campaign", 500);
const campaign = campaignRows[0]?.create_campaign;
if (!campaign) {
return apiError("Failed to create campaign", 500);
}
const campaign = await res.json();
// Track analytics
const audienceSize = validation.data.contact_ids?.length || 0;
analytics.campaignCreated(campaign.id, validation.data.type, audienceSize);
@@ -102,24 +90,13 @@ export async function GET(req: NextRequest) {
return apiError("brand_id is required", 400);
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/communication_campaigns?brand_id=eq.${brand_id}&select=*&order=created_at.desc`,
{
headers: {
apikey: anonKey,
"Content-Type": "application/json",
},
}
const { rows: campaigns } = await pool.query(
`SELECT * FROM communication_campaigns
WHERE brand_id = $1
ORDER BY created_at DESC`,
[brand_id],
);
if (!res.ok) {
return apiError("Failed to fetch campaigns", 500);
}
const campaigns = await res.json();
return apiResponse(campaigns);
} catch (error) {
captureError(error as Error, { path: "/api/campaigns", method: "GET" });
@@ -137,4 +114,4 @@ export async function OPTIONS() {
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
}
+18 -42
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -38,39 +39,25 @@ export async function POST(req: NextRequest) {
const body = await req.json();
const validation = createReferralSchema.safeParse(body);
if (!validation.success) {
return validationError(validation.error);
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Redeem referral via RPC
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/redeem_referral`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
p_brand_id: validation.data.brand_id,
p_referred_email: validation.data.referred_email,
p_referral_code: validation.data.referral_code,
}),
}
const { rows } = await pool.query<{ redeem_referral: { referred_user_id?: string; [k: string]: unknown } | null }>(
`SELECT redeem_referral($1, $2, $3) AS redeem_referral`,
[
validation.data.brand_id,
validation.data.referred_email,
validation.data.referral_code,
],
);
if (!res.ok) {
const error = await res.json();
return apiError(error.message || "Failed to redeem referral", 500);
const referral = rows[0]?.redeem_referral;
if (!referral) {
return apiError("Failed to redeem referral", 500);
}
const referral = await res.json();
analytics.referralCompleted(validation.data.referral_code, referral.referred_user_id);
return apiResponse(referral, 201);
@@ -89,24 +76,13 @@ export async function GET(req: NextRequest) {
return apiError("brand_id is required", 400);
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/referral_codes?brand_id=eq.${brand_id}&select=*&order=created_at.desc`,
{
headers: {
apikey: anonKey,
"Content-Type": "application/json",
},
}
const { rows: referrals } = await pool.query(
`SELECT * FROM referral_codes
WHERE brand_id = $1
ORDER BY created_at DESC`,
[brand_id],
);
if (!res.ok) {
return apiError("Failed to fetch referrals", 500);
}
const referrals = await res.json();
return apiResponse(referrals);
} catch (error) {
captureError(error as Error, { path: "/api/referrals", method: "GET" });
@@ -124,4 +100,4 @@ export async function OPTIONS() {
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
}
+12 -24
View File
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -50,34 +51,21 @@ export async function GET(req: NextRequest) {
return validationError(validation.error);
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get report via RPC
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/generate_report`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
p_brand_id: validation.data.brand_id,
p_start_date: validation.data.start_date,
p_end_date: validation.data.end_date,
p_report_type: validation.data.report_type,
}),
}
const { rows } = await pool.query<{ generate_report: unknown }>(
`SELECT generate_report($1, $2::timestamptz, $3::timestamptz, $4) AS generate_report`,
[
validation.data.brand_id,
validation.data.start_date,
validation.data.end_date,
validation.data.report_type,
],
);
if (!res.ok) {
const report = rows[0]?.generate_report;
if (report == null) {
return apiError("Failed to generate report", 500);
}
const report = await res.json();
return apiResponse(report);
} catch (error) {
captureError(error as Error, { path: "/api/reports", method: "GET" });
@@ -95,4 +83,4 @@ export async function OPTIONS() {
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
}
+24 -47
View File
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -43,44 +44,30 @@ export async function POST(req: NextRequest) {
// Parse and validate body
const body = await req.json();
const validation = createWaterLogSchema.safeParse(body);
if (!validation.success) {
return validationError(validation.error);
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Create water log via RPC
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_water_log`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
p_brand_id: validation.data.brand_id,
p_field_id: validation.data.field_id,
p_field_name: validation.data.field_name,
p_gallons: validation.data.gallons,
p_duration_minutes: validation.data.duration_minutes,
p_water_method: validation.data.water_method,
p_notes: validation.data.notes,
p_logged_at: validation.data.logged_at,
}),
}
const { rows } = await pool.query<{ create_water_log: { id: string; [k: string]: unknown } | null }>(
`SELECT create_water_log($1, $2, $3, $4, $5, $6, $7, $8) AS create_water_log`,
[
validation.data.brand_id,
validation.data.field_id ?? null,
validation.data.field_name ?? null,
validation.data.gallons,
validation.data.duration_minutes ?? null,
validation.data.water_method ?? null,
validation.data.notes ?? null,
validation.data.logged_at ?? null,
],
);
if (!res.ok) {
const error = await res.json();
return apiError(error.message || "Failed to create water log", 500);
const waterLog = rows[0]?.create_water_log;
if (!waterLog) {
return apiError("Failed to create water log", 500);
}
const waterLog = await res.json();
return apiResponse(waterLog, 201);
} catch (error) {
captureError(error as Error, { path: "/api/water-logs", method: "POST" });
@@ -97,24 +84,14 @@ export async function GET(req: NextRequest) {
return apiError("brand_id is required", 400);
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/water_logs?brand_id=eq.${brand_id}&select=*&order=logged_at.desc&limit=100`,
{
headers: {
apikey: anonKey,
"Content-Type": "application/json",
},
}
const { rows: waterLogs } = await pool.query(
`SELECT * FROM water_logs
WHERE brand_id = $1
ORDER BY logged_at DESC
LIMIT 100`,
[brand_id],
);
if (!res.ok) {
return apiError("Failed to fetch water logs", 500);
}
const waterLogs = await res.json();
return apiResponse(waterLogs);
} catch (error) {
captureError(error as Error, { path: "/api/water-logs", method: "GET" });
@@ -132,4 +109,4 @@ export async function OPTIONS() {
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
}
+16 -23
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
export async function POST(request: Request) {
try {
@@ -9,34 +9,27 @@ export async function POST(request: Request) {
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 settingsRes = await pool.query<{
get_water_admin_settings: { enabled: boolean; session_duration_hours?: number } | null;
}>(
`SELECT get_water_admin_settings($1) AS "get_water_admin_settings"`,
[brandId],
);
const settings = await settingsRes.json();
if (!settingsRes.ok || !settings?.enabled) {
const settings = settingsRes.rows[0]?.get_water_admin_settings;
if (!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 verifyRes = await pool.query<{
verify_water_admin_pin: { success: boolean; session_id?: string } | null;
}>(
`SELECT verify_water_admin_pin($1, $2) AS "verify_water_admin_pin"`,
[brandId, pin],
);
const verifyData = await verifyRes.json();
if (!verifyRes.ok || !verifyData?.success) {
const verifyData = verifyRes.rows[0]?.verify_water_admin_pin;
if (!verifyData?.success || !verifyData.session_id) {
return NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 });
}
@@ -56,4 +49,4 @@ export async function POST(request: Request) {
} catch {
return NextResponse.json({ success: false, error: "Server error" }, { status: 500 });
}
}
}
+16 -21
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
export async function GET(request: NextRequest) {
const adminUser = await getAdminUser();
@@ -18,31 +18,26 @@ export async function GET(request: NextRequest) {
// Use brand_id from session (always Tuxedo for water log) or fallback to env
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!;
type WaterEntry = {
id: string;
user_id: string | null;
headgate_id: string | null;
measurement: number | null;
unit: string | null;
notes: string | null;
created_at: string;
};
const response = await fetch(
`${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 }),
}
const { rows: data } = await pool.query<{ get_water_entries: WaterEntry[] | null }>(
`SELECT get_water_entries($1, $2) AS "get_water_entries"`,
[brandId, 10000],
);
if (!response.ok) {
return NextResponse.json({ error: "Failed to fetch water log" }, { status: 500 });
}
const data = await response.json();
const entries = data[0]?.get_water_entries ?? [];
if (format === "csv") {
const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"];
const csvRows = [headers.join(",")];
for (const row of data) {
for (const row of entries) {
csvRows.push([
row.id,
row.user_id ?? "",
@@ -61,5 +56,5 @@ export async function GET(request: NextRequest) {
});
}
return NextResponse.json(data);
return NextResponse.json(entries);
}
+5 -27
View File
@@ -5,16 +5,9 @@ import Link from "next/link";
import { useCart } from "@/context/CartContext";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import { getPublicStopsForBrand, checkStopProductAvailability, type PublicStop } from "@/actions/checkout";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
type Stop = PublicStop;
export default function CartClient() {
const {
@@ -54,11 +47,7 @@ export default function CartClient() {
if (hasPickupItems && showStopPicker && cartBrandId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoadingStops(true);
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
)
.then((r) => r.json())
getPublicStopsForBrand(cartBrandId)
.then((data) => setStops(data ?? []))
.catch(() => setStops([]))
.finally(() => setLoadingStops(false));
@@ -74,19 +63,8 @@ export default function CartClient() {
if (hasPickupItems) {
const pickupProductIds = cart.filter((i) => i.fulfillment === "pickup").map((i) => i.id);
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/check_stop_product_availability`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stop.id, p_product_ids: pickupProductIds }),
}
)
.then((r) => r.json())
.then((data: { product_id: string; is_available: boolean }[]) => {
checkStopProductAvailability(stop.id, pickupProductIds)
.then((data) => {
const unavailable = (data ?? [])
.filter((row) => row.is_available === false)
.map((row) => row.product_id);
+2 -9
View File
@@ -6,6 +6,7 @@ import Link from "next/link";
import { useCart } from "@/context/CartContext";
import type { StopInfo } from "@/context/CartContext";
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
import { getPublicStopsForBrand } from "@/actions/checkout";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import StripeExpressCheckout, {
@@ -43,15 +44,7 @@ export default function CheckoutClient() {
useEffect(() => {
if (!cartBrandId) return;
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
{
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
}
)
.then((r) => r.json())
getPublicStopsForBrand(cartBrandId)
.then((data) => setStops(data ?? []))
.catch(() => setStops([]));
}, [cartBrandId]);
+2 -21
View File
@@ -1,7 +1,6 @@
import { notFound } from "next/navigation";
import { getTraceChain } from "@/actions/route-trace/lots";
import { getTraceChain, getLotIdByNumber } from "@/actions/route-trace/lots";
import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
import { svcHeaders } from "@/lib/svc-headers";
export async function generateMetadata({ params }: { params: Promise<{ lotNumber: string }> }) {
const { lotNumber } = await params;
@@ -70,25 +69,7 @@ function FieldIcon({ className }: { className?: string }) {
export default async function TracePage({ params }: { params: Promise<{ lotNumber: string }> }) {
const { lotNumber } = await params;
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
let lotId: string | null = null;
try {
const res = await fetch(
`${SUPABASE_URL}/rest/v1/harvest_lots?lot_number=eq.${encodeURIComponent(lotNumber)}&select=id`,
{
headers: {
...svcHeaders(SUPABASE_PAT),
"Content-Type": "application/json",
},
next: { revalidate: 60 },
}
);
const data = await res.json();
lotId = data?.[0]?.id ?? null;
} catch (_) {}
const lotId = await getLotIdByNumber(lotNumber);
if (!lotId) notFound();
const result = await getTraceChain(lotId);