916ad39176
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
68 lines
2.1 KiB
TypeScript
68 lines
2.1 KiB
TypeScript
"use server";
|
|
|
|
import { getAdminUser } from "@/lib/admin-permissions";
|
|
import { withBrand } from "@/db/client";
|
|
import { products } from "@/db/schema";
|
|
|
|
export type ImportProductsResult =
|
|
| { success: true; created: number; updated: number; errors: { product: string; error: string }[] }
|
|
| { success: false; error: string };
|
|
|
|
/**
|
|
* Bulk-import products. Replaces the legacy `bulk_upsert_products` SECURITY
|
|
* DEFINER RPC. The new `products` schema drops the legacy `type`, `is_taxable`,
|
|
* `pickup_type`, and `image_url` columns; we keep `name`, `description`,
|
|
* `price_cents`, and `active`. Without an id we always INSERT (no upsert
|
|
* key for matching — the caller can run an update path separately if
|
|
* deduplication is needed).
|
|
*/
|
|
export async function importProductsBatch(
|
|
brandId: string,
|
|
productsToImport: Array<{
|
|
name: string;
|
|
description: string;
|
|
price: number;
|
|
type: string;
|
|
active: boolean;
|
|
image_url?: string;
|
|
}>
|
|
): Promise<ImportProductsResult> {
|
|
const adminUser = await getAdminUser();
|
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
|
|
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
|
return { success: false, error: "Not authorized for this brand" };
|
|
}
|
|
|
|
let created = 0;
|
|
const errors: { product: string; error: string }[] = [];
|
|
|
|
for (const p of productsToImport) {
|
|
const priceCents = Math.round(Number(p.price) * 100);
|
|
if (!Number.isFinite(priceCents) || priceCents < 0) {
|
|
errors.push({ product: p.name, error: "Invalid price" });
|
|
continue;
|
|
}
|
|
try {
|
|
await withBrand(brandId, (db) =>
|
|
db.insert(products).values({
|
|
brandId: brandId,
|
|
name: p.name,
|
|
description: p.description ?? null,
|
|
priceCents,
|
|
active: p.active,
|
|
})
|
|
);
|
|
created++;
|
|
} catch (err) {
|
|
errors.push({
|
|
product: p.name,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { success: true, created, updated: 0, errors };
|
|
}
|