migrate: replace Supabase REST with Drizzle/pg in core admin (wave 1)
This commit is contained in:
+106
-137
@@ -3,7 +3,7 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type LocationInput = {
|
||||
name: string;
|
||||
@@ -56,39 +56,37 @@ export async function createLocation(
|
||||
const effectiveBrandId = activeBrandId;
|
||||
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_create_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: effectiveBrandId,
|
||||
p_name: input.name,
|
||||
p_address: input.address ?? null,
|
||||
p_city: input.city ?? null,
|
||||
p_state: input.state ?? null,
|
||||
p_zip: input.zip ?? null,
|
||||
p_phone: input.phone ?? null,
|
||||
p_contact_name: input.contact_name ?? null,
|
||||
p_contact_email: input.contact_email ?? null,
|
||||
p_notes: input.notes ?? null,
|
||||
p_active: input.active ?? true,
|
||||
}),
|
||||
try {
|
||||
const { rows } = await pool.query<{ id: string; slug: string }>(
|
||||
`SELECT * FROM admin_create_location(
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||
)`,
|
||||
[
|
||||
effectiveBrandId,
|
||||
input.name,
|
||||
input.address ?? null,
|
||||
input.city ?? null,
|
||||
input.state ?? null,
|
||||
input.zip ?? null,
|
||||
input.phone ?? null,
|
||||
input.contact_name ?? null,
|
||||
input.contact_email ?? null,
|
||||
input.notes ?? null,
|
||||
],
|
||||
);
|
||||
const data = rows[0];
|
||||
if (!data) {
|
||||
return { success: false, error: "Insert failed" };
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||
return { success: true, id: data.id, slug: data.slug };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Insert failed",
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||
return { success: true, id: data.id, slug: data.slug };
|
||||
}
|
||||
|
||||
// ── Create (batch) ───────────────────────────────────────────────────────────
|
||||
@@ -108,24 +106,21 @@ export async function createLocationsBatch(
|
||||
const effectiveBrandId = activeBrandId;
|
||||
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
|
||||
let inserted: { id?: string }[] = [];
|
||||
try {
|
||||
const { rows } = await pool.query<{ id?: string }>(
|
||||
"SELECT * FROM admin_create_locations_batch($1, $2::jsonb)",
|
||||
[effectiveBrandId, JSON.stringify(locations)],
|
||||
);
|
||||
inserted = rows;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
error: err instanceof Error ? err.message : "Insert failed",
|
||||
};
|
||||
}
|
||||
|
||||
const inserted = await res.json();
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||
return {
|
||||
@@ -144,25 +139,23 @@ export async function updateLocation(
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_update_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Update failed" };
|
||||
let data: { success?: boolean; error?: string } = {};
|
||||
try {
|
||||
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
|
||||
"SELECT * FROM admin_update_location($1, $2, $3::jsonb)",
|
||||
[locationId, brandId, JSON.stringify(updates)],
|
||||
);
|
||||
data = rows[0] ?? {};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Update failed",
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
|
||||
if (!data.success) {
|
||||
return { success: false, error: data.error ?? "Update failed" };
|
||||
}
|
||||
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||
@@ -178,25 +171,23 @@ export async function deleteLocation(
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" };
|
||||
let data: { success?: boolean; error?: string } = {};
|
||||
try {
|
||||
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
|
||||
"SELECT * FROM admin_delete_location($1, $2)",
|
||||
[locationId, brandId],
|
||||
);
|
||||
data = rows[0] ?? {};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Delete failed",
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Delete failed" };
|
||||
if (!data.success) {
|
||||
return { success: false, error: data.error ?? "Delete failed" };
|
||||
}
|
||||
|
||||
revalidateTag("locations", "default");
|
||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||
@@ -210,27 +201,18 @@ export async function adminListLocations(
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Application-layer brand scoping: platform_admin (brand_id: null) gets all
|
||||
// brands; everyone else gets only their own brand.
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
const effectiveBrandId = activeBrandId;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
||||
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? (data as LocationWithCount[]) : [];
|
||||
try {
|
||||
const { rows } = await pool.query<LocationWithCount>(
|
||||
"SELECT * FROM admin_list_locations($1)",
|
||||
[effectiveBrandId],
|
||||
);
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read (public, by brand slug) ─────────────────────────────────────────────
|
||||
@@ -244,22 +226,15 @@ export async function getPublicLocationsForBrand(
|
||||
): Promise<PublicLocation[]> {
|
||||
if (!brandSlug) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
||||
next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? (data as PublicLocation[]) : [];
|
||||
try {
|
||||
const { rows } = await pool.query<PublicLocation>(
|
||||
"SELECT * FROM get_locations_for_brand($1)",
|
||||
[brandSlug],
|
||||
);
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Attach a stop to a location ──────────────────────────────────────────────
|
||||
@@ -272,29 +247,23 @@ export async function attachStopToLocation(
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_stop_id: stopId,
|
||||
p_location_id: locationId,
|
||||
p_brand_id: brandId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Attach failed" };
|
||||
let data: { success?: boolean; error?: string } = {};
|
||||
try {
|
||||
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
|
||||
"SELECT * FROM admin_attach_location_to_stop($1, $2, $3)",
|
||||
[stopId, locationId, brandId],
|
||||
);
|
||||
data = rows[0] ?? {};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Attach failed",
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.success) return { success: false, error: data.error ?? "Attach failed" };
|
||||
if (!data.success) {
|
||||
return { success: false, error: data.error ?? "Attach failed" };
|
||||
}
|
||||
|
||||
revalidateTag("stops", "default");
|
||||
revalidateTag("locations", "default");
|
||||
|
||||
Reference in New Issue
Block a user