From b8317a200ed0d70ea0dbf79d1db8d36623bdf1f8 Mon Sep 17 00:00:00 2001 From: default Date: Sun, 7 Jun 2026 03:05:00 +0000 Subject: [PATCH] migrate: replace Supabase REST with Drizzle/pg in water-log/time-tracking/reports/etc (wave 4) --- src/actions/admin/audit.ts | 33 ++--- src/actions/admin/reset-admin.ts | 55 +++----- src/actions/brands.ts | 53 +++---- src/actions/checkout.ts | 162 +++++++++++----------- src/actions/platform/command-center.ts | 90 ++++-------- src/app/api/ai/customer-insights/route.ts | 36 ++--- src/app/api/v1/campaigns/route.ts | 69 +++------ src/app/api/v1/referrals/route.ts | 60 +++----- src/app/api/v1/reports/route.ts | 36 ++--- src/app/api/v1/water-logs/route.ts | 71 ++++------ src/app/api/water-admin-auth/route.ts | 39 +++--- src/app/api/water-logs/export/route.ts | 37 +++-- src/app/cart/CartClient.tsx | 32 +---- src/app/checkout/CheckoutClient.tsx | 11 +- src/app/trace/[lotNumber]/page.tsx | 23 +-- src/lib/admin-permissions-service.ts | 84 ++++++----- 16 files changed, 346 insertions(+), 545 deletions(-) diff --git a/src/actions/admin/audit.ts b/src/actions/admin/audit.ts index 39465a2..61bb51f 100644 --- a/src/actions/admin/audit.ts +++ b/src/actions/admin/audit.ts @@ -1,15 +1,6 @@ "use server"; -import { createClient as createServiceClient } from "@supabase/supabase-js"; - -function getServiceClient() { - const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set"); - return createServiceClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - roleKey, - ); -} +import { pool } from "@/lib/db"; type AdminActionPayload = { action_type: "create" | "update" | "delete"; @@ -30,35 +21,33 @@ type UserActivityPayload = { export async function logAdminAction(payload: AdminActionPayload): Promise { try { - const service = getServiceClient(); - await service.rpc("log_admin_action", { - p_payload: { + await pool.query("SELECT log_admin_action($1::jsonb)", [ + JSON.stringify({ action_type: payload.action_type, admin_id: payload.admin_id ?? null, admin_email: payload.admin_email ?? null, affected_user_id: payload.affected_user_id ?? null, brand_id: payload.brand_id ?? null, details: payload.details ?? {}, - }, - }); - } catch (e) { + }), + ]); + } catch { // logging failed silently } } export async function logUserActivity(payload: UserActivityPayload): Promise { try { - const service = getServiceClient(); - await service.rpc("log_user_activity", { - p_payload: { + await pool.query("SELECT log_user_activity($1::jsonb)", [ + JSON.stringify({ user_id: payload.user_id, activity_type: payload.activity_type, details: payload.details ?? {}, ip_address: payload.ip_address ?? null, user_agent: payload.user_agent ?? null, - }, - }); - } catch (e) { + }), + ]); + } catch { // logging failed silently } } diff --git a/src/actions/admin/reset-admin.ts b/src/actions/admin/reset-admin.ts index 3607240..a77cff7 100644 --- a/src/actions/admin/reset-admin.ts +++ b/src/actions/admin/reset-admin.ts @@ -1,15 +1,6 @@ "use server"; -import { createClient as createServiceClient } from "@supabase/supabase-js"; - -function getServiceClient() { - const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set"); - return createServiceClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - roleKey, - ); -} +import { query } from "@/lib/db"; export type ResetAdminPasswordResult = | { success: true; tempPassword: string } @@ -17,35 +8,35 @@ export type ResetAdminPasswordResult = /** * Emergency recovery action — only usable in development or when the caller - * already has service role access. Resets the password for the specified email + * already has direct DB access. Resets the password for the specified email * and returns the temp password so it can be displayed to the user. + * + * Now that Supabase auth is gone, this hits the `users` table directly and + * calls the same `update_user_password` SECURITY DEFINER RPC used by the + * self-service password change action. */ export async function resetAdminPassword( email: string, newPassword: string ): Promise { - const service = getServiceClient(); - - // Look up auth user by email - const { data: authUsers, error: listError } = await service.auth.admin.listUsers(); - if (listError || !authUsers?.users) { - return { success: false, error: "Could not list users: " + listError?.message }; - } - - const authUser = authUsers.users.find((u) => u.email?.toLowerCase() === email.toLowerCase()); - if (!authUser) { + // Look up the user by email + const { rows } = await query<{ id: string }>( + "SELECT id FROM users WHERE email = $1 LIMIT 1", + [email.toLowerCase()], + ); + const user = rows[0]; + if (!user) { return { success: false, error: "No auth user found for that email address." }; } - // Update password via service role - const { error: updateError } = await service.auth.admin.updateUserById( - authUser.id, - { password: newPassword, email_confirm: true } - ); - - if (updateError) { - return { success: false, error: updateError.message }; + // Update password via SECURITY DEFINER RPC + try { + await query("SELECT update_user_password($1, $2)", [user.id, newPassword]); + return { success: true, tempPassword: newPassword }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Failed to update password", + }; } - - return { success: true, tempPassword: newPassword }; -} \ No newline at end of file +} diff --git a/src/actions/brands.ts b/src/actions/brands.ts index fdd5866..15dd021 100644 --- a/src/actions/brands.ts +++ b/src/actions/brands.ts @@ -1,7 +1,7 @@ import "server-only"; import { getAdminUser } from "@/lib/admin-permissions"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; export type BrandListItem = { id: string; @@ -16,49 +16,28 @@ export type BrandListItem = { * - platform_admin: all brands (queried directly from the `brands` table) * - everyone else: brands in `adminUser.brand_ids` * - empty array for unauthenticated / no-access admins - * - * This is a plain async function (not a server action) so it can be called - * from server components and server actions without the "use server" wrapper. - * The BrandSelector client component receives the result as a prop. */ export async function listBrandsForAdmin(): Promise { const adminUser = await getAdminUser(); if (!adminUser) return []; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!supabaseUrl || !serviceKey) return []; - - if (adminUser.role === "platform_admin") { - try { - const res = await fetch( - `${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`, - { headers: svcHeaders(serviceKey), cache: "no-store" } - ); - if (!res.ok) return []; - const data = await res.json(); - return Array.isArray(data) ? data : []; - } catch { - return []; - } - } - - if (adminUser.brand_ids.length === 0) return []; - - // Use PostgREST `in` filter. The brand_ids are UUIDs, so the quoting - // pattern in the spec is safe; the inner quotes are required by PostgREST - // for UUID literals. - const filter = `id=in.(${adminUser.brand_ids - .map((id) => `"${id}"`) - .join(",")})`; try { - const res = await fetch( - `${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`, - { headers: svcHeaders(serviceKey), cache: "no-store" } + if (adminUser.role === "platform_admin") { + const { rows } = await pool.query( + `SELECT id, name, slug, logo_url FROM brands ORDER BY name`, + ); + return rows; + } + + if (adminUser.brand_ids.length === 0) return []; + + const { rows } = await pool.query( + `SELECT id, name, slug, logo_url FROM brands + WHERE id = ANY($1::uuid[]) + ORDER BY name`, + [adminUser.brand_ids], ); - if (!res.ok) return []; - const data = await res.json(); - return Array.isArray(data) ? data : []; + return rows; } catch { return []; } diff --git a/src/actions/checkout.ts b/src/actions/checkout.ts index fbf0d1b..e63f996 100644 --- a/src/actions/checkout.ts +++ b/src/actions/checkout.ts @@ -1,6 +1,6 @@ "use server"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; export type CartItem = { id: string; @@ -64,9 +64,6 @@ export async function createOrder( brandId?: string, shippingAddress?: ShippingAddress ): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - // ── Calculate tax if brand collects tax ───────────────────────────────── let taxAmount = 0; let taxRate = 0; @@ -90,33 +87,22 @@ export async function createOrder( } } - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/create_order_with_items`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, - body: JSON.stringify({ - p_idempotency_key: idempotencyKey, - p_customer_name: customerName, - p_customer_email: customerEmail, - p_customer_phone: customerPhone, - p_stop_id: stopId, - p_items: items, - p_tax_amount: taxAmount, - p_tax_rate: taxRate, - p_tax_location: taxLocation || null, - }), - } + const { rows } = await pool.query<{ create_order_with_items: CreatedOrder | null }>( + `SELECT create_order_with_items($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9) AS "create_order_with_items"`, + [ + idempotencyKey, + customerName, + customerEmail, + customerPhone, + stopId, + JSON.stringify(items), + taxAmount, + taxRate, + taxLocation || null, + ], ); + const data = rows[0]?.create_order_with_items; - if (!response.ok) { - const err = await response.json().catch(() => ({ message: "Unknown error" })); - return { success: false, error: err.message ?? "Failed to create order" }; - } - - const data = await response.json(); - - // RPC returns a JSONB object with order + items if (!data || !data.id) { return { success: false, error: "Order created but data not returned" }; } @@ -124,14 +110,20 @@ export async function createOrder( // Send order receipt email try { const { sendOrderReceiptEmail } = await import("@/lib/email-service"); + const rawItems = data.items ?? []; + const taxAmount = (data as { tax_amount?: number }).tax_amount ?? 0; await sendOrderReceiptEmail({ customerName, customerEmail, orderId: data.id, - items: data.items ?? [], + items: rawItems.map((i) => ({ + name: i.product_name, + quantity: i.quantity, + price: i.price, + })), subtotal: data.subtotal ?? 0, - taxAmount: data.tax_amount ?? 0, - total: (data.subtotal ?? 0) + (data.tax_amount ?? 0), + taxAmount, + total: (data.subtotal ?? 0) + taxAmount, fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup", stopCity: data.stop_city ?? undefined, stopState: data.stop_state ?? undefined, @@ -140,31 +132,22 @@ export async function createOrder( stopLocation: data.stop_location ?? undefined, brandName: "Tuxedo Corn", }); - } catch (e) { + } catch { // Email failure should not fail the order } - return { success: true, order: data as CreatedOrder }; + return { success: true, order: data }; } // ── Cart Persistence ────────────────────────────────────────────────────────── export async function getServerCart(userId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - try { - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_user_cart`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_user_id: userId }), - } + const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>( + `SELECT get_user_cart($1) AS "get_user_cart"`, + [userId], ); - - if (!response.ok) return []; - const data = await response.json(); + const data = rows[0]?.get_user_cart; return Array.isArray(data) ? data : []; } catch { return []; @@ -177,24 +160,15 @@ export async function mergeLocalCart( ): Promise<{ merged: CartItem[] }> { if (!localCart || localCart.length === 0) return { merged: [] }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - // Fetch server cart let serverCart: CartItem[] = []; try { - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_user_cart`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_user_id: userId }), - } + const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>( + `SELECT get_user_cart($1) AS "get_user_cart"`, + [userId], ); - if (response.ok) { - const data = await response.json(); - serverCart = Array.isArray(data) ? data : []; - } + const data = rows[0]?.get_user_cart; + serverCart = Array.isArray(data) ? data : []; } catch { // proceed with local cart only } @@ -227,13 +201,9 @@ export async function mergeLocalCart( // Persist merged cart to server try { - await fetch( - `${supabaseUrl}/rest/v1/rpc/upsert_user_cart`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_user_id: userId, p_items: merged }), - } + await pool.query( + `SELECT upsert_user_cart($1, $2::jsonb)`, + [userId, JSON.stringify(merged)], ); } catch { // best-effort — localStorage is still source of truth for now @@ -243,19 +213,53 @@ export async function mergeLocalCart( } export async function clearServerCart(userId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - try { - await fetch( - `${supabaseUrl}/rest/v1/rpc/clear_user_cart`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_user_id: userId }), - } + await pool.query( + `SELECT clear_user_cart($1)`, + [userId], ); } catch { // ignore } -} \ No newline at end of file +} + +// ── Stop picker (used by cart + checkout client components) ─────────────── + +export type PublicStop = { + id: string; + city: string; + state: string; + date: string; + time: string; + location: string; + brand_id: string; +}; + +export async function getPublicStopsForBrand(brandId: string): Promise { + const { rows } = await pool.query( + `SELECT id, city, state, date, time, location, brand_id + FROM stops + WHERE active = true AND brand_id = $1 + ORDER BY date`, + [brandId], + ); + return rows; +} + +export type ProductAvailability = { + product_id: string; + is_available: boolean; +}; + +export async function checkStopProductAvailability( + stopId: string, + productIds: string[] +): Promise { + if (!productIds || productIds.length === 0) return []; + const { rows } = await pool.query<{ check_stop_product_availability: ProductAvailability[] | null }>( + `SELECT check_stop_product_availability($1, $2::uuid[]) AS "check_stop_product_availability"`, + [stopId, productIds], + ); + const data = rows[0]?.check_stop_product_availability; + return Array.isArray(data) ? data : []; +} diff --git a/src/actions/platform/command-center.ts b/src/actions/platform/command-center.ts index fab2426..7bf9ebe 100644 --- a/src/actions/platform/command-center.ts +++ b/src/actions/platform/command-center.ts @@ -1,10 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; -import { svcHeaders } from "@/lib/svc-headers"; - -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; -const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; +import { pool } from "@/lib/db"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -65,25 +62,22 @@ export type CreatePainLogData = { description?: string | null; }; -// ── Internal fetch helper ──────────────────────────────────────────────────── +// ── Internal RPC helper ────────────────────────────────────────────────────── -async function platformRPC(rpcName: string, body: Record = {}): Promise { +async function platformRPC(rpcName: string): Promise { const adminUser = await getAdminUser(); if (!adminUser) throw new Error("Not authenticated"); if (adminUser.role !== "platform_admin") throw new Error("Access denied: platform admin only"); - const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); + const { rows } = await pool.query>( + `SELECT ${rpcName}() AS "${rpcName}"`, + ); - if (!response.ok) { - const err = await response.text(); - throw new Error(`RPC ${rpcName} failed: ${err}`); + const data = rows[0]?.[rpcName]; + if (data == null) { + return null as T; } - - return response.json() as Promise; + return data as T; } // ── Platform actions ───────────────────────────────────────────────────────── @@ -104,19 +98,11 @@ export async function getPainLog(): Promise { const adminUser = await getAdminUser(); if (!adminUser) throw new Error("Not authenticated"); - const response = await fetch( - `${supabaseUrl}/rest/v1/founder_pain_log_platform?select=*`, - { - headers: svcHeaders(supabaseKey), - } + const { rows } = await pool.query( + `SELECT * FROM founder_pain_log_platform ORDER BY created_at DESC`, ); - if (!response.ok) { - const err = await response.text(); - throw new Error(`Failed to fetch pain log: ${err}`); - } - - return response.json() as Promise; + return rows; } export async function createPainLogItem(data: CreatePainLogData): Promise<{ success: boolean; error?: string }> { @@ -125,22 +111,17 @@ export async function createPainLogItem(data: CreatePainLogData): Promise<{ succ if (!adminUser) return { success: false, error: "Not authenticated" }; if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" }; - const response = await fetch(`${supabaseUrl}/rest/v1/founder_pain_log`, { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, - body: JSON.stringify({ - brand_id: data.brand_id || null, - severity: data.severity, - category: data.category, - title: data.title, - description: data.description || null, - }), - }); - - if (!response.ok) { - const err = await response.text(); - return { success: false, error: err }; - } + await pool.query( + `INSERT INTO founder_pain_log (brand_id, severity, category, title, description) + VALUES ($1, $2, $3, $4, $5)`, + [ + data.brand_id || null, + data.severity, + data.category, + data.title, + data.description || null, + ], + ); return { success: true }; } catch (e) { @@ -154,26 +135,15 @@ export async function resolvePainLogItem(id: string): Promise<{ success: boolean if (!adminUser) return { success: false, error: "Not authenticated" }; if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" }; - const response = await fetch( - `${supabaseUrl}/rest/v1/founder_pain_log?id=eq.${id}`, - { - method: "PATCH", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - status: "resolved", - resolved_at: new Date().toISOString(), - resolved_by: adminUser.id, - }), - } + await pool.query( + `UPDATE founder_pain_log + SET status = 'resolved', resolved_at = now(), resolved_by = $2 + WHERE id = $1`, + [id, adminUser.id], ); - if (!response.ok) { - const err = await response.text(); - return { success: false, error: err }; - } - return { success: true }; } catch (e) { return { success: false, error: String(e) }; } -} \ No newline at end of file +} diff --git a/src/app/api/ai/customer-insights/route.ts b/src/app/api/ai/customer-insights/route.ts index 48b374d..3ae1c6b 100644 --- a/src/app/api/ai/customer-insights/route.ts +++ b/src/app/api/ai/customer-insights/route.ts @@ -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 = { 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 }); } -} \ No newline at end of file +} diff --git a/src/app/api/v1/campaigns/route.ts b/src/app/api/v1/campaigns/route.ts index a536a50..68c2d65 100644 --- a/src/app/api/v1/campaigns/route.ts +++ b/src/app/api/v1/campaigns/route.ts @@ -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", }, }); -} \ No newline at end of file +} diff --git a/src/app/api/v1/referrals/route.ts b/src/app/api/v1/referrals/route.ts index 8b609c1..3b023dc 100644 --- a/src/app/api/v1/referrals/route.ts +++ b/src/app/api/v1/referrals/route.ts @@ -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", }, }); -} \ No newline at end of file +} diff --git a/src/app/api/v1/reports/route.ts b/src/app/api/v1/reports/route.ts index 4111610..c8337e2 100644 --- a/src/app/api/v1/reports/route.ts +++ b/src/app/api/v1/reports/route.ts @@ -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", }, }); -} \ No newline at end of file +} diff --git a/src/app/api/v1/water-logs/route.ts b/src/app/api/v1/water-logs/route.ts index bc7c742..e70e751 100644 --- a/src/app/api/v1/water-logs/route.ts +++ b/src/app/api/v1/water-logs/route.ts @@ -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", }, }); -} \ No newline at end of file +} diff --git a/src/app/api/water-admin-auth/route.ts b/src/app/api/water-admin-auth/route.ts index 2ae739d..f580e88 100644 --- a/src/app/api/water-admin-auth/route.ts +++ b/src/app/api/water-admin-auth/route.ts @@ -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 }); } -} \ No newline at end of file +} diff --git a/src/app/api/water-logs/export/route.ts b/src/app/api/water-logs/export/route.ts index 22be0b0..dfced66 100644 --- a/src/app/api/water-logs/export/route.ts +++ b/src/app/api/water-logs/export/route.ts @@ -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); } diff --git a/src/app/cart/CartClient.tsx b/src/app/cart/CartClient.tsx index 8ece8bc..80da1b5 100644 --- a/src/app/cart/CartClient.tsx +++ b/src/app/cart/CartClient.tsx @@ -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); diff --git a/src/app/checkout/CheckoutClient.tsx b/src/app/checkout/CheckoutClient.tsx index d126fae..140ac39 100644 --- a/src/app/checkout/CheckoutClient.tsx +++ b/src/app/checkout/CheckoutClient.tsx @@ -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]); diff --git a/src/app/trace/[lotNumber]/page.tsx b/src/app/trace/[lotNumber]/page.tsx index 40fbe32..ccf8917 100644 --- a/src/app/trace/[lotNumber]/page.tsx +++ b/src/app/trace/[lotNumber]/page.tsx @@ -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); diff --git a/src/lib/admin-permissions-service.ts b/src/lib/admin-permissions-service.ts index 4dcf95d..54dd7de 100644 --- a/src/lib/admin-permissions-service.ts +++ b/src/lib/admin-permissions-service.ts @@ -1,18 +1,15 @@ /** - * Service-layer admin user creation via Supabase REST API. - * Uses apikey-only authentication — no Authorization header (which fails on - * Vercel Edge due to raw JWT chars +, /, = in the token). + * Service-layer admin user creation. Hits the `admin_users` table directly + * via the shared pg pool. Returns the inserted row (or existing row if the + * user was already provisioned). */ export async function createAdminUser( userId: string, role: string, brandId: string | null ): Promise | null> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!supabaseUrl || !serviceKey) return null; - - const body = JSON.stringify({ + const { pool } = await import("@/lib/db"); + const body = { user_id: userId, role, brand_id: brandId, @@ -28,33 +25,52 @@ export async function createAdminUser( can_manage_reports: role === "platform_admin", can_manage_settings: role === "platform_admin", must_change_password: false, - }); + }; - // Use apikey-only — no Authorization header to avoid Vercel Edge JWT rejection - const res = await fetch(`${supabaseUrl}/rest/v1/admin_users`, { - method: "POST", - headers: { - apikey: serviceKey, - "Content-Type": "application/json", - Prefer: "return=representation", - }, - body, - }); - - if (res.status === 409) { - // User already exists — fetch and return - const existing = await fetch( - `${supabaseUrl}/rest/v1/admin_users?user_id=eq.${userId}&limit=1`, - { headers: { apikey: serviceKey, "Content-Type": "application/json" } } + try { + const { rows } = await pool.query>( + `INSERT INTO admin_users + (user_id, role, brand_id, active, + can_manage_products, can_manage_stops, can_manage_orders, + can_manage_pickup, can_manage_messages, can_manage_refunds, + can_manage_users, can_manage_water_log, can_manage_reports, + can_manage_settings, must_change_password) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (user_id) DO UPDATE + SET role = EXCLUDED.role, + brand_id = EXCLUDED.brand_id, + active = EXCLUDED.active, + can_manage_products = EXCLUDED.can_manage_products, + can_manage_stops = EXCLUDED.can_manage_stops, + can_manage_orders = EXCLUDED.can_manage_orders, + can_manage_pickup = EXCLUDED.can_manage_pickup, + can_manage_messages = EXCLUDED.can_manage_messages, + can_manage_refunds = EXCLUDED.can_manage_refunds, + can_manage_users = EXCLUDED.can_manage_users, + can_manage_water_log = EXCLUDED.can_manage_water_log, + can_manage_reports = EXCLUDED.can_manage_reports, + can_manage_settings = EXCLUDED.can_manage_settings + RETURNING *`, + [ + body.user_id, + body.role, + body.brand_id, + body.active, + body.can_manage_products, + body.can_manage_stops, + body.can_manage_orders, + body.can_manage_pickup, + body.can_manage_messages, + body.can_manage_refunds, + body.can_manage_users, + body.can_manage_water_log, + body.can_manage_reports, + body.can_manage_settings, + body.must_change_password, + ], ); - const data = existing.ok ? await existing.json().catch(() => null) : null; - return (Array.isArray(data) && data.length > 0) ? data[0] as Record : null; - } - - if (!res.ok) { + return rows[0] ?? null; + } catch { return null; } - - const data = await res.json(); - return (Array.isArray(data) ? data[0] : data) as Record; -} \ No newline at end of file +}