migrate: replace Supabase REST with Drizzle/pg in core admin (wave 1)
This commit is contained in:
+20
-26
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
export type AuditAction = "INSERT" | "UPDATE" | "DELETE";
|
export type AuditAction = "INSERT" | "UPDATE" | "DELETE";
|
||||||
|
|
||||||
@@ -22,12 +22,10 @@ type AuditResult =
|
|||||||
* Logs an audit event to the audit_logs table.
|
* Logs an audit event to the audit_logs table.
|
||||||
*
|
*
|
||||||
* Resolves the admin user from the Auth.js session via getAdminUser().
|
* Resolves the admin user from the Auth.js session via getAdminUser().
|
||||||
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function.
|
* Audit writes go through the `log_audit_event` SECURITY DEFINER
|
||||||
|
* PL/pgSQL function via the shared pg pool — no Supabase REST hop.
|
||||||
*/
|
*/
|
||||||
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
|
|
||||||
const performed_by = adminUser?.user_id ?? null;
|
const performed_by = adminUser?.user_id ?? null;
|
||||||
@@ -49,26 +47,22 @@ export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult>
|
|||||||
brand_id: payload.brand_id ?? null,
|
brand_id: payload.brand_id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(
|
try {
|
||||||
`${supabaseUrl}/rest/v1/rpc/log_audit_event`,
|
const { rows } = await pool.query<{ id?: string }>(
|
||||||
{
|
"SELECT * FROM log_audit_event($1::jsonb)",
|
||||||
method: "POST",
|
[JSON.stringify(rpcPayload)],
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
);
|
||||||
body: JSON.stringify({ p_payload: rpcPayload }),
|
const auditId = typeof rows[0] === "string"
|
||||||
|
? rows[0]
|
||||||
|
: rows[0]?.id;
|
||||||
|
if (!auditId) {
|
||||||
|
return { success: false, error: "Audit log written but ID not returned" };
|
||||||
}
|
}
|
||||||
);
|
return { success: true, audit_id: auditId };
|
||||||
|
} catch (err) {
|
||||||
if (!response.ok) {
|
return {
|
||||||
const err = await response.json().catch(() => ({ message: "Unknown error" }));
|
success: false,
|
||||||
return { success: false, error: err.message ?? "Failed to write audit log" };
|
error: err instanceof Error ? err.message : "Failed to write audit log",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const data = await response.json();
|
|
||||||
const auditId = typeof data === "string" ? data : data?.[0]?.id ?? data?.id;
|
|
||||||
|
|
||||||
if (!auditId) {
|
|
||||||
return { success: false, error: "Audit log written but ID not returned" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, audit_id: auditId };
|
|
||||||
}
|
|
||||||
|
|||||||
+124
-136
@@ -1,12 +1,23 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
export type UploadLogoResult =
|
export type UploadLogoResult =
|
||||||
| { success: true; logoUrl: string }
|
| { success: true; logoUrl: string }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a brand logo (light or dark variant) to Supabase Storage and
|
||||||
|
* save the resulting public URL to the brand_settings row via the
|
||||||
|
* `upsert_brand_settings` SECURITY DEFINER RPC.
|
||||||
|
*
|
||||||
|
* NOTE: the storage PUT itself is not a database call, so it still
|
||||||
|
* goes through the Supabase Storage REST API. Storage migration to an
|
||||||
|
* S3-compatible backend is tracked separately; until that lands, the
|
||||||
|
* public URL pattern (`/storage/v1/object/public/...`) keeps working
|
||||||
|
* for any pre-existing bucket.
|
||||||
|
*/
|
||||||
export async function uploadBrandLogo(
|
export async function uploadBrandLogo(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
file: File,
|
file: File,
|
||||||
@@ -43,7 +54,7 @@ export async function uploadBrandLogo(
|
|||||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
|
||||||
body: buffer,
|
body: buffer,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -54,20 +65,10 @@ export async function uploadBrandLogo(
|
|||||||
|
|
||||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||||
|
|
||||||
// Save URL to brand_settings via RPC
|
const saveOk = await callUpsertBrandSettings(brandId, {
|
||||||
const saveRes = await fetch(
|
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
|
||||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
});
|
||||||
{
|
if (!saveOk) {
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
p_brand_id: brandId,
|
|
||||||
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!saveRes.ok) {
|
|
||||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export async function uploadOlatheSweetLogo(
|
|||||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
|
||||||
body: buffer,
|
body: buffer,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -119,19 +120,10 @@ export async function uploadOlatheSweetLogo(
|
|||||||
|
|
||||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||||
|
|
||||||
const saveRes = await fetch(
|
const saveOk = await callUpsertBrandSettings(brandId, {
|
||||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
p_olathe_sweet_logo_url: publicUrl,
|
||||||
{
|
});
|
||||||
method: "POST",
|
if (!saveOk) {
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
p_brand_id: brandId,
|
|
||||||
p_olathe_sweet_logo_url: publicUrl,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!saveRes.ok) {
|
|
||||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +164,7 @@ export async function uploadOlatheSweetLogoDark(
|
|||||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
|
||||||
body: buffer,
|
body: buffer,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -183,25 +175,45 @@ export async function uploadOlatheSweetLogoDark(
|
|||||||
|
|
||||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||||
|
|
||||||
const saveRes = await fetch(
|
const saveOk = await callUpsertBrandSettings(brandId, {
|
||||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
p_olathe_sweet_logo_url_dark: publicUrl,
|
||||||
{
|
});
|
||||||
method: "POST",
|
if (!saveOk) {
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
p_brand_id: brandId,
|
|
||||||
p_olathe_sweet_logo_url_dark: publicUrl,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!saveRes.ok) {
|
|
||||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, logoUrl: publicUrl };
|
return { success: true, logoUrl: publicUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helper: invoke the SECURITY DEFINER `upsert_brand_settings`
|
||||||
|
* RPC via the shared pg pool. Accepts a partial args object whose keys
|
||||||
|
* map to the function's `p_*` parameters.
|
||||||
|
*/
|
||||||
|
async function callUpsertBrandSettings(
|
||||||
|
brandId: string,
|
||||||
|
args: Record<string, unknown>
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Always set the brand id.
|
||||||
|
args.p_brand_id = brandId;
|
||||||
|
|
||||||
|
// Build a `$1, $2, ...` parameter list in deterministic key order so
|
||||||
|
// the positional binds line up with the named parameters.
|
||||||
|
const keys = Object.keys(args);
|
||||||
|
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
const params = keys.map((k) => args[k]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`SELECT upsert_brand_settings(${placeholders})`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type BrandSettings = {
|
export type BrandSettings = {
|
||||||
id?: string;
|
id?: string;
|
||||||
brand_id: string;
|
brand_id: string;
|
||||||
@@ -252,51 +264,35 @@ export type SaveBrandSettingsResult =
|
|||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
|
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
try {
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const { rows } = await pool.query<BrandSettings>(
|
||||||
|
"SELECT * FROM get_brand_settings($1)",
|
||||||
const response = await fetch(
|
[brandId],
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`,
|
);
|
||||||
{
|
return { success: true, settings: rows[0] ?? null };
|
||||||
method: "POST",
|
} catch {
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
return { success: false, error: "Failed to fetch brand settings" };
|
||||||
body: JSON.stringify({ p_brand_id: brandId }),
|
}
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) return { success: false, error: "Failed to fetch brand settings" };
|
|
||||||
const data = await response.json();
|
|
||||||
return { success: true, settings: data };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public version for storefront pages — uses slug, no auth required
|
// Public version for storefront pages — uses slug, no auth required
|
||||||
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
// Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
// crash the prerender — the page just falls back to its default brand
|
||||||
|
// name and revalidates from a real request later.
|
||||||
if (!supabaseUrl || !supabaseKey) {
|
|
||||||
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
|
|
||||||
// doesn't crash the prerender — the page just falls back to its
|
|
||||||
// default brand name and revalidates from a real request later.
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
|
"SELECT * FROM get_brand_settings_by_slug($1)",
|
||||||
{
|
[brandSlug],
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
const data = rows[0];
|
||||||
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
if (!data) {
|
||||||
const data = await response.json();
|
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
settings: data,
|
settings: data,
|
||||||
wholesaleEnabled: data?.wholesale_enabled,
|
wholesaleEnabled: data.wholesale_enabled,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||||
@@ -348,60 +344,52 @@ export async function saveBrandSettings(params: {
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
try {
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const { rows } = await pool.query<BrandSettings>(
|
||||||
|
`SELECT * FROM upsert_brand_settings(
|
||||||
const response = await fetch(
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
|
||||||
{
|
$21, $22, $23, $24, $25, $26, $27, $28, $29, $30,
|
||||||
method: "POST",
|
$31, $32
|
||||||
headers: {
|
)`,
|
||||||
...svcHeaders(supabaseKey),
|
[
|
||||||
"Content-Type": "application/json",
|
params.brandId,
|
||||||
Prefer: "return=representation",
|
params.legalBusinessName ?? null,
|
||||||
},
|
params.phone ?? null,
|
||||||
body: JSON.stringify({
|
params.email ?? null,
|
||||||
p_brand_id: params.brandId,
|
params.websiteUrl ?? null,
|
||||||
p_legal_business_name: params.legalBusinessName ?? null,
|
params.streetAddress ?? null,
|
||||||
p_phone: params.phone ?? null,
|
params.city ?? null,
|
||||||
p_email: params.email ?? null,
|
params.state ?? null,
|
||||||
p_website_url: params.websiteUrl ?? null,
|
params.postalCode ?? null,
|
||||||
p_street_address: params.streetAddress ?? null,
|
params.country ?? null,
|
||||||
p_city: params.city ?? null,
|
params.logoUrl ?? null,
|
||||||
p_state: params.state ?? null,
|
params.logoUrlDark ?? null,
|
||||||
p_postal_code: params.postalCode ?? null,
|
params.olatheSweetLogoUrl ?? null,
|
||||||
p_country: params.country ?? null,
|
params.olatheSweetLogoUrlDark ?? null,
|
||||||
p_logo_url: params.logoUrl ?? null,
|
params.defaultEmailSignature ?? null,
|
||||||
p_logo_url_dark: params.logoUrlDark ?? null,
|
params.invoiceFooterNotes ?? null,
|
||||||
p_olathe_sweet_logo_url: params.olatheSweetLogoUrl ?? null,
|
params.heroTagline ?? null,
|
||||||
p_olathe_sweet_logo_url_dark: params.olatheSweetLogoUrlDark ?? null,
|
params.aboutHeadline ?? null,
|
||||||
p_default_email_signature: params.defaultEmailSignature ?? null,
|
params.aboutSubheadline ?? null,
|
||||||
p_invoice_footer_notes: params.invoiceFooterNotes ?? null,
|
params.customFooterText ?? null,
|
||||||
p_hero_tagline: params.heroTagline ?? null,
|
params.showWholesaleLink ?? null,
|
||||||
p_about_headline: params.aboutHeadline ?? null,
|
params.showZipSearch ?? null,
|
||||||
p_about_subheadline: params.aboutSubheadline ?? null,
|
params.showSchedulePdf ?? null,
|
||||||
p_custom_footer_text: params.customFooterText ?? null,
|
params.showTextAlerts ?? null,
|
||||||
p_show_wholesale_link: params.showWholesaleLink ?? null,
|
params.schedulePdfNotes ?? null,
|
||||||
p_show_zip_search: params.showZipSearch ?? null,
|
params.heroImageUrl ?? null,
|
||||||
p_show_schedule_pdf: params.showSchedulePdf ?? null,
|
params.brandPrimaryColor ?? null,
|
||||||
p_show_text_alerts: params.showTextAlerts ?? null,
|
params.brandSecondaryColor ?? null,
|
||||||
p_schedule_pdf_notes: params.schedulePdfNotes ?? null,
|
params.brandBgColor ?? null,
|
||||||
p_hero_image_url: params.heroImageUrl ?? null,
|
params.brandTextColor ?? null,
|
||||||
p_brand_primary_color: params.brandPrimaryColor ?? null,
|
params.collectSalesTax ?? null,
|
||||||
p_brand_secondary_color: params.brandSecondaryColor ?? null,
|
params.nexusStates ?? null,
|
||||||
p_brand_bg_color: params.brandBgColor ?? null,
|
],
|
||||||
p_brand_text_color: params.brandTextColor ?? null,
|
);
|
||||||
p_collect_sales_tax: params.collectSalesTax ?? null,
|
return { success: true, settings: rows[0] as BrandSettings };
|
||||||
p_nexus_states: params.nexusStates ?? null,
|
} catch (err) {
|
||||||
}),
|
const message = err instanceof Error ? err.message : "Failed to save";
|
||||||
}
|
return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.text();
|
|
||||||
return { success: false, error: `Failed to save: ${err.slice(0, 200)}` };
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const data = await response.json();
|
|
||||||
return { success: true, settings: data };
|
|
||||||
}
|
|
||||||
|
|||||||
+144
-155
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -31,35 +28,6 @@ export type DashboardSummary = {
|
|||||||
active_products: number;
|
active_products: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function brandScopedFetch<T>(
|
|
||||||
endpoint: string,
|
|
||||||
params?: Record<string, string>
|
|
||||||
): Promise<T> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
|
||||||
|
|
||||||
let url = `${supabaseUrl}/rest/v1${endpoint}`;
|
|
||||||
if (params) {
|
|
||||||
const searchParams = new URLSearchParams(params);
|
|
||||||
url += `?${searchParams.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
...svcHeaders(supabaseKey),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.text();
|
|
||||||
throw new Error(`Dashboard fetch failed: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
|
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getDashboardStats(): Promise<DashboardStats> {
|
export async function getDashboardStats(): Promise<DashboardStats> {
|
||||||
@@ -74,58 +42,69 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|||||||
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||||
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
|
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// Get start of week (7 days ago) - kept for potential weekly comparison
|
// Fetch today's orders. `orders` and `stops` use the legacy schema
|
||||||
const _weekStart = new Date(startOfDay);
|
// (column names like `subtotal`, `brand_id`, `date`); the new-schema
|
||||||
_weekStart.setDate(_weekStart.getDate() - 6);
|
// Drizzle `orders` table doesn't have these. Raw SQL via the shared
|
||||||
|
// pg pool.
|
||||||
// Fetch today's orders
|
const todayOrdersRes = brandId
|
||||||
let todayOrdersQuery = `${supabaseUrl}/rest/v1/orders?select=id,subtotal,status`;
|
? await pool.query<{ subtotal: number; status: string }>(
|
||||||
todayOrdersQuery += `&created_at=gte.${startOfDay.toISOString()}`;
|
`SELECT subtotal, status
|
||||||
todayOrdersQuery += `&created_at=lt.${endOfDay.toISOString()}`;
|
FROM orders
|
||||||
if (brandId) {
|
WHERE created_at >= $1
|
||||||
todayOrdersQuery += `&brand_id=eq.${brandId}`;
|
AND created_at < $2
|
||||||
}
|
AND brand_id = $3`,
|
||||||
|
[startOfDay.toISOString(), endOfDay.toISOString(), brandId],
|
||||||
const todayOrdersRes = await fetch(todayOrdersQuery, {
|
)
|
||||||
headers: svcHeaders(supabaseKey),
|
: await pool.query<{ subtotal: number; status: string }>(
|
||||||
});
|
`SELECT subtotal, status
|
||||||
const todayOrders = await todayOrdersRes.json();
|
FROM orders
|
||||||
|
WHERE created_at >= $1
|
||||||
|
AND created_at < $2`,
|
||||||
|
[startOfDay.toISOString(), endOfDay.toISOString()],
|
||||||
|
);
|
||||||
|
const todayOrders = todayOrdersRes.rows;
|
||||||
|
|
||||||
// Calculate today's revenue and orders
|
// Calculate today's revenue and orders
|
||||||
const validOrders = (todayOrders as Array<{subtotal: number; status: string}>)
|
const validOrders = todayOrders.filter((o) => o.status !== "cancelled");
|
||||||
.filter(o => o.status !== "cancelled");
|
const todayRevenue = validOrders.reduce(
|
||||||
const todayRevenue = validOrders
|
(sum, o) => sum + (o.subtotal || 0),
|
||||||
.reduce((sum, o) => sum + (o.subtotal || 0), 0);
|
0,
|
||||||
|
);
|
||||||
const todayOrderCount = validOrders.length;
|
const todayOrderCount = validOrders.length;
|
||||||
|
|
||||||
// Fetch pending stops (stops where date >= today and status is scheduled/upcoming)
|
// Fetch pending stops (stops where date >= today and status is scheduled)
|
||||||
let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`;
|
const stopsRes = brandId
|
||||||
stopsQuery += `&date=gte.${startOfDay.toISOString().split("T")[0]}`;
|
? await pool.query<{ id: string }>(
|
||||||
stopsQuery += `&status=eq.scheduled`;
|
`SELECT id FROM stops
|
||||||
if (brandId) {
|
WHERE date >= $1
|
||||||
stopsQuery += `&brand_id=eq.${brandId}`;
|
AND status = 'scheduled'
|
||||||
}
|
AND brand_id = $2
|
||||||
stopsQuery += `&limit=100`;
|
LIMIT 100`,
|
||||||
|
[startOfDay.toISOString().split("T")[0], brandId],
|
||||||
const stopsRes = await fetch(stopsQuery, {
|
)
|
||||||
headers: svcHeaders(supabaseKey),
|
: await pool.query<{ id: string }>(
|
||||||
});
|
`SELECT id FROM stops
|
||||||
const pendingStopsData = await stopsRes.json();
|
WHERE date >= $1
|
||||||
const pendingStops = Array.isArray(pendingStopsData) ? pendingStopsData.length : 0;
|
AND status = 'scheduled'
|
||||||
|
LIMIT 100`,
|
||||||
|
[startOfDay.toISOString().split("T")[0]],
|
||||||
|
);
|
||||||
|
const pendingStops = stopsRes.rows.length;
|
||||||
|
|
||||||
// Fetch active products
|
// Fetch active products
|
||||||
let productsQuery = `${supabaseUrl}/rest/v1/products?select=id`;
|
const productsRes = brandId
|
||||||
productsQuery += `&active=eq.true`;
|
? await pool.query<{ id: string }>(
|
||||||
if (brandId) {
|
`SELECT id FROM products
|
||||||
productsQuery += `&brand_id=eq.${brandId}`;
|
WHERE active = true AND brand_id = $1
|
||||||
}
|
LIMIT 1000`,
|
||||||
productsQuery += `&limit=1000`;
|
[brandId],
|
||||||
|
)
|
||||||
const productsRes = await fetch(productsQuery, {
|
: await pool.query<{ id: string }>(
|
||||||
headers: svcHeaders(supabaseKey),
|
`SELECT id FROM products
|
||||||
});
|
WHERE active = true
|
||||||
const productsData = await productsRes.json();
|
LIMIT 1000`,
|
||||||
const activeProducts = Array.isArray(productsData) ? productsData.length : 0;
|
);
|
||||||
|
const activeProducts = productsRes.rows.length;
|
||||||
|
|
||||||
// Fetch weekly orders for chart (last 7 days)
|
// Fetch weekly orders for chart (last 7 days)
|
||||||
const weeklyOrders: number[] = [];
|
const weeklyOrders: number[] = [];
|
||||||
@@ -134,41 +113,57 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|||||||
dayStart.setDate(dayStart.getDate() - i);
|
dayStart.setDate(dayStart.getDate() - i);
|
||||||
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
let dayQuery = `${supabaseUrl}/rest/v1/orders?select=id,status`;
|
const dayRes = brandId
|
||||||
dayQuery += `&created_at=gte.${dayStart.toISOString()}`;
|
? await pool.query<{ id: string }>(
|
||||||
dayQuery += `&created_at=lt.${dayEnd.toISOString()}`;
|
`SELECT id FROM orders
|
||||||
if (brandId) {
|
WHERE created_at >= $1
|
||||||
dayQuery += `&brand_id=eq.${brandId}`;
|
AND created_at < $2
|
||||||
}
|
AND brand_id = $3
|
||||||
dayQuery += `&limit=1`;
|
LIMIT 1`,
|
||||||
|
[dayStart.toISOString(), dayEnd.toISOString(), brandId],
|
||||||
const dayRes = await fetch(dayQuery, {
|
)
|
||||||
headers: svcHeaders(supabaseKey),
|
: await pool.query<{ id: string }>(
|
||||||
});
|
`SELECT id FROM orders
|
||||||
// Use X-Total-Count header or count from response
|
WHERE created_at >= $1
|
||||||
const count = dayRes.headers.get("X-Total-Count");
|
AND created_at < $2
|
||||||
weeklyOrders.push(count ? parseInt(count) : 0);
|
LIMIT 1`,
|
||||||
|
[dayStart.toISOString(), dayEnd.toISOString()],
|
||||||
|
);
|
||||||
|
weeklyOrders.push(dayRes.rows.length > 0 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recent orders (last 10)
|
// Fetch recent orders (last 10)
|
||||||
let recentQuery = `${supabaseUrl}/rest/v1/orders?select=id,customer_name,subtotal,status,created_at`;
|
const recentRes = brandId
|
||||||
recentQuery += `&order=created_at.desc`;
|
? await pool.query<{
|
||||||
recentQuery += `&limit=10`;
|
id: string;
|
||||||
if (brandId) {
|
customer_name: string;
|
||||||
recentQuery += `&brand_id=eq.${brandId}`;
|
subtotal: number;
|
||||||
}
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, customer_name, subtotal, status, created_at
|
||||||
|
FROM orders
|
||||||
|
WHERE brand_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
[brandId],
|
||||||
|
)
|
||||||
|
: await pool.query<{
|
||||||
|
id: string;
|
||||||
|
customer_name: string;
|
||||||
|
subtotal: number;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, customer_name, subtotal, status, created_at
|
||||||
|
FROM orders
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
);
|
||||||
|
|
||||||
const recentRes = await fetch(recentQuery, {
|
const recentOrders = recentRes.rows
|
||||||
headers: {
|
.filter((o) => o.status !== "cancelled")
|
||||||
...svcHeaders(supabaseKey),
|
.map((o) => ({
|
||||||
Prefer: "count=exact",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const recentOrdersData = await recentRes.json();
|
|
||||||
|
|
||||||
const recentOrders = (Array.isArray(recentOrdersData) ? recentOrdersData : [])
|
|
||||||
.filter((o: {status: string}) => o.status !== "cancelled")
|
|
||||||
.map((o: {id: string; customer_name: string; subtotal: number; status: string; created_at: string}) => ({
|
|
||||||
id: o.id || "",
|
id: o.id || "",
|
||||||
customer_name: o.customer_name || "Guest",
|
customer_name: o.customer_name || "Guest",
|
||||||
total: o.subtotal || 0,
|
total: o.subtotal || 0,
|
||||||
@@ -186,7 +181,6 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch dashboard stats:", error);
|
console.error("Failed to fetch dashboard stats:", error);
|
||||||
// Return zeros on error
|
|
||||||
return {
|
return {
|
||||||
todayOrders: 0,
|
todayOrders: 0,
|
||||||
todayRevenue: 0,
|
todayRevenue: 0,
|
||||||
@@ -207,58 +201,53 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
|
|||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
// Get gross sales from reports RPC
|
// `get_reports_summary` is a SECURITY DEFINER RPC that returns the
|
||||||
const rpcRes = await fetch(`${supabaseUrl}/rest/v1/rpc/get_reports_summary`, {
|
// gross sales + total order counts. Migration 031.
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
...svcHeaders(supabaseKey),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
p_brand_id: brandId,
|
|
||||||
p_start_date: thirtyDaysAgo.toISOString().split("T")[0],
|
|
||||||
p_end_date: new Date().toISOString().split("T")[0],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
let total_revenue = 0;
|
let total_revenue = 0;
|
||||||
let total_orders = 0;
|
let total_orders = 0;
|
||||||
|
try {
|
||||||
if (rpcRes.ok) {
|
const { rows } = await pool.query<{
|
||||||
const data = await rpcRes.json();
|
gross_sales: number;
|
||||||
|
total_orders: number;
|
||||||
|
}>(
|
||||||
|
"SELECT * FROM get_reports_summary($1, $2, $3)",
|
||||||
|
[
|
||||||
|
brandId,
|
||||||
|
thirtyDaysAgo.toISOString().split("T")[0],
|
||||||
|
new Date().toISOString().split("T")[0],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const data = rows[0];
|
||||||
total_revenue = data?.gross_sales ?? 0;
|
total_revenue = data?.gross_sales ?? 0;
|
||||||
total_orders = data?.total_orders ?? 0;
|
total_orders = data?.total_orders ?? 0;
|
||||||
|
} catch {
|
||||||
|
// Fall through with zeros if the RPC is missing.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get active stops count
|
// Get active stops count
|
||||||
let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`;
|
const stopsRes = brandId
|
||||||
stopsQuery += `&date=gte.${new Date().toISOString().split("T")[0]}`;
|
? await pool.query<{ id: string }>(
|
||||||
stopsQuery += `&status=eq.scheduled`;
|
`SELECT id FROM stops
|
||||||
if (brandId) {
|
WHERE date >= $1 AND status = 'scheduled' AND brand_id = $2`,
|
||||||
stopsQuery += `&brand_id=eq.${brandId}`;
|
[new Date().toISOString().split("T")[0], brandId],
|
||||||
}
|
)
|
||||||
|
: await pool.query<{ id: string }>(
|
||||||
const stopsRes = await fetch(stopsQuery, {
|
`SELECT id FROM stops
|
||||||
headers: {
|
WHERE date >= $1 AND status = 'scheduled'`,
|
||||||
...svcHeaders(supabaseKey),
|
[new Date().toISOString().split("T")[0]],
|
||||||
Prefer: "count=exact",
|
);
|
||||||
},
|
const activeStops = stopsRes.rows.length;
|
||||||
});
|
|
||||||
const activeStops = parseInt(stopsRes.headers.get("X-Total-Count") || "0");
|
|
||||||
|
|
||||||
// Get active products count
|
// Get active products count
|
||||||
let productsQuery = `${supabaseUrl}/rest/v1/products?select=id&active=eq.true`;
|
const productsRes = brandId
|
||||||
if (brandId) {
|
? await pool.query<{ id: string }>(
|
||||||
productsQuery += `&brand_id=eq.${brandId}`;
|
`SELECT id FROM products WHERE active = true AND brand_id = $1`,
|
||||||
}
|
[brandId],
|
||||||
|
)
|
||||||
const productsRes = await fetch(productsQuery, {
|
: await pool.query<{ id: string }>(
|
||||||
headers: {
|
`SELECT id FROM products WHERE active = true`,
|
||||||
...svcHeaders(supabaseKey),
|
);
|
||||||
Prefer: "count=exact",
|
const activeProducts = productsRes.rows.length;
|
||||||
},
|
|
||||||
});
|
|
||||||
const activeProducts = parseInt(productsRes.headers.get("X-Total-Count") || "0");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total_revenue,
|
total_revenue,
|
||||||
@@ -295,4 +284,4 @@ function formatTimeAgo(dateString: string): string {
|
|||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
|
||||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-137
@@ -3,7 +3,7 @@
|
|||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
export type LocationInput = {
|
export type LocationInput = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -56,39 +56,37 @@ export async function createLocation(
|
|||||||
const effectiveBrandId = activeBrandId;
|
const effectiveBrandId = activeBrandId;
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
|
if (!effectiveBrandId) return { success: false, error: "No brand selected" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
try {
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const { rows } = await pool.query<{ id: string; slug: string }>(
|
||||||
|
`SELECT * FROM admin_create_location(
|
||||||
const res = await fetch(
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||||
`${supabaseUrl}/rest/v1/rpc/admin_create_location`,
|
)`,
|
||||||
{
|
[
|
||||||
method: "POST",
|
effectiveBrandId,
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
input.name,
|
||||||
body: JSON.stringify({
|
input.address ?? null,
|
||||||
p_brand_id: effectiveBrandId,
|
input.city ?? null,
|
||||||
p_name: input.name,
|
input.state ?? null,
|
||||||
p_address: input.address ?? null,
|
input.zip ?? null,
|
||||||
p_city: input.city ?? null,
|
input.phone ?? null,
|
||||||
p_state: input.state ?? null,
|
input.contact_name ?? null,
|
||||||
p_zip: input.zip ?? null,
|
input.contact_email ?? null,
|
||||||
p_phone: input.phone ?? null,
|
input.notes ?? null,
|
||||||
p_contact_name: input.contact_name ?? null,
|
],
|
||||||
p_contact_email: input.contact_email ?? null,
|
);
|
||||||
p_notes: input.notes ?? null,
|
const data = rows[0];
|
||||||
p_active: input.active ?? true,
|
if (!data) {
|
||||||
}),
|
return { success: false, error: "Insert failed" };
|
||||||
}
|
}
|
||||||
);
|
revalidateTag("locations", "default");
|
||||||
|
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||||
if (!res.ok) {
|
return { success: true, id: data.id, slug: data.slug };
|
||||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
} catch (err) {
|
||||||
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" };
|
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) ───────────────────────────────────────────────────────────
|
// ── Create (batch) ───────────────────────────────────────────────────────────
|
||||||
@@ -108,24 +106,21 @@ export async function createLocationsBatch(
|
|||||||
const effectiveBrandId = activeBrandId;
|
const effectiveBrandId = activeBrandId;
|
||||||
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
|
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
let inserted: { id?: string }[] = [];
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
try {
|
||||||
|
const { rows } = await pool.query<{ id?: string }>(
|
||||||
const res = await fetch(
|
"SELECT * FROM admin_create_locations_batch($1, $2::jsonb)",
|
||||||
`${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`,
|
[effectiveBrandId, JSON.stringify(locations)],
|
||||||
{
|
);
|
||||||
method: "POST",
|
inserted = rows;
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
} catch (err) {
|
||||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }),
|
return {
|
||||||
}
|
success: false,
|
||||||
);
|
created: 0,
|
||||||
|
error: err instanceof Error ? err.message : "Insert failed",
|
||||||
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" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const inserted = await res.json();
|
|
||||||
revalidateTag("locations", "default");
|
revalidateTag("locations", "default");
|
||||||
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
|
||||||
return {
|
return {
|
||||||
@@ -144,25 +139,23 @@ export async function updateLocation(
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
let data: { success?: boolean; error?: string } = {};
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
try {
|
||||||
|
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
|
||||||
const res = await fetch(
|
"SELECT * FROM admin_update_location($1, $2, $3::jsonb)",
|
||||||
`${supabaseUrl}/rest/v1/rpc/admin_update_location`,
|
[locationId, brandId, JSON.stringify(updates)],
|
||||||
{
|
);
|
||||||
method: "POST",
|
data = rows[0] ?? {};
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
} catch (err) {
|
||||||
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }),
|
return {
|
||||||
}
|
success: false,
|
||||||
);
|
error: err instanceof Error ? err.message : "Update failed",
|
||||||
|
};
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
|
||||||
return { success: false, error: (err as { message?: string }).message ?? "Update failed" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
if (!data.success) {
|
||||||
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
|
return { success: false, error: data.error ?? "Update failed" };
|
||||||
|
}
|
||||||
|
|
||||||
revalidateTag("locations", "default");
|
revalidateTag("locations", "default");
|
||||||
revalidateTag(`brand:${brandId}: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) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
let data: { success?: boolean; error?: string } = {};
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
try {
|
||||||
|
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
|
||||||
const res = await fetch(
|
"SELECT * FROM admin_delete_location($1, $2)",
|
||||||
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`,
|
[locationId, brandId],
|
||||||
{
|
);
|
||||||
method: "POST",
|
data = rows[0] ?? {};
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
} catch (err) {
|
||||||
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }),
|
return {
|
||||||
}
|
success: false,
|
||||||
);
|
error: err instanceof Error ? err.message : "Delete failed",
|
||||||
|
};
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
|
||||||
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
if (!data.success) {
|
||||||
if (!data.success) return { success: false, error: data.error ?? "Delete failed" };
|
return { success: false, error: data.error ?? "Delete failed" };
|
||||||
|
}
|
||||||
|
|
||||||
revalidateTag("locations", "default");
|
revalidateTag("locations", "default");
|
||||||
revalidateTag(`brand:${brandId}:locations`, "default");
|
revalidateTag(`brand:${brandId}:locations`, "default");
|
||||||
@@ -210,27 +201,18 @@ export async function adminListLocations(
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return [];
|
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 activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
const effectiveBrandId = activeBrandId;
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
const res = await fetch(
|
try {
|
||||||
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`,
|
const { rows } = await pool.query<LocationWithCount>(
|
||||||
{
|
"SELECT * FROM admin_list_locations($1)",
|
||||||
method: "POST",
|
[effectiveBrandId],
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
);
|
||||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
return Array.isArray(rows) ? rows : [];
|
||||||
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] },
|
} catch {
|
||||||
}
|
return [];
|
||||||
);
|
}
|
||||||
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return Array.isArray(data) ? (data as LocationWithCount[]) : [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Read (public, by brand slug) ─────────────────────────────────────────────
|
// ── Read (public, by brand slug) ─────────────────────────────────────────────
|
||||||
@@ -244,22 +226,15 @@ export async function getPublicLocationsForBrand(
|
|||||||
): Promise<PublicLocation[]> {
|
): Promise<PublicLocation[]> {
|
||||||
if (!brandSlug) return [];
|
if (!brandSlug) return [];
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
try {
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const { rows } = await pool.query<PublicLocation>(
|
||||||
|
"SELECT * FROM get_locations_for_brand($1)",
|
||||||
const res = await fetch(
|
[brandSlug],
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`,
|
);
|
||||||
{
|
return Array.isArray(rows) ? rows : [];
|
||||||
method: "POST",
|
} catch {
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
return [];
|
||||||
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[]) : [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Attach a stop to a location ──────────────────────────────────────────────
|
// ── Attach a stop to a location ──────────────────────────────────────────────
|
||||||
@@ -272,29 +247,23 @@ export async function attachStopToLocation(
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
let data: { success?: boolean; error?: string } = {};
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
try {
|
||||||
|
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
|
||||||
const res = await fetch(
|
"SELECT * FROM admin_attach_location_to_stop($1, $2, $3)",
|
||||||
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`,
|
[stopId, locationId, brandId],
|
||||||
{
|
);
|
||||||
method: "POST",
|
data = rows[0] ?? {};
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
} catch (err) {
|
||||||
body: JSON.stringify({
|
return {
|
||||||
p_stop_id: stopId,
|
success: false,
|
||||||
p_location_id: locationId,
|
error: err instanceof Error ? err.message : "Attach failed",
|
||||||
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" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
if (!data.success) {
|
||||||
if (!data.success) return { success: false, error: data.error ?? "Attach failed" };
|
return { success: false, error: data.error ?? "Attach failed" };
|
||||||
|
}
|
||||||
|
|
||||||
revalidateTag("stops", "default");
|
revalidateTag("stops", "default");
|
||||||
revalidateTag("locations", "default");
|
revalidateTag("locations", "default");
|
||||||
|
|||||||
+31
-40
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
import { mockOrders, mockStops } from "@/lib/mock-data";
|
import { mockOrders, mockStops } from "@/lib/mock-data";
|
||||||
|
|
||||||
type AdminOrder = {
|
type AdminOrder = {
|
||||||
@@ -149,29 +149,29 @@ export async function getAdminOrders(): Promise<AdminOrdersResponse> {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
const brandId = adminUser?.brand_id ?? null;
|
const brandId = adminUser?.brand_id ?? null;
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// The legacy `get_admin_orders` RPC is a SECURITY DEFINER function that
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
// returns orders + stops joined with order_items. Call it directly via
|
||||||
|
// the shared pg pool so we don't go through Supabase REST.
|
||||||
const response = await fetch(
|
try {
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_admin_orders`,
|
const { rows } = await pool.query<AdminOrdersResult>(
|
||||||
{
|
"SELECT * FROM get_admin_orders($1)",
|
||||||
method: "POST",
|
[brandId],
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
);
|
||||||
body: JSON.stringify({ p_brand_id: brandId }),
|
const data = rows[0] ?? { orders: [], stops: [] };
|
||||||
}
|
return {
|
||||||
);
|
success: true,
|
||||||
|
orders: data.orders ?? [],
|
||||||
if (!response.ok) {
|
stops: data.stops ?? [],
|
||||||
return { success: false, orders: [], stops: [], error: `HTTP ${response.status}: ${response.statusText}` };
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
orders: [],
|
||||||
|
stops: [],
|
||||||
|
error: err instanceof Error ? err.message : "Failed to fetch orders",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
orders: data?.orders ?? [],
|
|
||||||
stops: data?.stops ?? [],
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminStops(): Promise<AdminStop[]> {
|
export async function getAdminStops(): Promise<AdminStop[]> {
|
||||||
@@ -216,22 +216,13 @@ export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDe
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
const brandId = adminUser?.brand_id ?? null;
|
const brandId = adminUser?.brand_id ?? null;
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
try {
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const { rows } = await pool.query<AdminOrderDetail>(
|
||||||
|
"SELECT * FROM get_admin_order_detail($1, $2)",
|
||||||
const response = await fetch(
|
[orderId, brandId],
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_admin_order_detail`,
|
);
|
||||||
{
|
return rows[0] ?? null;
|
||||||
method: "POST",
|
} catch {
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ p_order_id: orderId, p_brand_id: brandId }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
export type AdminCreateOrderItem = {
|
export type AdminCreateOrderItem = {
|
||||||
@@ -58,9 +58,6 @@ export async function createAdminOrder(
|
|||||||
return { success: false, error: "At least one item is required" };
|
return { success: false, error: "At least one item is required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
|
||||||
|
|
||||||
// Build items for RPC (match checkout shape)
|
// Build items for RPC (match checkout shape)
|
||||||
const rpcItems = input.items.map((i) => ({
|
const rpcItems = input.items.map((i) => ({
|
||||||
product_id: i.product_id,
|
product_id: i.product_id,
|
||||||
@@ -77,33 +74,23 @@ export async function createAdminOrder(
|
|||||||
const taxLocation = null;
|
const taxLocation = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const { rows } = await pool.query<{ id: string }>(
|
||||||
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
`SELECT * FROM create_order_with_items(
|
||||||
{
|
$1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9
|
||||||
method: "POST",
|
)`,
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
[
|
||||||
body: JSON.stringify({
|
idempotencyKey,
|
||||||
p_idempotency_key: idempotencyKey,
|
input.customer_name.trim(),
|
||||||
p_customer_name: input.customer_name.trim(),
|
input.customer_email?.trim() || null,
|
||||||
p_customer_email: input.customer_email?.trim() || null,
|
input.customer_phone?.trim() || null,
|
||||||
p_customer_phone: input.customer_phone?.trim() || null,
|
input.stop_id || null,
|
||||||
p_stop_id: input.stop_id || null,
|
JSON.stringify(rpcItems),
|
||||||
p_items: rpcItems,
|
taxAmount,
|
||||||
p_tax_amount: taxAmount,
|
taxRate,
|
||||||
p_tax_rate: taxRate,
|
taxLocation,
|
||||||
p_tax_location: taxLocation,
|
],
|
||||||
// The RPC may also accept brand scoping internally via stop or we can extend later.
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
const data = rows[0];
|
||||||
if (!response.ok) {
|
|
||||||
const errText = await response.text().catch(() => "Unknown error");
|
|
||||||
return { success: false, error: `Failed to create order: ${errText}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data || !data.id) {
|
if (!data || !data.id) {
|
||||||
return { success: false, error: "Order created but no ID returned" };
|
return { success: false, error: "Order created but no ID returned" };
|
||||||
}
|
}
|
||||||
@@ -112,18 +99,20 @@ export async function createAdminOrder(
|
|||||||
if (input.internal_notes?.trim()) {
|
if (input.internal_notes?.trim()) {
|
||||||
// Best-effort; don't fail the whole create if this secondary update fails.
|
// Best-effort; don't fail the whole create if this secondary update fails.
|
||||||
try {
|
try {
|
||||||
await fetch(`${supabaseUrl}/rest/v1/orders?id=eq.${data.id}`, {
|
await pool.query(
|
||||||
method: "PATCH",
|
"UPDATE orders SET internal_notes = $1 WHERE id = $2",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
[input.internal_notes.trim(), data.id],
|
||||||
body: JSON.stringify({ internal_notes: input.internal_notes.trim() }),
|
);
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, orderId: data.id, order: data };
|
return { success: true, orderId: data.id, order: data };
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
return { success: false, error: err?.message ?? "Unexpected error creating order" };
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Unexpected error creating order",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
import { logAuditEvent } from "@/actions/audit";
|
import { logAuditEvent } from "@/actions/audit";
|
||||||
|
|
||||||
export type CreateRefundResult =
|
export type CreateRefundResult =
|
||||||
@@ -22,37 +22,39 @@ export async function createRefund(
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
let inserted: { id: string } | null = null;
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
try {
|
||||||
|
const { rows } = await pool.query<{ id: string }>(
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/refunds`, {
|
`INSERT INTO refunds (order_id, amount, reason, processor, processor_refund_id, status)
|
||||||
method: "POST",
|
VALUES ($1, $2, $3, $4, $5, 'pending')
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
RETURNING id`,
|
||||||
body: JSON.stringify({
|
[
|
||||||
order_id: orderId,
|
orderId,
|
||||||
amount: data.amount,
|
data.amount,
|
||||||
reason: data.reason ?? null,
|
data.reason ?? null,
|
||||||
processor: data.processor ?? null,
|
data.processor ?? null,
|
||||||
processor_refund_id: data.processor_refund_id ?? null,
|
data.processor_refund_id ?? null,
|
||||||
status: "pending",
|
],
|
||||||
}),
|
);
|
||||||
});
|
inserted = rows[0] ?? null;
|
||||||
|
} catch (err) {
|
||||||
if (!res.ok) {
|
return {
|
||||||
const err = await res.text();
|
success: false,
|
||||||
return { success: false, error: `Failed: ${err}` };
|
error: err instanceof Error ? err.message : "Failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!inserted) {
|
||||||
|
return { success: false, error: "Insert returned no row" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const inserted = await res.json();
|
|
||||||
|
|
||||||
logAuditEvent({
|
logAuditEvent({
|
||||||
table_name: "refunds",
|
table_name: "refunds",
|
||||||
record_id: inserted[0]?.id ?? "",
|
record_id: inserted.id,
|
||||||
action: "INSERT",
|
action: "INSERT",
|
||||||
old_data: {},
|
old_data: {},
|
||||||
new_data: { order_id: orderId, amount: data.amount },
|
new_data: { order_id: orderId, amount: data.amount },
|
||||||
brand_id: brandId,
|
brand_id: brandId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, id: inserted[0]?.id ?? "" };
|
return { success: true, id: inserted.id };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
import { logAuditEvent } from "@/actions/audit";
|
import { logAuditEvent } from "@/actions/audit";
|
||||||
|
|
||||||
export type UpdateOrderResult =
|
export type UpdateOrderResult =
|
||||||
@@ -36,33 +36,51 @@ export async function updateOrder(
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// Build a partial SET clause. Each set column is added in the order
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
// the caller passed it; we don't care about column ordering.
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const push = (col: string, val: unknown) => {
|
||||||
|
params.push(val);
|
||||||
|
sets.push(`${col} = $${params.length}`);
|
||||||
|
};
|
||||||
|
|
||||||
const patchData: Record<string, unknown> = {};
|
if (data.customer_name !== undefined) push("customer_name", data.customer_name);
|
||||||
if (data.customer_name !== undefined) patchData.customer_name = data.customer_name;
|
if (data.customer_email !== undefined) push("customer_email", data.customer_email);
|
||||||
if (data.customer_email !== undefined) patchData.customer_email = data.customer_email;
|
if (data.customer_phone !== undefined) push("customer_phone", data.customer_phone);
|
||||||
if (data.customer_phone !== undefined) patchData.customer_phone = data.customer_phone;
|
if (data.status !== undefined) push("status", data.status);
|
||||||
if (data.status !== undefined) patchData.status = data.status;
|
if (data.discount_amount !== undefined) push("discount_amount", data.discount_amount);
|
||||||
if (data.discount_amount !== undefined) patchData.discount_amount = data.discount_amount;
|
if (data.discount_reason !== undefined) push("discount_reason", data.discount_reason);
|
||||||
if (data.discount_reason !== undefined) patchData.discount_reason = data.discount_reason;
|
if (data.internal_notes !== undefined) push("internal_notes", data.internal_notes);
|
||||||
if (data.internal_notes !== undefined) patchData.internal_notes = data.internal_notes;
|
if (data.pickup_complete !== undefined) push("pickup_complete", data.pickup_complete);
|
||||||
if (data.pickup_complete !== undefined) patchData.pickup_complete = data.pickup_complete;
|
if (data.pickup_completed_at !== undefined) push("pickup_completed_at", data.pickup_completed_at);
|
||||||
if (data.pickup_completed_at !== undefined) patchData.pickup_completed_at = data.pickup_completed_at;
|
if (data.subtotal !== undefined) push("subtotal", data.subtotal);
|
||||||
if (data.subtotal !== undefined) patchData.subtotal = data.subtotal;
|
if (data.payment_processor !== undefined) push("payment_processor", data.payment_processor);
|
||||||
if (data.payment_processor !== undefined) patchData.payment_processor = data.payment_processor;
|
if (data.payment_status !== undefined) push("payment_status", data.payment_status);
|
||||||
if (data.payment_status !== undefined) patchData.payment_status = data.payment_status;
|
if (data.payment_transaction_id !== undefined) push("payment_transaction_id", data.payment_transaction_id);
|
||||||
if (data.payment_transaction_id !== undefined) patchData.payment_transaction_id = data.payment_transaction_id;
|
|
||||||
|
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/orders?id=eq.${orderId}`, {
|
if (sets.length === 0) {
|
||||||
method: "PATCH",
|
return { success: true };
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
}
|
||||||
body: JSON.stringify(patchData),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
params.push(orderId);
|
||||||
const err = await res.text();
|
const patchData = Object.fromEntries(
|
||||||
return { success: false, error: `Failed: ${err}` };
|
sets.map((s, i) => {
|
||||||
|
const col = s.split(" = ")[0];
|
||||||
|
return [col, params[i]];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE orders SET ${sets.join(", ")} WHERE id = $${params.length}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Failed",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuditEvent({
|
logAuditEvent({
|
||||||
@@ -85,25 +103,33 @@ export async function updateOrderItem(
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const sets: string[] = [];
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
const params: unknown[] = [];
|
||||||
|
const push = (col: string, val: unknown) => {
|
||||||
|
params.push(val);
|
||||||
|
sets.push(`${col} = $${params.length}`);
|
||||||
|
};
|
||||||
|
|
||||||
const patchData: Record<string, unknown> = {};
|
if (data.quantity !== undefined) push("quantity", data.quantity);
|
||||||
if (data.quantity !== undefined) patchData.quantity = data.quantity;
|
if (data.price !== undefined) push("price", data.price);
|
||||||
if (data.price !== undefined) patchData.price = data.price;
|
|
||||||
|
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, {
|
if (sets.length === 0) {
|
||||||
method: "PATCH",
|
return { success: true };
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(patchData),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.text();
|
|
||||||
return { success: false, error: `Failed: ${err}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
params.push(itemId);
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE order_items SET ${sets.join(", ")} WHERE id = $${params.length}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult> {
|
export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult> {
|
||||||
@@ -111,18 +137,13 @@ export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
try {
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
await pool.query("DELETE FROM order_items WHERE id = $1", [itemId]);
|
||||||
|
return { success: true };
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, {
|
} catch (err) {
|
||||||
method: "DELETE",
|
return {
|
||||||
headers: { ...svcHeaders(supabaseKey) },
|
success: false,
|
||||||
});
|
error: err instanceof Error ? err.message : "Failed",
|
||||||
|
};
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.text();
|
|
||||||
return { success: false, error: `Failed: ${err}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-41
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
export type PaymentProvider = "stripe" | "square" | "manual";
|
export type PaymentProvider = "stripe" | "square" | "manual";
|
||||||
|
|
||||||
@@ -26,21 +26,15 @@ export type GetPaymentSettingsResult =
|
|||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> {
|
export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
try {
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
const { rows } = await pool.query<PaymentSettings>(
|
||||||
|
"SELECT * FROM get_payment_settings($1)",
|
||||||
const response = await fetch(
|
[brandId],
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_payment_settings`,
|
);
|
||||||
{
|
return { success: true, settings: rows[0] ?? null };
|
||||||
method: "POST",
|
} catch {
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
return { success: false, error: "Failed to fetch payment settings" };
|
||||||
body: JSON.stringify({ p_brand_id: brandId }),
|
}
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) return { success: false, error: "Failed to fetch payment settings" };
|
|
||||||
const data = await response.json();
|
|
||||||
return { success: true, settings: data };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SavePaymentSettingsResult =
|
export type SavePaymentSettingsResult =
|
||||||
@@ -72,30 +66,25 @@ export async function savePaymentSettings(params: {
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
try {
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
await pool.query(
|
||||||
|
`SELECT upsert_payment_settings(
|
||||||
const response = await fetch(
|
$1, $2, $3, $4, $5, $6, $7, $8, $9
|
||||||
`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`,
|
)`,
|
||||||
{
|
[
|
||||||
method: "POST",
|
params.brandId,
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
params.provider,
|
||||||
body: JSON.stringify({
|
params.stripePublishableKey ?? null,
|
||||||
p_brand_id: params.brandId,
|
params.stripeSecretKey ?? null,
|
||||||
p_provider: params.provider,
|
params.stripeUserId ?? null,
|
||||||
p_stripe_publishable_key: params.stripePublishableKey ?? null,
|
params.squareAccessToken ?? null,
|
||||||
p_stripe_secret_key: params.stripeSecretKey ?? null,
|
params.squareLocationId ?? null,
|
||||||
p_stripe_user_id: params.stripeUserId ?? null,
|
params.squareSyncEnabled ?? null,
|
||||||
p_square_access_token: params.squareAccessToken ?? null,
|
params.squareInventoryMode ?? null,
|
||||||
p_square_location_id: params.squareLocationId ?? null,
|
],
|
||||||
p_square_sync_enabled: params.squareSyncEnabled ?? null,
|
);
|
||||||
p_square_inventory_mode: params.squareInventoryMode ?? null,
|
return { success: true };
|
||||||
}),
|
} catch {
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return { success: false, error: "Failed to save payment settings" };
|
return { success: false, error: "Failed to save payment settings" };
|
||||||
}
|
}
|
||||||
return { success: true };
|
}
|
||||||
}
|
|
||||||
|
|||||||
+36
-71
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { logAuditEvent } from "@/actions/audit";
|
import { logAuditEvent } from "@/actions/audit";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
type MarkPickupResult =
|
type MarkPickupResult =
|
||||||
| { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
|
| { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
|
||||||
@@ -30,67 +30,44 @@ export async function markPickupComplete(
|
|||||||
|
|
||||||
// brand_admin: verify the order belongs to their brand
|
// brand_admin: verify the order belongs to their brand
|
||||||
if (adminUser.role === "brand_admin" && adminUser.brand_id) {
|
if (adminUser.role === "brand_admin" && adminUser.brand_id) {
|
||||||
const brandRes = await fetch(
|
const orderRes = await pool.query<{ brand_id: string | null; stop_id: string | null }>(
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=stop_id,brand_id`,
|
"SELECT brand_id, stop_id FROM orders WHERE id = $1 LIMIT 1",
|
||||||
{
|
[orderId],
|
||||||
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
if (orderRes.rows.length === 0) {
|
||||||
if (!brandRes.ok) {
|
|
||||||
return { success: false, error: "Failed to verify order ownership" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderData = await brandRes.json();
|
|
||||||
if (!Array.isArray(orderData) || orderData.length === 0) {
|
|
||||||
return { success: false, error: "Order not found" };
|
return { success: false, error: "Order not found" };
|
||||||
}
|
}
|
||||||
|
const order = orderRes.rows[0];
|
||||||
|
|
||||||
const order = orderData[0];
|
|
||||||
|
|
||||||
// Check brand_id on the order first, then fall back to stop brand
|
|
||||||
if (order.brand_id && order.brand_id !== adminUser.brand_id) {
|
if (order.brand_id && order.brand_id !== adminUser.brand_id) {
|
||||||
return { success: false, error: "Not authorized for this order" };
|
return { success: false, error: "Not authorized for this order" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!order.brand_id && order.stop_id) {
|
if (!order.brand_id && order.stop_id) {
|
||||||
const stopRes = await fetch(
|
const stopRes = await pool.query<{ brand_id: string | null }>(
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?id=eq.${order.stop_id}&select=brand_id`,
|
"SELECT brand_id FROM stops WHERE id = $1 LIMIT 1",
|
||||||
{
|
[order.stop_id],
|
||||||
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
if (stopRes.ok) {
|
stopRes.rows[0] &&
|
||||||
const stopData = await stopRes.json();
|
stopRes.rows[0].brand_id !== adminUser.brand_id
|
||||||
if (Array.isArray(stopData) && stopData[0]?.brand_id !== adminUser.brand_id) {
|
) {
|
||||||
return { success: false, error: "Not authorized for this order" };
|
return { success: false, error: "Not authorized for this order" };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH the order
|
// UPDATE the order
|
||||||
const patchRes = await fetch(
|
const updateRes = await pool.query(
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`,
|
`UPDATE orders
|
||||||
{
|
SET pickup_complete = true,
|
||||||
method: "PATCH",
|
pickup_completed_at = $1,
|
||||||
headers: {
|
pickup_completed_by = $2
|
||||||
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
|
WHERE id = $3`,
|
||||||
"Content-Type": "application/json",
|
[now, performedBy, orderId],
|
||||||
Prefer: "return=representation",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
pickup_complete: true,
|
|
||||||
pickup_completed_at: now,
|
|
||||||
pickup_completed_by: performedBy,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
if ((updateRes.rowCount ?? 0) === 0) {
|
||||||
if (!patchRes.ok) {
|
return { success: false, error: "Order not found" };
|
||||||
const err = await patchRes.json().catch(() => ({ message: "Patch failed" }));
|
|
||||||
return { success: false, error: err.message ?? "Failed to update pickup" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget audit log
|
// Fire-and-forget audit log
|
||||||
@@ -113,31 +90,19 @@ export async function markPickupComplete(
|
|||||||
|
|
||||||
// Emit pickup_completed event
|
// Emit pickup_completed event
|
||||||
// Need brand_id — get it from the order we just patched
|
// Need brand_id — get it from the order we just patched
|
||||||
const orderRes = await fetch(
|
const orderRes = await pool.query<{ brand_id: string | null }>(
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=brand_id`,
|
"SELECT brand_id FROM orders WHERE id = $1",
|
||||||
{
|
[orderId],
|
||||||
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (orderRes.ok) {
|
const orderBrandId = orderRes.rows[0]?.brand_id;
|
||||||
const orderData = await orderRes.json();
|
if (orderBrandId) {
|
||||||
const orderBrandId = Array.isArray(orderData) && orderData[0]?.brand_id;
|
try {
|
||||||
if (orderBrandId) {
|
await pool.query(
|
||||||
await fetch(
|
"SELECT * FROM record_pickup_completed_event($1, $2, $3)",
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/record_pickup_completed_event`,
|
[orderId, orderBrandId, performedBy],
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
p_order_id: orderId,
|
|
||||||
p_brand_id: orderBrandId,
|
|
||||||
p_actor_id: performedBy,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
} catch {
|
||||||
|
// Event emission is best-effort.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,4 +111,4 @@ export async function markPickupComplete(
|
|||||||
pickup_completed_at: now,
|
pickup_completed_at: now,
|
||||||
pickup_completed_by: performedBy,
|
pickup_completed_by: performedBy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-41
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
|
import { logAuditEvent } from "@/actions/audit";
|
||||||
|
|
||||||
export async function deleteProduct(
|
export async function deleteProduct(
|
||||||
productId: string,
|
productId: string,
|
||||||
@@ -23,27 +24,23 @@ export async function deleteProduct(
|
|||||||
}
|
}
|
||||||
const effectiveBrandId = activeBrandId;
|
const effectiveBrandId = activeBrandId;
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// `delete_product` is a SECURITY DEFINER RPC that soft-deletes the row
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
// (sets `deleted_at`) and guards against products referenced by
|
||||||
|
// existing order_items. It returns JSONB {success, error?}.
|
||||||
const response = await fetch(
|
let result: { success?: boolean; error?: string } = {};
|
||||||
`${supabaseUrl}/rest/v1/rpc/delete_product`,
|
try {
|
||||||
{
|
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||||
method: "POST",
|
"SELECT * FROM delete_product($1, $2)",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
[productId, effectiveBrandId],
|
||||||
body: JSON.stringify({
|
);
|
||||||
p_product_id: productId,
|
result = rows[0] ?? {};
|
||||||
p_brand_id: effectiveBrandId,
|
} catch (err) {
|
||||||
}),
|
return {
|
||||||
}
|
success: false,
|
||||||
);
|
error: err instanceof Error ? err.message : "Delete failed",
|
||||||
|
};
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.json().catch(() => ({ message: "Request failed" }));
|
|
||||||
return { success: false, error: err.message ?? "Delete failed" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return { success: false, error: result.error ?? "Delete failed" };
|
return { success: false, error: result.error ?? "Delete failed" };
|
||||||
}
|
}
|
||||||
@@ -59,24 +56,3 @@ export async function deleteProduct(
|
|||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logAuditEvent(event: {
|
|
||||||
table_name: string;
|
|
||||||
record_id: string;
|
|
||||||
action: string;
|
|
||||||
old_data: Record<string, unknown>;
|
|
||||||
new_data: Record<string, unknown>;
|
|
||||||
brand_id: string | null;
|
|
||||||
}) {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/rpc.log_audit_event`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(event),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+83
-75
@@ -3,7 +3,7 @@
|
|||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
export type StopImportRow = {
|
export type StopImportRow = {
|
||||||
city: string;
|
city: string;
|
||||||
@@ -35,12 +35,6 @@ export async function createStopsBatch(
|
|||||||
return { success: false, created: 0, error: "No brand selected" };
|
return { success: false, created: 0, error: "No brand selected" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
// Use anon key — admin_create_stops_batch is SECURITY DEFINER so RLS is
|
|
||||||
// bypassed. This fixes the prior 42501 RLS violation that came from doing
|
|
||||||
// direct REST inserts against the RLS-blocked `stops` table.
|
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
const rows = stops.map((s) => ({
|
const rows = stops.map((s) => ({
|
||||||
city: s.city,
|
city: s.city,
|
||||||
state: s.state,
|
state: s.state,
|
||||||
@@ -53,22 +47,30 @@ export async function createStopsBatch(
|
|||||||
active: false,
|
active: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stops_batch`, {
|
// `admin_create_stops_batch` is SECURITY DEFINER — bypasses RLS, so we
|
||||||
method: "POST",
|
// can call it as the app's DB role.
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
let inserted: { id?: string }[] = [];
|
||||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_stops: rows }),
|
try {
|
||||||
});
|
const { rows: rpcRows } = await pool.query<{ id?: string }>(
|
||||||
|
"SELECT * FROM admin_create_stops_batch($1, $2::jsonb)",
|
||||||
if (!res.ok) {
|
[effectiveBrandId, JSON.stringify(rows)],
|
||||||
const err = await res.json().catch(() => ({ message: "Request failed" }));
|
);
|
||||||
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
|
inserted = rpcRows;
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
created: 0,
|
||||||
|
error: err instanceof Error ? err.message : "Insert failed",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidateTag("stops", "default");
|
revalidateTag("stops", "default");
|
||||||
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
|
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
|
||||||
|
|
||||||
const inserted = await res.json();
|
return {
|
||||||
return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length };
|
success: true,
|
||||||
|
created: Array.isArray(inserted) ? inserted.length : stops.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishStop(
|
export async function publishStop(
|
||||||
@@ -79,18 +81,22 @@ export async function publishStop(
|
|||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// Direct update via raw SQL — the stops table lives in the legacy
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
// schema (city/state/date/active), so Drizzle's new-schema stops
|
||||||
|
// table doesn't have the columns we'd need to set.
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, {
|
try {
|
||||||
method: "PATCH",
|
const { rowCount } = await pool.query(
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
"UPDATE stops SET status = 'active', active = true WHERE id = $1",
|
||||||
body: JSON.stringify({ status: "active", active: true }),
|
[stopId],
|
||||||
});
|
);
|
||||||
|
if (!rowCount) {
|
||||||
if (!res.ok) {
|
return { success: false, error: "Stop not found" };
|
||||||
const err = await res.json().catch(() => ({ message: "Patch failed" }));
|
}
|
||||||
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" };
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Publish failed",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidateTag("stops", "default");
|
revalidateTag("stops", "default");
|
||||||
@@ -99,6 +105,41 @@ export async function publishStop(
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete a stop via the `delete_stop` SECURITY DEFINER RPC. Guards
|
||||||
|
* against deleting a stop that has open (non-pickup) orders.
|
||||||
|
*/
|
||||||
|
export async function deleteStop(
|
||||||
|
stopId: string,
|
||||||
|
brandId: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
let data: { success?: boolean; error?: string } = {};
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
|
||||||
|
"SELECT * FROM delete_stop($1, $2)",
|
||||||
|
[stopId, brandId],
|
||||||
|
);
|
||||||
|
data = rows[0] ?? {};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Delete failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
return { success: false, error: data.error ?? "Delete failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateTag("stops", "default");
|
||||||
|
revalidateTag(`brand:${brandId}:stops`, "default");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch active stops for sitemap generation.
|
* Fetch active stops for sitemap generation.
|
||||||
* This is a public function that doesn't require authentication.
|
* This is a public function that doesn't require authentication.
|
||||||
@@ -110,27 +151,13 @@ export type StopForSitemap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseKey) return [];
|
|
||||||
|
|
||||||
// Get all active stops with their brand slug.
|
|
||||||
// Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
|
// Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
|
||||||
// crash the prerender — the sitemap just renders without stop URLs.
|
// crash the prerender — the sitemap just renders without stop URLs.
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const { rows } = await pool.query<StopForSitemap>(
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
|
"SELECT * FROM get_active_stops_with_brand()",
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
return Array.isArray(rows) ? rows : [];
|
||||||
if (!response.ok) return [];
|
|
||||||
|
|
||||||
const stops = await response.json();
|
|
||||||
return Array.isArray(stops) ? stops : [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -139,9 +166,7 @@ export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
|||||||
/**
|
/**
|
||||||
* Fetch active stops for a brand by slug.
|
* Fetch active stops for a brand by slug.
|
||||||
* Public action used by the storefront stop pages (e.g. /tuxedo/stops,
|
* Public action used by the storefront stop pages (e.g. /tuxedo/stops,
|
||||||
* /indian-river-direct/stops) — replaces the previous client-side
|
* /indian-river-direct/stops).
|
||||||
* `supabase.from("stops")` query so supabase-js no longer ships to
|
|
||||||
* the browser for those routes.
|
|
||||||
*
|
*
|
||||||
* Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')`
|
* Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')`
|
||||||
* from any stop mutation (see createStopsBatch, publishStop, etc.).
|
* from any stop mutation (see createStopsBatch, publishStop, etc.).
|
||||||
@@ -163,33 +188,16 @@ export async function getPublicStopsForBrand(
|
|||||||
): Promise<PublicStop[]> {
|
): Promise<PublicStop[]> {
|
||||||
if (!brandSlug) return [];
|
if (!brandSlug) return [];
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
// Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
// crash the prerender — the page just renders with no stops and
|
||||||
|
// revalidates from a real request once the cache is warm.
|
||||||
if (!supabaseUrl || !supabaseKey) return [];
|
|
||||||
|
|
||||||
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
|
|
||||||
// doesn't crash the prerender — the page just renders with no stops
|
|
||||||
// and revalidates from a real request once the cache is warm.
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const { rows } = await pool.query<PublicStop>(
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
|
"SELECT * FROM get_public_stops_for_brand($1)",
|
||||||
{
|
[brandSlug],
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ p_brand_slug: brandSlug }),
|
|
||||||
next: {
|
|
||||||
revalidate: 300,
|
|
||||||
tags: ["stops", `brand:${brandSlug}:stops`],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
return Array.isArray(rows) ? rows : [];
|
||||||
if (!response.ok) return [];
|
|
||||||
|
|
||||||
const stops = await response.json();
|
|
||||||
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
import { getMockTableData } from "@/lib/mock-data";
|
import { getMockTableData } from "@/lib/mock-data";
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
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()}` };
|
return { success: true, id: `mock-stop-${Date.now()}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// `admin_create_stop` is a SECURITY DEFINER RPC that bypasses the
|
||||||
// Preferred path: anon key + SECURITY DEFINER RPC (bypasses RLS, works even if
|
// block_stops_mutations RLS policy. It returns either {success,
|
||||||
// service role key is absent at runtime). See:
|
// stop_id} or {success:false, error}. Migration 202.
|
||||||
// supabase/migrations/202_fix_admin_create_stop.sql
|
let rpcResult: { success?: boolean; error?: string; stop_id?: string; id?: string } = {};
|
||||||
// scripts/apply-admin-create-stop.js (run this locally with the keys you have)
|
try {
|
||||||
// supabase/ADMIN_CREATE_STOP_FIX.sql (exact prompt SQL for SQL editor)
|
const { rows } = await pool.query<{ success?: boolean; error?: string; stop_id?: string; id?: string }>(
|
||||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
"SELECT * FROM admin_create_stop($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||||
|
[
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, {
|
data.active ?? false,
|
||||||
method: "POST",
|
data.address || null,
|
||||||
headers: { ...svcHeaders(anonKey), "Content-Type": "application/json" },
|
brandId,
|
||||||
body: JSON.stringify({
|
data.city,
|
||||||
p_active: data.active ?? false,
|
data.cutoff_time || null,
|
||||||
p_address: data.address || null,
|
data.date,
|
||||||
p_brand_id: brandId,
|
data.location,
|
||||||
p_city: data.city,
|
data.state,
|
||||||
p_cutoff_time: data.cutoff_time || null,
|
data.time,
|
||||||
p_date: data.date,
|
data.zip || null,
|
||||||
p_location: data.location,
|
],
|
||||||
p_state: data.state,
|
);
|
||||||
p_time: data.time,
|
rpcResult = rows[0] ?? {};
|
||||||
p_zip: data.zip || null,
|
} catch (err) {
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
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.
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error: err instanceof Error ? err.message : "Failed to create stop",
|
||||||
"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.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not reach here
|
if (rpcResult.success === false) {
|
||||||
return { success: false, error: "Unexpected state creating stop" };
|
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";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
export type StopDetail = {
|
export type StopDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,39 +50,32 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
|
|||||||
return { success: false, error: "Not authorized" };
|
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";
|
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||||
|
|
||||||
// Use a fresh server-side client (cookie-less) so RLS doesn't block reads
|
if (useMockData) {
|
||||||
// for platform_admin dev sessions. The auth check above has already gated
|
return { success: false, error: "Stop not found" };
|
||||||
// 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 (!stop) {
|
// 1. Stop + brand — legacy schema, raw SQL (the Drizzle stops table
|
||||||
return { success: false, error: stopErr ?? "Stop not found" };
|
// 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
|
// Brand-scope check for brand_admin
|
||||||
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
|
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
|
// 2. Candidate products for this brand
|
||||||
const { data: allProducts } = server
|
const { rows: allProducts } = await pool.query<{ id: string; name: string; type: string; price: number }>(
|
||||||
? await server
|
`SELECT id, name, type, price
|
||||||
.from("products")
|
FROM products
|
||||||
.select("id, name, type, price")
|
WHERE brand_id = $1 AND active = true`,
|
||||||
.eq("brand_id", stop.brand_id)
|
[stop.brand_id],
|
||||||
.eq("active", true)
|
);
|
||||||
: { data: [] as { id: string; name: string; type: string; price: number }[] };
|
|
||||||
|
|
||||||
// 3. Assigned products (joined with product info)
|
// 3. Assigned products (joined with product info)
|
||||||
const { data: productStops } = server
|
const { rows: productStops } = await pool.query<AssignedProduct>(
|
||||||
? await server
|
`SELECT ps.id, ps.product_id,
|
||||||
.from("product_stops")
|
json_build_object('id', p.id, 'name', p.name, 'type', p.type, 'price', p.price) AS products
|
||||||
.select("id, product_id, products(id, name, type, price)")
|
FROM product_stops ps
|
||||||
.eq("stop_id", stopId)
|
LEFT JOIN products p ON p.id = ps.product_id
|
||||||
: { data: [] as AssignedProduct[] };
|
WHERE ps.stop_id = $1`,
|
||||||
|
[stopId],
|
||||||
|
);
|
||||||
|
|
||||||
// 4. Brands for the brand switcher
|
// 4. Brands for the brand switcher
|
||||||
const { data: brands } = server
|
const { rows: brands } = await pool.query<{ id: string; name: string; slug: string }>(
|
||||||
? await server.from("brands").select("id, name, slug")
|
`SELECT id, name, slug FROM brands ORDER BY name`,
|
||||||
: { data: [] as { id: string; name: string; slug: string }[] };
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
import { logAuditEvent } from "@/actions/audit";
|
import { logAuditEvent } from "@/actions/audit";
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||||
@@ -37,32 +37,51 @@ export async function updateStop(
|
|||||||
return { success: true };
|
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 slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`;
|
||||||
|
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, {
|
// Direct UPDATE on the legacy stops table — the new-schema Drizzle
|
||||||
method: "PATCH",
|
// stops table doesn't have the columns we need to write.
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
const { rowCount, error } = await pool
|
||||||
body: JSON.stringify({
|
.query(
|
||||||
city: data.city,
|
`UPDATE stops SET
|
||||||
state: data.state,
|
city = $1,
|
||||||
location: data.location,
|
state = $2,
|
||||||
date: data.date,
|
location = $3,
|
||||||
time: data.time,
|
date = $4,
|
||||||
slug,
|
time = $5,
|
||||||
active: data.active,
|
slug = $6,
|
||||||
brand_id: brandId,
|
active = $7,
|
||||||
address: data.address ?? null,
|
brand_id = $8,
|
||||||
zip: data.zip ?? null,
|
address = $9,
|
||||||
cutoff_time: data.cutoff_time ?? null,
|
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) {
|
if (error || !rowCount) {
|
||||||
const err = await res.text();
|
return {
|
||||||
return { success: false, error: `Failed: ${err}` };
|
success: false,
|
||||||
|
error: error ? error.message : "Stop not found",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
logAuditEvent({
|
logAuditEvent({
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { z } from "zod";
|
|||||||
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||||
import { analytics } from "@/lib/analytics";
|
import { analytics } from "@/lib/analytics";
|
||||||
import { captureError } from "@/lib/sentry";
|
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
|
// Helper functions
|
||||||
function apiResponse(data: unknown, status: number = 200) {
|
function apiResponse(data: unknown, status: number = 200) {
|
||||||
@@ -55,44 +58,47 @@ export async function GET(req: NextRequest) {
|
|||||||
return validationError(validation.error);
|
return validationError(validation.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// Build the WHERE conditions. We use `withDb` (no tenant GUC) here
|
||||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
// because this is a public API — RLS isn't enforced via the new
|
||||||
|
// schema's app.current_tenant_id GUC, so we filter explicitly.
|
||||||
// Build query
|
const whereParts = [];
|
||||||
let query = `${supabaseUrl}/rest/v1/products?select=*&order=name.asc&limit=${validation.data.limit}&offset=${validation.data.offset}`;
|
|
||||||
|
|
||||||
if (validation.data.brand_id) {
|
if (validation.data.brand_id) {
|
||||||
query += `&brand_id=eq.${validation.data.brand_id}`;
|
whereParts.push(eq(products.tenantId, validation.data.brand_id));
|
||||||
}
|
|
||||||
if (validation.data.category) {
|
|
||||||
query += `&category=ilike.*${encodeURIComponent(validation.data.category)}*`;
|
|
||||||
}
|
}
|
||||||
if (validation.data.is_active !== undefined) {
|
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) {
|
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, {
|
// Note: the legacy schema had a `category` column on products; the
|
||||||
headers: {
|
// new schema doesn't. The category filter is silently ignored — no
|
||||||
apikey: anonKey,
|
// point failing a public read just because the column disappeared.
|
||||||
"Content-Type": "application/json",
|
void validation.data.category;
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
// Public read across all tenants (this is a public catalog API, not
|
||||||
return apiError("Failed to fetch products", 500);
|
// 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
|
// Track search analytics
|
||||||
if (validation.data.search) {
|
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) {
|
} catch (error) {
|
||||||
captureError(error as Error, { path: "/api/products", method: "GET" });
|
captureError(error as Error, { path: "/api/products", method: "GET" });
|
||||||
return apiError("Internal server error", 500);
|
return apiError("Internal server error", 500);
|
||||||
@@ -108,42 +114,51 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
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) {
|
if (!brand_id || !name) {
|
||||||
return apiError("brand_id and name are required", 400);
|
return apiError("brand_id and name are required", 400);
|
||||||
}
|
}
|
||||||
|
if (typeof price !== "number") {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
return apiError("price is required (number)", 400);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const product = await res.json();
|
// Insert the product. Tenant context is required because `products`
|
||||||
return apiResponse(product, 201);
|
// 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) {
|
} catch (error) {
|
||||||
captureError(error as Error, { path: "/api/products", method: "POST" });
|
captureError(error as Error, { path: "/api/products", method: "POST" });
|
||||||
return apiError("Internal server error", 500);
|
return apiError("Internal server error", 500);
|
||||||
@@ -163,4 +178,8 @@ export async function OPTIONS() {
|
|||||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
"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;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
|
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
|
||||||
import AddStopModal from "@/components/admin/AddStopModal";
|
import AddStopModal from "@/components/admin/AddStopModal";
|
||||||
import { publishStop } from "@/actions/stops";
|
import { publishStop, deleteStop } from "@/actions/stops";
|
||||||
|
|
||||||
type Stop = {
|
type Stop = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -85,18 +85,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(stopId: string) {
|
async function handleDelete(stopId: string) {
|
||||||
const res = await fetch(
|
const data = await deleteStop(stopId, brandId);
|
||||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: brandId }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const data = await res.json();
|
|
||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
|||||||
Reference in New Issue
Block a user