feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] || [];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user