Files
route-commerce/src/actions/products/upload-image.ts
T
openclaw c434015829
Deploy to route.crispygoat.com / deploy (push) Failing after 17s
feat(storage): MinIO object storage, Neon Auth, Supabase removal
- 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
2026-06-09 11:51:32 -06:00

75 lines
2.4 KiB
TypeScript

"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { productImages } from "@/db/schema";
import { uploadObject, BUCKETS } from "@/lib/storage";
export type UploadProductImageResult =
| { success: true; imageUrl: string }
| { success: false; error: string };
export async function uploadProductImage(
productId: string,
file: File
): Promise<UploadProductImageResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
return { success: false, error: "Invalid file type. Use PNG, JPEG, or WebP." };
}
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: "File too large. Max 5MB." };
}
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
const storageKey = `products/${productId}/${crypto.randomUUID()}.${ext}`;
try {
const buffer = Buffer.from(await file.arrayBuffer());
const imageUrl = await uploadObject({
bucket: BUCKETS.PRODUCTS,
key: storageKey,
body: buffer,
contentType: file.type,
});
await withBrand(adminUser.brand_id ?? "__missing__", (db) =>
db.insert(productImages).values({
productId,
storageKey,
position: 0,
altText: null,
})
);
return { success: true, imageUrl };
} catch (err) {
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
}
}
export async function deleteProductImage(
productId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// In the new schema, "clearing" an image means removing the row(s)
// from `product_images` for this product. The legacy `image_url` PATCH
// pathway is gone (that column no longer exists on `products`).
try {
await withBrand(adminUser.brand_id ?? "__missing__", (db) =>
db.delete(productImages).where(eq(productImages.productId, productId))
);
return { success: true };
} catch (err) {
return { success: false, error: `Failed to clear image: ${err instanceof Error ? err.message : String(err)}` };
}
}
// Imported lazily to avoid a circular dep with the table ref above.
import { eq } from "drizzle-orm";