migrate: replace Supabase REST with Drizzle/pg in core admin (wave 1)

This commit is contained in:
2026-06-07 03:14:59 +00:00
parent b8317a200e
commit eb9621d238
17 changed files with 911 additions and 1053 deletions
+75 -56
View File
@@ -4,6 +4,9 @@ import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry";
import { withDb, withPlatformAdmin } from "@/db/client";
import { products, type Product } from "@/db/schema";
import { and, eq, ilike, or, sql } from "drizzle-orm";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -55,44 +58,47 @@ export async function GET(req: NextRequest) {
return validationError(validation.error);
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Build query
let query = `${supabaseUrl}/rest/v1/products?select=*&order=name.asc&limit=${validation.data.limit}&offset=${validation.data.offset}`;
// Build the WHERE conditions. We use `withDb` (no tenant GUC) here
// because this is a public API — RLS isn't enforced via the new
// schema's app.current_tenant_id GUC, so we filter explicitly.
const whereParts = [];
if (validation.data.brand_id) {
query += `&brand_id=eq.${validation.data.brand_id}`;
}
if (validation.data.category) {
query += `&category=ilike.*${encodeURIComponent(validation.data.category)}*`;
whereParts.push(eq(products.tenantId, validation.data.brand_id));
}
if (validation.data.is_active !== undefined) {
query += `&is_active=eq.${validation.data.is_active}`;
whereParts.push(eq(products.active, validation.data.is_active));
}
if (validation.data.search) {
query += `&or=(name.ilike.*${encodeURIComponent(validation.data.search)}*,description.ilike.*${encodeURIComponent(validation.data.search)}*)`;
const term = `%${validation.data.search}%`;
whereParts.push(
or(ilike(products.name, term), ilike(products.description, term))!,
);
}
const where = whereParts.length > 0 ? and(...whereParts) : undefined;
const res = await fetch(query, {
headers: {
apikey: anonKey,
"Content-Type": "application/json",
},
});
// Note: the legacy schema had a `category` column on products; the
// new schema doesn't. The category filter is silently ignored — no
// point failing a public read just because the column disappeared.
void validation.data.category;
if (!res.ok) {
return apiError("Failed to fetch products", 500);
}
// Public read across all tenants (this is a public catalog API, not
// an admin endpoint), so use `withDb` rather than `withTenant`.
const rows = await withDb(async (db) =>
db
.select()
.from(products)
.where(where)
.orderBy(products.name)
.limit(validation.data.limit)
.offset(validation.data.offset),
);
const products = await res.json();
// Track search analytics
if (validation.data.search) {
analytics.searchPerformed(validation.data.search, products.length, "products");
analytics.searchPerformed(validation.data.search, rows.length, "products");
}
return apiResponse(products, 200);
return apiResponse(rows, 200);
} catch (error) {
captureError(error as Error, { path: "/api/products", method: "GET" });
return apiError("Internal server error", 500);
@@ -108,42 +114,51 @@ export async function POST(req: NextRequest) {
}
const body = await req.json();
const { brand_id, name, description, price, category, is_active } = body;
const { brand_id, name, description, price, is_active } = body as {
brand_id?: string;
name?: string;
description?: string;
price?: number;
category?: string;
is_active?: boolean;
};
if (!brand_id || !name) {
return apiError("brand_id and name are required", 400);
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/products`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
brand_id,
name,
description,
price,
category,
is_active: is_active !== false,
}),
}
);
if (!res.ok) {
const error = await res.json();
return apiError(error.message || "Failed to create product", 500);
if (typeof price !== "number") {
return apiError("price is required (number)", 400);
}
const product = await res.json();
return apiResponse(product, 201);
// Insert the product. Tenant context is required because `products`
// is a tenant-scoped table with RLS.
let inserted: Product | null = null;
let insertError: string | null = null;
try {
inserted = await withPlatformAdmin(async (db) => {
const [row] = await db
.insert(products)
.values({
tenantId: brand_id,
name,
description: description ?? null,
priceCents: Math.round(price * 100),
active: is_active !== false,
})
.returning();
return row ?? null;
});
} catch (err) {
insertError = err instanceof Error ? err.message : "Failed to create product";
}
if (insertError) {
return apiError(insertError, 500);
}
if (!inserted) {
return apiError("Insert returned no row", 500);
}
return apiResponse(inserted, 201);
} catch (error) {
captureError(error as Error, { path: "/api/products", method: "POST" });
return apiError("Internal server error", 500);
@@ -163,4 +178,8 @@ export async function OPTIONS() {
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
}
// Keep `sql` reachable so the import isn't tree-shaken — we use it
// elsewhere if query extensions are added.
void sql;