c86e97e816
Deploy to route.crispygoat.com / deploy (push) Failing after 7s
- 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
125 lines
3.7 KiB
TypeScript
125 lines
3.7 KiB
TypeScript
"use server";
|
|
|
|
import { getAdminUser } from "@/lib/admin-permissions";
|
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
|
import { pool } from "@/lib/db";
|
|
import { randomUUID } from "crypto";
|
|
|
|
export type AdminCreateOrderItem = {
|
|
product_id: string;
|
|
quantity: number;
|
|
price: number;
|
|
fulfillment?: "pickup" | "ship";
|
|
};
|
|
|
|
export type AdminCreateOrderInput = {
|
|
customer_name: string;
|
|
customer_email?: string | null;
|
|
customer_phone?: string | null;
|
|
stop_id?: string | null; // null for shipping-only / manual
|
|
items: AdminCreateOrderItem[];
|
|
internal_notes?: string | null;
|
|
// Optional overrides; if omitted we can compute simple or let RPC default
|
|
tax_amount?: number;
|
|
discount_amount?: number;
|
|
discount_reason?: string | null;
|
|
};
|
|
|
|
// Type for the created order from the RPC
|
|
export type CreatedOrder = {
|
|
id: string;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type AdminCreateOrderResult =
|
|
| { success: true; orderId: string; order: CreatedOrder }
|
|
| { success: false; error: string };
|
|
|
|
export async function createAdminOrder(
|
|
brandId: string | null,
|
|
input: AdminCreateOrderInput
|
|
): Promise<AdminCreateOrderResult> {
|
|
const adminUser = await getAdminUser();
|
|
if (!adminUser) {
|
|
return { success: false, error: "Not authenticated" };
|
|
}
|
|
if (!adminUser.can_manage_orders) {
|
|
return { success: false, error: "Not authorized to create orders" };
|
|
}
|
|
|
|
// Brand scoping
|
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
return { success: false, error: "Brand access required" };
|
|
}
|
|
const effectiveBrandId = activeBrandId;
|
|
if (adminUser.role === "brand_admin" && adminUser.brand_id && effectiveBrandId !== adminUser.brand_id) {
|
|
return { success: false, error: "Not authorized for this brand" };
|
|
}
|
|
|
|
if (!input.customer_name?.trim()) {
|
|
return { success: false, error: "Customer name is required" };
|
|
}
|
|
if (!input.items || input.items.length === 0) {
|
|
return { success: false, error: "At least one item is required" };
|
|
}
|
|
|
|
// Build items for RPC (match checkout shape)
|
|
const rpcItems = input.items.map((i) => ({
|
|
product_id: i.product_id,
|
|
quantity: i.quantity,
|
|
price: i.price,
|
|
fulfillment: i.fulfillment ?? "pickup",
|
|
}));
|
|
|
|
const idempotencyKey = randomUUID();
|
|
|
|
// For admin manual orders we pass minimal tax info (0 or provided). Full tax calc can be added later.
|
|
const taxAmount = input.tax_amount ?? 0;
|
|
const taxRate = 0;
|
|
const taxLocation = null;
|
|
|
|
try {
|
|
const { rows } = await pool.query<{ id: string }>(
|
|
`SELECT * FROM create_order_with_items(
|
|
$1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9
|
|
)`,
|
|
[
|
|
idempotencyKey,
|
|
input.customer_name.trim(),
|
|
input.customer_email?.trim() || null,
|
|
input.customer_phone?.trim() || null,
|
|
input.stop_id || null,
|
|
JSON.stringify(rpcItems),
|
|
taxAmount,
|
|
taxRate,
|
|
taxLocation,
|
|
],
|
|
);
|
|
const data = rows[0];
|
|
if (!data || !data.id) {
|
|
return { success: false, error: "Order created but no ID returned" };
|
|
}
|
|
|
|
// Optionally attach internal notes via a follow-up update if the RPC doesn't support it directly.
|
|
if (input.internal_notes?.trim()) {
|
|
// Best-effort; don't fail the whole create if this secondary update fails.
|
|
try {
|
|
await pool.query(
|
|
"UPDATE orders SET internal_notes = $1 WHERE id = $2",
|
|
[input.internal_notes.trim(), data.id],
|
|
);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
return { success: true, orderId: data.id, order: data };
|
|
} catch (err) {
|
|
return {
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "Unexpected error creating order",
|
|
};
|
|
}
|
|
}
|