Files
route-commerce/src/actions/checkout.ts
T
Tyler 7e665ea43e
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
Email service surfaces real Resend errors instead of silent false
The sendEmail / sendCampaignEmail / sendWelcomeEmail /
sendOrderReceiptEmail / sendPasswordResetEmail / sendOperationalAlert
helpers all returned Promise<boolean> and silently returned false on
any failure (missing API key, 401 invalid key, 422 unverified sender
domain, network error, etc.). The caller had no way to know which
problem it was.

Switch the public return type to a structured result:

  export type EmailSendResult = { ok: true } | { ok: false; error: string };

- Missing RESEND_API_KEY: error reads 'RESEND_API_KEY is not set.
  Add it to .env.local (or your hosting dashboard) and restart the
  server.'
- 4xx/5xx: error reads 'Resend 422 — validation_error: The gmail.com
  domain is not verified' (extracts Resend's own name + message).
- Network failure: error reads the thrown message verbatim.
- Non-JSON error body: falls back to 'Resend <status>'.

Callers updated:
  - src/actions/admin/users.ts (createAdminUser): now propagates the
    real emailError into the modal UI — no more generic 'sendWelcome
    Email returned false'.
  - src/actions/checkout.ts: logs the real error.
  - src/app/api/cron/send-scheduled/route.ts: per-recipient error
    logged for the scheduled-campaigns cron.

Tests:
  - tests/unit/email-service.test.ts (new, 5 tests): covers happy
    path, missing key, 4xx/5xx with JSON body, 4xx/5xx with non-JSON
    body, and network failure.
  - tests/unit/create-admin-user.test.ts: updated to use the new
    { ok, error? } return shape; new assertion that the action
    surfaces the real emailError string into the result.
2026-06-17 12:10:04 -06:00

286 lines
7.9 KiB
TypeScript

"use server";
import { pool } from "@/lib/db";
export type CartItem = {
id: string;
name: string;
price: string;
quantity: number;
fulfillment: "pickup" | "ship";
brand_id: string;
brand_slug: string;
};
type CheckoutItem = {
id: string;
name: string;
price: number;
quantity: number;
fulfillment: "pickup" | "ship";
is_taxable?: boolean;
};
type CreatedOrder = {
id: string;
customer_name: string;
customer_email: string;
customer_phone: string;
subtotal: number;
status: string;
stop_id: string | null;
brand_id: string;
stop_city: string;
stop_state: string;
stop_date: string;
stop_time: string | null;
stop_location: string;
items: {
product_id: string;
product_name: string;
quantity: number;
price: number;
fulfillment: string;
}[];
};
type CheckoutResult =
| { success: true; order: CreatedOrder }
| { success: false; error: string };
type ShippingAddress = {
state: string;
postal_code: string;
city?: string;
};
export async function createOrder(
idempotencyKey: string,
customerName: string,
customerEmail: string,
customerPhone: string,
stopId: string | null,
items: CheckoutItem[],
brandId?: string,
shippingAddress?: ShippingAddress
): Promise<CheckoutResult> {
// ── Calculate tax if brand collects tax ─────────────────────────────────
let taxAmount = 0;
let taxRate = 0;
let taxLocation = "";
if (brandId && shippingAddress) {
try {
const { calculateOrderTax } = await import("@/actions/tax");
const taxResult = await calculateOrderTax({
brandId,
subtotal: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
items: items.map((i) => ({ id: i.id, quantity: i.quantity, price: i.price, is_taxable: i.is_taxable })),
fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup",
shippingAddress,
});
taxAmount = taxResult.taxAmount;
taxRate = taxResult.taxRate;
taxLocation = taxResult.taxLocation;
} catch {
// Tax calculation failure should not block checkout
}
}
const { rows } = await pool.query<{ create_order_with_items: CreatedOrder | null }>(
`SELECT create_order_with_items($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9) AS "create_order_with_items"`,
[
idempotencyKey,
customerName,
customerEmail,
customerPhone,
stopId,
JSON.stringify(items),
taxAmount,
taxRate,
taxLocation || null,
],
);
const data = rows[0]?.create_order_with_items;
if (!data || !data.id) {
return { success: false, error: "Order created but data not returned" };
}
// Send order receipt email
try {
const { sendOrderReceiptEmail } = await import("@/lib/email-service");
const { getBrandSettingsPublic } = await import("@/actions/brand-settings");
const rawItems = data.items ?? [];
const taxAmount = (data as { tax_amount?: number }).tax_amount ?? 0;
// Look up brand settings to get the logo URL
let logoUrl: string | null = null;
if (brandId) {
// Resolve brand slug from brand ID, then fetch settings
const { rows: brandRows } = await pool.query<{ slug: string }>(
"SELECT slug FROM brands WHERE id = $1",
[brandId]
);
if (brandRows[0]) {
const settings = await getBrandSettingsPublic(brandRows[0].slug);
logoUrl = settings.success ? (settings.settings?.logo_url ?? null) : null;
}
}
const result = await sendOrderReceiptEmail({
customerName,
customerEmail,
orderId: data.id,
items: rawItems.map((i) => ({
name: i.product_name,
quantity: i.quantity,
price: i.price,
})),
subtotal: data.subtotal ?? 0,
taxAmount,
total: (data.subtotal ?? 0) + taxAmount,
fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup",
stopCity: data.stop_city ?? undefined,
stopState: data.stop_state ?? undefined,
stopDate: data.stop_date ?? undefined,
stopTime: data.stop_time ?? undefined,
stopLocation: data.stop_location ?? undefined,
brandName: "Tuxedo Corn",
logoUrl,
});
if (!result.ok) {
console.error("[checkout] Order receipt email failed:", result.error);
}
} catch {
// Email failure should not fail the order
}
return { success: true, order: data };
}
// ── Cart Persistence ──────────────────────────────────────────────────────────
export async function getServerCart(userId: string): Promise<CartItem[]> {
try {
const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
`SELECT get_user_cart($1) AS "get_user_cart"`,
[userId],
);
const data = rows[0]?.get_user_cart;
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
export async function mergeLocalCart(
localCart: CartItem[],
userId: string
): Promise<{ merged: CartItem[] }> {
if (!localCart || localCart.length === 0) return { merged: [] };
// Fetch server cart
let serverCart: CartItem[] = [];
try {
const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
`SELECT get_user_cart($1) AS "get_user_cart"`,
[userId],
);
const data = rows[0]?.get_user_cart;
serverCart = Array.isArray(data) ? data : [];
} catch {
// proceed with local cart only
}
// Merge: server items take precedence for fulfillment conflicts;
// quantities are summed for same product IDs.
const mergedMap = new Map<string, CartItem>();
// Add server items first
for (const item of serverCart) {
mergedMap.set(item.id, { ...item });
}
// Overlay local items — sum quantities, prefer server fulfillment
for (const local of localCart) {
const existing = mergedMap.get(local.id);
if (existing) {
mergedMap.set(local.id, {
...existing,
quantity: existing.quantity + local.quantity,
// Server fulfillment wins if set
fulfillment: existing.fulfillment ?? local.fulfillment,
});
} else {
mergedMap.set(local.id, local);
}
}
const merged = Array.from(mergedMap.values());
// Persist merged cart to server
try {
await pool.query(
`SELECT upsert_user_cart($1, $2::jsonb)`,
[userId, JSON.stringify(merged)],
);
} catch {
// best-effort — localStorage is still source of truth for now
}
return { merged };
}
export async function clearServerCart(userId: string): Promise<void> {
try {
await pool.query(
`SELECT clear_user_cart($1)`,
[userId],
);
} catch {
// ignore
}
}
// ── Stop picker (used by cart + checkout client components) ───────────────
export type PublicStop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
export async function getPublicStopsForBrand(brandId: string): Promise<PublicStop[]> {
const { rows } = await pool.query<PublicStop>(
`SELECT id, city, state, date, time, location, brand_id
FROM stops
WHERE active = true AND brand_id = $1
ORDER BY date`,
[brandId],
);
return rows;
}
export type ProductAvailability = {
product_id: string;
is_available: boolean;
};
export async function checkStopProductAvailability(
stopId: string,
productIds: string[]
): Promise<ProductAvailability[]> {
if (!productIds || productIds.length === 0) return [];
const { rows } = await pool.query<{ check_stop_product_availability: ProductAvailability[] | null }>(
`SELECT check_stop_product_availability($1, $2::uuid[]) AS "check_stop_product_availability"`,
[stopId, productIds],
);
const data = rows[0]?.check_stop_product_availability;
return Array.isArray(data) ? data : [];
}