migrate: replace Supabase REST with Drizzle/pg in core admin (wave 1)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getMockTableData } from "@/lib/mock-data";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
@@ -38,87 +38,43 @@ export async function createStop(
|
||||
return { success: true, id: `mock-stop-${Date.now()}` };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
// Preferred path: anon key + SECURITY DEFINER RPC (bypasses RLS, works even if
|
||||
// service role key is absent at runtime). See:
|
||||
// supabase/migrations/202_fix_admin_create_stop.sql
|
||||
// scripts/apply-admin-create-stop.js (run this locally with the keys you have)
|
||||
// supabase/ADMIN_CREATE_STOP_FIX.sql (exact prompt SQL for SQL editor)
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(anonKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_active: data.active ?? false,
|
||||
p_address: data.address || null,
|
||||
p_brand_id: brandId,
|
||||
p_city: data.city,
|
||||
p_cutoff_time: data.cutoff_time || null,
|
||||
p_date: data.date,
|
||||
p_location: data.location,
|
||||
p_state: data.state,
|
||||
p_time: data.time,
|
||||
p_zip: data.zip || null,
|
||||
}),
|
||||
});
|
||||
|
||||
let usedFallback = false;
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
const lower = errText.toLowerCase();
|
||||
const looksLikeMissingFn =
|
||||
lower.includes("pgrst202") ||
|
||||
lower.includes("admin_create_stop") ||
|
||||
lower.includes("could not find the function") ||
|
||||
lower.includes("function not found");
|
||||
|
||||
if (looksLikeMissingFn) {
|
||||
usedFallback = true;
|
||||
} else {
|
||||
return { success: false, error: `Failed: ${errText}` };
|
||||
}
|
||||
} else {
|
||||
const inserted = await res.json().catch(() => ({} as any));
|
||||
if (inserted && inserted.success === false) {
|
||||
// Our RPC returns structured errors as 200 + {success:false}
|
||||
const errMsg = inserted.error || "Failed to create stop";
|
||||
const lower = errMsg.toLowerCase();
|
||||
if (lower.includes("function") || lower.includes("not found") || lower.includes("pgrst")) {
|
||||
usedFallback = true;
|
||||
} else {
|
||||
return { success: false, error: errMsg };
|
||||
}
|
||||
} else {
|
||||
// Happy path from RPC (either old {id,slug} or new {success, stop_id})
|
||||
const stopId = inserted?.stop_id || inserted?.id || "";
|
||||
const effectiveBrandId = brandId || adminUser.brand_id;
|
||||
if (effectiveBrandId) {
|
||||
revalidateTag("stops", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
|
||||
}
|
||||
return { success: true, id: stopId };
|
||||
}
|
||||
}
|
||||
|
||||
if (usedFallback) {
|
||||
// We cannot bypass the block_stops_mutations RLS policy via REST even with the service key.
|
||||
// The only reliable path is the SECURITY DEFINER RPC created by migration 202.
|
||||
// Tell the user exactly how to install it using only the keys they already have.
|
||||
// `admin_create_stop` is a SECURITY DEFINER RPC that bypasses the
|
||||
// block_stops_mutations RLS policy. It returns either {success,
|
||||
// stop_id} or {success:false, error}. Migration 202.
|
||||
let rpcResult: { success?: boolean; error?: string; stop_id?: string; id?: string } = {};
|
||||
try {
|
||||
const { rows } = await pool.query<{ success?: boolean; error?: string; stop_id?: string; id?: string }>(
|
||||
"SELECT * FROM admin_create_stop($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
[
|
||||
data.active ?? false,
|
||||
data.address || null,
|
||||
brandId,
|
||||
data.city,
|
||||
data.cutoff_time || null,
|
||||
data.date,
|
||||
data.location,
|
||||
data.state,
|
||||
data.time,
|
||||
data.zip || null,
|
||||
],
|
||||
);
|
||||
rpcResult = rows[0] ?? {};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"The admin_create_stop database function is missing (this is why Add New Stop fails with function not found).\n\n" +
|
||||
"Fix using only the keys in your .env.local (no Supabase dashboard needed):\n" +
|
||||
" 1. On a machine that can reach your database (normal laptop usually works):\n" +
|
||||
" node scripts/apply-admin-create-stop.js\n" +
|
||||
" 2. Or: npm run migrate:one 202\n" +
|
||||
" 3. Or apply supabase/migrations/202_fix_admin_create_stop.sql via psql / Supabase CLI with --db-url.\n\n" +
|
||||
"After it succeeds, restart your dev server and Add New Stop will work.",
|
||||
error: err instanceof Error ? err.message : "Failed to create stop",
|
||||
};
|
||||
}
|
||||
|
||||
// Should not reach here
|
||||
return { success: false, error: "Unexpected state creating stop" };
|
||||
if (rpcResult.success === false) {
|
||||
return { success: false, error: rpcResult.error ?? "Failed to create stop" };
|
||||
}
|
||||
|
||||
const stopId = rpcResult.stop_id || rpcResult.id || "";
|
||||
const effectiveBrandId = brandId || adminUser.brand_id;
|
||||
if (effectiveBrandId) {
|
||||
revalidateTag("stops", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
|
||||
}
|
||||
return { success: true, id: stopId };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type StopDetail = {
|
||||
id: string;
|
||||
@@ -50,39 +50,32 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
|
||||
// Use a fresh server-side client (cookie-less) so RLS doesn't block reads
|
||||
// for platform_admin dev sessions. The auth check above has already gated
|
||||
// access.
|
||||
const server = useMockData
|
||||
? null
|
||||
: createClient(supabaseUrl, supabaseKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
// 1. Stop + brand
|
||||
let stop: StopDetail | null = null;
|
||||
let stopErr: string | null = null;
|
||||
|
||||
if (server) {
|
||||
const { data, error } = await server
|
||||
.from("stops")
|
||||
.select("*, brands(name, slug)")
|
||||
.eq("id", stopId)
|
||||
.single();
|
||||
if (error) stopErr = error.message;
|
||||
else stop = (data ?? null) as StopDetail | null;
|
||||
} else {
|
||||
// Mock fallback — empty
|
||||
stopErr = "Stop not found";
|
||||
if (useMockData) {
|
||||
return { success: false, error: "Stop not found" };
|
||||
}
|
||||
|
||||
if (!stop) {
|
||||
return { success: false, error: stopErr ?? "Stop not found" };
|
||||
// 1. Stop + brand — legacy schema, raw SQL (the Drizzle stops table
|
||||
// maps to the new schema which doesn't have city/state/date/etc).
|
||||
const stopRes = await pool.query<StopDetail & { brand_name: string; brand_slug: string }>(
|
||||
`SELECT s.*, b.name AS brand_name, b.slug AS brand_slug
|
||||
FROM stops s
|
||||
LEFT JOIN brands b ON b.id = s.brand_id
|
||||
WHERE s.id = $1
|
||||
LIMIT 1`,
|
||||
[stopId],
|
||||
);
|
||||
const stopRow = stopRes.rows[0];
|
||||
if (!stopRow) {
|
||||
return { success: false, error: "Stop not found" };
|
||||
}
|
||||
const stop: StopDetail = {
|
||||
...stopRow,
|
||||
brands: stopRow.brand_name
|
||||
? { name: stopRow.brand_name, slug: stopRow.brand_slug }
|
||||
: null,
|
||||
};
|
||||
|
||||
// Brand-scope check for brand_admin
|
||||
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
|
||||
@@ -90,26 +83,27 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
|
||||
}
|
||||
|
||||
// 2. Candidate products for this brand
|
||||
const { data: allProducts } = server
|
||||
? await server
|
||||
.from("products")
|
||||
.select("id, name, type, price")
|
||||
.eq("brand_id", stop.brand_id)
|
||||
.eq("active", true)
|
||||
: { data: [] as { id: string; name: string; type: string; price: number }[] };
|
||||
const { rows: allProducts } = await pool.query<{ id: string; name: string; type: string; price: number }>(
|
||||
`SELECT id, name, type, price
|
||||
FROM products
|
||||
WHERE brand_id = $1 AND active = true`,
|
||||
[stop.brand_id],
|
||||
);
|
||||
|
||||
// 3. Assigned products (joined with product info)
|
||||
const { data: productStops } = server
|
||||
? await server
|
||||
.from("product_stops")
|
||||
.select("id, product_id, products(id, name, type, price)")
|
||||
.eq("stop_id", stopId)
|
||||
: { data: [] as AssignedProduct[] };
|
||||
const { rows: productStops } = await pool.query<AssignedProduct>(
|
||||
`SELECT ps.id, ps.product_id,
|
||||
json_build_object('id', p.id, 'name', p.name, 'type', p.type, 'price', p.price) AS products
|
||||
FROM product_stops ps
|
||||
LEFT JOIN products p ON p.id = ps.product_id
|
||||
WHERE ps.stop_id = $1`,
|
||||
[stopId],
|
||||
);
|
||||
|
||||
// 4. Brands for the brand switcher
|
||||
const { data: brands } = server
|
||||
? await server.from("brands").select("id, name, slug")
|
||||
: { data: [] as { id: string; name: string; slug: string }[] };
|
||||
const { rows: brands } = await pool.query<{ id: string; name: string; slug: string }>(
|
||||
`SELECT id, name, slug FROM brands ORDER BY name`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
import { logAuditEvent } from "@/actions/audit";
|
||||
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||
@@ -37,32 +37,51 @@ export async function updateStop(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
city: data.city,
|
||||
state: data.state,
|
||||
location: data.location,
|
||||
date: data.date,
|
||||
time: data.time,
|
||||
slug,
|
||||
active: data.active,
|
||||
brand_id: brandId,
|
||||
address: data.address ?? null,
|
||||
zip: data.zip ?? null,
|
||||
cutoff_time: data.cutoff_time ?? null,
|
||||
}),
|
||||
});
|
||||
// Direct UPDATE on the legacy stops table — the new-schema Drizzle
|
||||
// stops table doesn't have the columns we need to write.
|
||||
const { rowCount, error } = await pool
|
||||
.query(
|
||||
`UPDATE stops SET
|
||||
city = $1,
|
||||
state = $2,
|
||||
location = $3,
|
||||
date = $4,
|
||||
time = $5,
|
||||
slug = $6,
|
||||
active = $7,
|
||||
brand_id = $8,
|
||||
address = $9,
|
||||
zip = $10,
|
||||
cutoff_time = $11
|
||||
WHERE id = $12`,
|
||||
[
|
||||
data.city,
|
||||
data.state,
|
||||
data.location,
|
||||
data.date,
|
||||
data.time,
|
||||
slug,
|
||||
data.active,
|
||||
brandId,
|
||||
data.address ?? null,
|
||||
data.zip ?? null,
|
||||
data.cutoff_time ?? null,
|
||||
stopId,
|
||||
],
|
||||
)
|
||||
.then((r) => ({ rowCount: r.rowCount ?? 0, error: null }))
|
||||
.catch((e: unknown) => ({
|
||||
rowCount: 0,
|
||||
error: e instanceof Error ? e : new Error(String(e)),
|
||||
}));
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return { success: false, error: `Failed: ${err}` };
|
||||
if (error || !rowCount) {
|
||||
return {
|
||||
success: false,
|
||||
error: error ? error.message : "Stop not found",
|
||||
};
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
|
||||
Reference in New Issue
Block a user