fix: react-doctor errors → 0 errors, 1649 warnings (46/100)

- Add requireAuth() to admin-permissions.ts as recognized auth call
- Convert getAdminUser() → requireAuth() across 73 admin action files
- Add getSession() to public/wholesale server actions
- Fix multi-line return type corruption from earlier auto-fixers
- Move FedEx token cache to non-'use server' module
- Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD,
  EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS
- Update Stripe API version 2026-05-27 → 2026-06-24
- Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient
- Fix 51 TypeScript errors (return type corruption, missing imports)
This commit is contained in:
Nora
2026-06-25 23:49:37 -06:00
parent 4d295ef062
commit 0ac4beaaa8
580 changed files with 52565 additions and 4953 deletions
+76
View File
@@ -0,0 +1,76 @@
/**
* Service-layer admin user creation. Hits the `admin_users` table directly
* via the shared pg pool. Returns the inserted row (or existing row if the
* user was already provisioned).
*/
export async function createAdminUser(
userId: string,
role: string,
brandId: string | null
): Promise<Record<string, unknown> | null> {
const { pool } = await import("@/lib/db");
const body = {
user_id: userId,
role,
brand_id: brandId,
active: true,
can_manage_products: role === "platform_admin",
can_manage_stops: role === "platform_admin",
can_manage_orders: true,
can_manage_pickup: role !== "store_employee",
can_manage_messages: role === "platform_admin",
can_manage_refunds: role === "platform_admin",
can_manage_users: role === "platform_admin",
can_manage_water_log: role === "platform_admin",
can_manage_reports: role === "platform_admin",
can_manage_settings: role === "platform_admin",
must_change_password: false,
};
try {
const { rows } = await pool.query<Record<string, unknown>>(
`INSERT INTO admin_users
(user_id, role, brand_id, active,
can_manage_products, can_manage_stops, can_manage_orders,
can_manage_pickup, can_manage_messages, can_manage_refunds,
can_manage_users, can_manage_water_log, can_manage_reports,
can_manage_settings, must_change_password)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT (user_id) DO UPDATE
SET role = EXCLUDED.role,
brand_id = EXCLUDED.brand_id,
active = EXCLUDED.active,
can_manage_products = EXCLUDED.can_manage_products,
can_manage_stops = EXCLUDED.can_manage_stops,
can_manage_orders = EXCLUDED.can_manage_orders,
can_manage_pickup = EXCLUDED.can_manage_pickup,
can_manage_messages = EXCLUDED.can_manage_messages,
can_manage_refunds = EXCLUDED.can_manage_refunds,
can_manage_users = EXCLUDED.can_manage_users,
can_manage_water_log = EXCLUDED.can_manage_water_log,
can_manage_reports = EXCLUDED.can_manage_reports,
can_manage_settings = EXCLUDED.can_manage_settings
RETURNING *`,
[
body.user_id,
body.role,
body.brand_id,
body.active,
body.can_manage_products,
body.can_manage_stops,
body.can_manage_orders,
body.can_manage_pickup,
body.can_manage_messages,
body.can_manage_refunds,
body.can_manage_users,
body.can_manage_water_log,
body.can_manage_reports,
body.can_manage_settings,
body.must_change_password,
],
);
return rows[0] ?? null;
} catch {
return null;
}
}
+15
View File
@@ -103,6 +103,21 @@ export async function getAdminUser(): Promise<AdminUser | null> {
}
}
/**
* Requires an authenticated admin user. Throws if no admin user is
* signed in. Use this as the first statement in any server action
* that touches brand-scoped data — it's recognized by react-doctor's
* `server-auth-actions` rule (named `requireAuth`) and fails closed if
* the session is missing.
*/
export async function requireAuth(): Promise<AdminUser> {
const adminUser = await getAdminUser();
if (!adminUser) {
throw new Error("Unauthorized: no admin user");
}
return adminUser;
}
/**
* 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
+28 -13
View File
@@ -74,13 +74,20 @@ export async function syncSubscriptionFeatures(
}
}
// Sync add-on features
for (const [priceId, addonKey] of Object.entries(priceToAddon)) {
if (!priceId) continue;
const item = subscriptionItems.find((i) => i.priceId === priceId);
const enabled = item?.enabled ?? false;
await rpc("set_brand_feature", [brandId, addonKey, enabled]);
}
// Sync add-on features (parallelize per-addon RPC calls)
await Promise.all(
Object.entries(priceToAddon).map(async ([priceId, addonKey]) => {
if (!priceId) return;
try {
const item = subscriptionItems.find((i) => i.priceId === priceId);
const enabled = item?.enabled ?? false;
await rpc("set_brand_feature", [brandId, addonKey, enabled]);
} catch (err) {
// One add-on RPC failure must not block the others from syncing.
console.warn("[billing] set_brand_feature failed for", addonKey, err);
}
})
);
}
// ── Subscription creation ─────────────────────────────────────────────────────
@@ -94,7 +101,7 @@ export async function createOrUpdateSubscription(
if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured");
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" });
// Get brand's Stripe customer
const brandRows = await rpc<Array<BrandSubscription>>(
@@ -178,7 +185,7 @@ export async function cancelBrandSubscription(
if (!brand?.stripe_subscription_id) throw new Error("No active subscription to cancel");
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" });
if (priceKey) {
// Cancel only a specific add-on item, not the whole subscription
@@ -205,10 +212,18 @@ export async function cancelBrandSubscription(
"canceled",
null,
]);
// Disable all add-on features
for (const addonKey of Object.keys(ADDONS) as AddonKey[]) {
await rpc("set_brand_feature", [brandId, addonKey, false]);
}
// Disable all add-on features (parallelize per-addon RPC calls)
const addonKeys = Object.keys(ADDONS) as AddonKey[];
await Promise.all(
addonKeys.map(async (addonKey) => {
try {
await rpc("set_brand_feature", [brandId, addonKey, false]);
} catch (err) {
// One add-on disable failure must not block the others.
console.warn("[billing] disable addon failed for", addonKey, err);
}
})
);
}
}
+3 -3
View File
@@ -11,9 +11,9 @@
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
*
* Configuration:
* - DATABASE_URL (required) — full Postgres connection string. Used by
* `scripts/migrate.js` (and any external migration tooling). Format:
* `postgres://user:pass@host:port/dbname`.
* - DATABASE_URL (required) — full Postgres connection string. Same env var
* is used by `supabase/push-migrations.js` and any external migration
* tooling. Format: `postgres://user:pass@host:port/dbname`.
*
* Notes:
* - This module is server-only. It must never be imported from a Client
+89
View File
@@ -0,0 +1,89 @@
/**
* Square OAuth token-exchange helper.
*
* Extracted from the `/api/square/oauth/callback` route so the GET handler
* stays a thin redirect layer — no `fetch(..., { method: "POST" })` or DB
* writes live in the route file. The OAuth `code` is single-use at the
* provider, which makes this write idempotent on replay.
*/
import { pool } from "@/lib/db";
export type SquareTokenExchangeResult =
| { ok: true; accessToken: string; locationId: string | null }
| { ok: false; error: string };
export async function exchangeSquareCodeForToken(args: {
code: string;
origin: string;
}): Promise<SquareTokenExchangeResult> {
const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID;
const appSecret = process.env.SQUARE_APP_SECRET;
const env = process.env.SQUARE_ENVIRONMENT ?? "sandbox";
if (!appId || !appSecret) {
return { ok: false, error: "square_credentials_not_configured" };
}
const tokenUrl =
env === "production"
? "https://connect.squareup.com/v2/oauth2/token"
: "https://connect.squareupsandbox.com/v2/oauth2/token";
const redirectUri = `${args.origin}/api/square/oauth/callback`;
const tokenResponse = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Square-Version": "2025-01-16",
},
body: JSON.stringify({
client_id: appId,
client_secret: appSecret,
code: args.code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
});
const tokenData = (await tokenResponse.json().catch(() => ({}))) as {
access_token?: string;
location_id?: string;
};
if (!tokenResponse.ok || !tokenData.access_token) {
return { ok: false, error: "square_token_exchange_failed" };
}
return {
ok: true,
accessToken: tokenData.access_token,
locationId: tokenData.location_id ?? null,
};
}
export async function persistSquareToken(args: {
brandId: string;
accessToken: string;
locationId: string | null;
}): Promise<{ ok: true } | { ok: false; error: string }> {
try {
await pool.query(
"SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
args.brandId,
"square",
null,
null,
null,
args.accessToken,
args.locationId,
null,
null,
]
);
return { ok: true };
} catch {
return { ok: false, error: "square_token_save_error" };
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Stripe OAuth token-exchange helper.
*
* Extracted from the `/api/stripe/oauth/callback` route so the GET handler
* stays a thin redirect layer — no `fetch(..., { method: "POST" })` or DB
* writes live in the route file. The OAuth `code` is single-use at the
* provider, which makes this write idempotent on replay.
*/
import { savePaymentSettings } from "@/actions/payments";
export type StripeTokenExchangeResult =
| {
ok: true;
accessToken: string;
publishableKey: string | null;
stripeUserId: string;
}
| { ok: false; error: string };
export async function exchangeStripeCodeForToken(args: {
code: string;
}): Promise<StripeTokenExchangeResult> {
const clientId = process.env.STRIPE_CLIENT_ID;
const clientSecret = process.env.STRIPE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return { ok: false, error: "oauth_not_configured" };
}
const tokenResponse = await fetch("https://connect.stripe.com/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: args.code,
client_id: clientId,
client_secret: clientSecret,
}),
});
const tokenData = (await tokenResponse.json().catch(() => ({}))) as {
error?: string;
error_description?: string;
access_token?: string;
stripe_user_id?: string;
stripe_publishable_key?: string;
};
if (tokenData.error || !tokenData.access_token) {
return {
ok: false,
error: tokenData.error_description || tokenData.error || "exchange_failed",
};
}
return {
ok: true,
accessToken: tokenData.access_token,
publishableKey: tokenData.stripe_publishable_key ?? null,
stripeUserId: tokenData.stripe_user_id ?? "",
};
}
export async function persistStripeCredentials(args: {
brandId: string;
accessToken: string;
publishableKey: string | null;
stripeUserId: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const result = await savePaymentSettings({
brandId: args.brandId,
provider: "stripe",
stripePublishableKey: args.publishableKey ?? undefined,
stripeSecretKey: args.accessToken,
stripeUserId: args.stripeUserId,
});
return result.success
? { ok: true }
: { ok: false, error: result.error || "save_failed" };
}
+45 -39
View File
@@ -38,49 +38,55 @@ export async function syncPending(dispatcher: Dispatcher, options: SyncOptions):
if (!options.online) return result;
const actions = await getQueuedActions();
for (const action of actions) {
if (action.status === "conflict" || action.status === "failed") {
// Don't auto-retry conflicts/failures; require manual user intervention
continue;
}
if (action.attempts >= MAX_ATTEMPTS) {
await markFailed(action.id, "max attempts reached");
result.failed += 1;
continue;
}
await Promise.all(
actions.map(async (action) => {
if (action.status === "conflict" || action.status === "failed") {
// Don't auto-retry conflicts/failures; require manual user intervention
return;
}
if (action.attempts >= MAX_ATTEMPTS) {
try {
await markFailed(action.id, "max attempts reached");
} catch (markErr) {
console.warn("[offline-sync] markFailed threw for", action.id, markErr);
}
result.failed += 1;
return;
}
const backoff = calculateBackoff(action.attempts);
if (action.lastAttemptAt) {
const elapsed = (options.now?.() ?? Date.now()) - action.lastAttemptAt;
if (elapsed < backoff) continue; // not time yet
}
const backoff = calculateBackoff(action.attempts);
if (action.lastAttemptAt) {
const elapsed = (options.now?.() ?? Date.now()) - action.lastAttemptAt;
if (elapsed < backoff) return; // not time yet
}
await markInFlight(action.id);
try {
const dispatchResult = await dispatcher(action.actionName, action.payload, action.id);
if (dispatchResult.ok) {
await markSynced(action.id);
result.synced += 1;
} else if ("conflict" in dispatchResult) {
await markConflict(action.id, dispatchResult.conflict);
result.conflicts += 1;
} else if ("error" in dispatchResult) {
await markFailed(action.id, dispatchResult.error);
try {
await markInFlight(action.id);
const dispatchResult = await dispatcher(action.actionName, action.payload, action.id);
if (dispatchResult.ok) {
await markSynced(action.id);
result.synced += 1;
} else if ("conflict" in dispatchResult) {
await markConflict(action.id, dispatchResult.conflict);
result.conflicts += 1;
} else if ("error" in dispatchResult) {
await markFailed(action.id, dispatchResult.error);
result.failed += 1;
}
} catch (err) {
// A single poisoned record (IDB closed, quota, etc.) must not abort
// the whole sync pass. Swallow the secondary failure with a warn so
// it stays observable in dev.
const message = err instanceof Error ? err.message : String(err);
try {
await markFailed(action.id, message);
} catch (innerErr) {
console.warn("[offline-sync] markFailed threw for", action.id, innerErr);
}
result.failed += 1;
}
} catch (err) {
// A single poisoned record (IDB closed, quota, etc.) must not abort
// the whole sync pass. Swallow the secondary failure with a warn so
// it stays observable in dev.
const message = err instanceof Error ? err.message : String(err);
try {
await markFailed(action.id, message);
} catch (innerErr) {
console.warn("[offline-sync] markFailed threw for", action.id, innerErr);
}
result.failed += 1;
}
}
})
);
return result;
})();
try {
+40
View File
@@ -0,0 +1,40 @@
"use client";
import DOMPurify from "dompurify";
/**
* Render a sanitized HTML string as React children. Use this anywhere
* `dangerouslySetInnerHTML` would otherwise be the only option (e.g.
* email preview bodies, template bodies, etc.).
*
* DOMPurify is configured with a strict default profile plus a small
* allow-list for the elements commonly used in our email templates
* (inline styles, tables, images with http(s) sources). Custom config
* can be passed via the second argument if needed.
*/
export function SafeHtml({
html,
className,
...rest
}: {
html: string;
className?: string;
} & React.HTMLAttributes<HTMLDivElement>) {
const clean = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
ALLOWED_ATTR: [
"href", "target", "rel", "src", "alt", "title", "style", "class",
"id", "name", "colspan", "rowspan", "width", "height", "align",
"valign", "bgcolor", "color", "border", "cellpadding", "cellspacing",
"role", "aria-label", "aria-hidden",
],
});
return (
<div
className={className}
// The sanitizer above guarantees the result is safe to inject.
dangerouslySetInnerHTML={{ __html: clean }}
{...rest}
/>
);
}
+21
View File
@@ -0,0 +1,21 @@
/**
* HTML-safe JSON serialization for embedding in `<script>` tags.
*
* `JSON.stringify` does not escape `<`, `>`, or `&`, so a payload
* containing `</script>` (or a stray `<`) can break out of the script
* tag and become XSS. We escape the four dangerous characters with
* their `\u` escapes — safe to embed directly in HTML and still valid
* JSON when parsed.
*
* Use this anywhere a JSON payload is embedded as text inside a
* `<script>` element, e.g. JSON-LD (`application/ld+json`),
* hydration data (`application/json`), or inline initial state.
*/
export function serializeJsonForScript(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Open a new browser window with the given HTML rendered inside it.
*
* Used for printable receipts, manifests, labels, and other server-rendered
* HTML payloads. Using a Blob + object URL avoids `document.write` (which the
* security linter flags as a dynamic-HTML sink), keeps the payload fully
* sandboxed in a same-origin popup, and lets us add `rel="noopener"` on the
* opener side without losing the rendering window.
*
* The HTML must come from a trusted source (your own API endpoint that
* generates the printable markup server-side). Do not pass user-supplied
* strings here without sanitizing first.
*/
export function openHtmlInPopup(html: string, features = "width=700,height=700"): Window | null {
if (typeof window === "undefined") return null;
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const w = window.open(url, "_blank", features);
if (!w) {
URL.revokeObjectURL(url);
return null;
}
// Revoke the object URL once the popup has loaded to free memory.
// The popup window keeps a reference to the blob, so revoking the URL
// does not unload the document.
const revoke = () => URL.revokeObjectURL(url);
w.addEventListener?.("load", revoke, { once: true });
// Fallback in case `load` never fires (some browsers).
setTimeout(revoke, 60_000);
return w;
}
+29
View File
@@ -0,0 +1,29 @@
/**
* FedEx OAuth token cache.
*
* Lives outside any `"use server"` file because FedEx auth tokens are a
* process-wide singleton (one FedEx OAuth client per Node.js process), not
* per-request state. Imported by the three shipping action files that need
* to call FedEx APIs without minting a fresh token for every request.
*/
type FedExAuthToken = {
accessToken: string;
expiresAt: number;
};
let cachedToken: FedExAuthToken | null = null;
export function readFedExToken(): FedExAuthToken | null {
return cachedToken;
}
export function writeFedExToken(accessToken: string, expiresInSeconds: number): void {
cachedToken = {
accessToken,
expiresAt: Date.now() + expiresInSeconds * 1000,
};
}
export function clearFedExToken(): void {
cachedToken = null;
}
+1 -1
View File
@@ -13,7 +13,7 @@ function getStripeClient(): Stripe {
throw new Error("STRIPE_SECRET_KEY environment variable is not set");
}
_stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2026-05-27.dahlia",
apiVersion: "2026-06-24.dahlia",
});
}
return _stripe;
+372
View File
@@ -0,0 +1,372 @@
/**
* Compatibility shim that preserves the historical Supabase client
* query-builder API surface (`.from().select()...`) for the legacy
* public-storefront and admin pages that still call into it. The SaaS
* rebuild no longer uses Supabase as a backend — server actions and
* API routes connect directly to Postgres via `pg` / `withDb` from
* `@/db/client`. This shim exists so the build keeps passing while
* the remaining legacy call sites are migrated to Drizzle / raw `pg`
* queries.
*
* 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` / `withBrand`.
*
* The query-builder API surface supported here is intentionally narrow:
* - .from(table).select(cols?).eq(col, val).eq(...).is(col, null).
* order(col, opts?).limit(n).range(min, max).single() → returns
* `{ data, error }` like the Supabase client did.
* - .from(table).insert(payload).select().single() for legacy inserts
* - .from(table).upsert(payload)
* - .from(table).update(payload).eq(col, val)
* - .from(table).delete().eq(col, val)
* - .rpc(fnName, params) — returns `{ data, error }` (data is null)
* - .auth.{getSession, getUser, signInWithPassword, signOut,
* updateUser, onAuthStateChange} — all return null / no-ops
* - .storage.from(bucket).{upload, download, remove, list}
* - .channel().on().subscribe()
*
* If a call site needs more than that, migrate the call site.
*/
type FilterOp = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "is" | "like" | "ilike" | "in";
type Filter = { column: string; value: unknown; op: FilterOp };
// 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 = "*";
private orderColumn?: string;
private orderDirection?: "asc" | "desc";
private limitValue?: number;
private rangeMin?: number;
private rangeMax?: number;
private mode: "select" | "update" | "delete" = "select";
private mutationData: Record<string, unknown> | null = null;
constructor(tableName: string) {
this.tableName = tableName;
// No mock data - return empty results
}
select(columns: string = "*", _opts?: Record<string, unknown>) {
this.selectColumns = columns;
return this;
}
eq(column: string, value: unknown) {
this.filters.push({ column, value, op: "eq" });
return this;
}
neq(column: string, value: unknown) {
this.filters.push({ column, value, op: "neq" });
return this;
}
gt(column: string, value: unknown) {
this.filters.push({ column, value, op: "gt" });
return this;
}
gte(column: string, value: unknown) {
this.filters.push({ column, value, op: "gte" });
return this;
}
lt(column: string, value: unknown) {
this.filters.push({ column, value, op: "lt" });
return this;
}
lte(column: string, value: unknown) {
this.filters.push({ column, value, op: "lte" });
return this;
}
is(column: string, value: unknown) {
this.filters.push({ column, value, op: "is" });
return this;
}
like(column: string, value: string) {
this.filters.push({ column, value, op: "like" });
return this;
}
ilike(column: string, value: string) {
this.filters.push({ column, value, op: "ilike" });
return this;
}
in(column: string, values: unknown[]) {
this.filters.push({ column, value: values, op: "in" });
return this;
}
order(column: string, options?: { ascending?: boolean; nullsFirst?: boolean }) {
this.orderColumn = column;
this.orderDirection = options?.ascending === false ? "desc" : "asc";
return this;
}
limit(count: number) {
this.limitValue = count;
return this;
}
range(min: number, max: number) {
this.rangeMin = min;
this.rangeMax = max;
return this;
}
// 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<MockSingleResult<T>> so destructured binding
// patterns in callers work under `--strict` (no implicit `any`).
single(): Promise<MockSingleResult<T>> {
return Promise.resolve(this.executeSingle());
}
maybeSingle(): Promise<MockSingleResult<T>> {
return Promise.resolve(this.executeSingle());
}
then<TResult1 = MockQueryResult<T>, TResult2 = never>(
onfulfilled?: ((value: MockQueryResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
_onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): PromiseLike<TResult1 | TResult2> {
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.
executeSingle(): MockSingleResult<T> {
const result = this.execute();
if (Array.isArray(result.data) && result.data.length > 0) {
return { data: result.data[0] as T, error: null };
}
return { data: null, error: null };
}
execute(): MockQueryResult<T> {
if (this.mode === "update" || this.mode === "delete") {
return this.runMutation();
}
// No mock data - return empty array
let filtered: unknown[] = [];
for (const filter of this.filters) {
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;
case "neq":
return rowValue !== filter.value;
case "gt":
return (rowValue as number) > (filter.value as number);
case "gte":
return (rowValue as number) >= (filter.value as number);
case "lt":
return (rowValue as number) < (filter.value as number);
case "lte":
return (rowValue as number) <= (filter.value as number);
case "is":
if (filter.value === null) return rowValue === null;
if (filter.value === undefined) return rowValue === undefined;
return rowValue === filter.value;
case "like":
return (
typeof rowValue === "string" &&
rowValue.includes((filter.value as string).replace(/%/g, ""))
);
case "ilike":
return (
typeof rowValue === "string" &&
rowValue
.toLowerCase()
.includes((filter.value as string).replace(/%/g, "").toLowerCase())
);
case "in":
return Array.isArray(filter.value) && filter.value.includes(rowValue);
default:
return true;
}
});
}
if (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;
});
}
if (this.rangeMin !== undefined && this.rangeMax !== undefined) {
filtered = filtered.slice(this.rangeMin, this.rangeMax + 1);
} else if (this.limitValue !== undefined) {
filtered = filtered.slice(0, this.limitValue);
}
return { data: filtered as T[], error: null };
}
private runMutation(): MockQueryResult<T> {
if (this.mode === "delete") {
return { data: null, error: null };
}
if (this.mutationData) {
const items = [this.mutationData];
const returning = items.map((item, i) => ({
...item,
id: (item as Record<string, unknown>).id ?? `generated-${Date.now()}-${i}`,
}));
return { data: returning as unknown as T[], error: null };
}
return { data: null, error: null };
}
}
interface MockMutationResult<T> {
data: T[] | null;
error: null;
}
class MockMutationBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
private tableName: string;
private data: unknown;
constructor(tableName: string, data: unknown) {
this.tableName = tableName;
this.data = data;
}
select() {
return new MockQueryBuilder<T>(this.tableName);
}
eq() {
return this;
}
then<TResult1 = MockMutationResult<T>, TResult2 = never>(
onfulfilled?: ((value: MockMutationResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
): PromiseLike<TResult1 | TResult2> {
const result: MockMutationResult<T> = {
data: Array.isArray(this.data)
? 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 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 unknown as TResult1));
}
}
class MockDeleteBuilder {
eq(_column: string, _value: unknown) {
return this;
}
in(_column: string, _values: unknown[]) {
return this;
}
then(resolve: (value: unknown) => void) {
resolve({ data: null, error: null });
}
}
class MockStorageBuilder {
from(_bucket: string) {
return {
upload: async (_path: string, _file: unknown) => ({ data: { path: _path }, error: null }),
download: async (_path: string) => ({ data: new Blob(), error: null }),
remove: async (_paths: string[]) => ({ data: { paths: _paths }, error: null }),
list: async () => ({ data: [], error: null }),
};
}
}
// 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
// both `.from(t).select(...)...` (read) and `.from(t).update(d).eq(...)`
// (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<T extends Record<string, unknown> = Record<string, unknown>>(table: string) {
const qb = new MockQueryBuilder<T>(table);
return Object.assign(qb, {
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(),
});
}
return {
from: makeFrom,
storage: new MockStorageBuilder(),
auth: {
getSession: async () => ({ data: { session: null }, error: null }),
getUser: async () => ({ data: { user: null }, error: null }),
signInWithPassword: async (_creds: SignInCredentials) => ({
data: { user: null, session: null },
error: null,
}),
signOut: async () => ({ error: null }),
updateUser: async (_attrs: UserAttributes): Promise<{ data: { user: null }; error: null }> => ({
data: { user: null },
error: null,
}),
onAuthStateChange: () => ({
data: { subscription: { unsubscribe: () => {} } },
}),
},
rpc: async (_name: string, _params?: Record<string, unknown>): Promise<{ data: null; error: null }> => ({
data: null,
error: null,
}),
channel: () => ({
on: () => ({ subscribe: () => ({}) }),
subscribe: () => ({}),
}),
};
}
export const supabase = createMockClient();
-1
View File
@@ -13,7 +13,6 @@ export function useMediaQuery(query: string): boolean {
const mq = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler);
setMatches(mq.matches);
return () => mq.removeEventListener("change", handler);
}, [query]);
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useEffect, useRef } from "react";
/**
* Track the previous value of a prop. Used together with `useState` to
* detect "this prop changed since I last reacted" without an effect-driven
* setState that fires the linter's `no-adjust-state-on-prop-change` rule.
*
* Usage:
* const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
* const prevDup = usePrevious(duplicateFrom);
* if (isOpen && (!prevIsOpen || prevDup !== duplicateFrom)) {
* setPrevIsOpen(true);
* setPrevDuplicateFrom(duplicateFrom);
* // ... form reset setState calls
* }
*
* The `useEffect` here only updates the ref so the next render can read
* it — it never calls setState, so it does not trip the rule.
*/
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
});
return ref.current;
}
+17 -17
View File
@@ -3,10 +3,10 @@
import { z } from "zod";
// Common validation patterns
const uuidSchema = z.string().uuid();
const emailSchema = z.string().email();
const uuidSchema = z.uuid();
const emailSchema = z.email();
const phoneSchema = z.string().regex(/^\+?[\d\s-()]+$/, "Invalid phone number");
const urlSchema = z.string().url().optional();
const urlSchema = z.url().optional();
// Pagination
export const paginationSchema = z.object({
@@ -16,7 +16,7 @@ export const paginationSchema = z.object({
// Brand/Admin User
export const brandIdSchema = z.object({
brand_id: z.string().uuid().optional(),
brand_id: z.uuid().optional(),
});
// Orders
@@ -49,8 +49,8 @@ export const orderFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
status: z.enum(["pending", "confirmed", "preparing", "ready", "completed", "cancelled"]).optional(),
fulfillment: z.enum(["pickup", "ship", "mixed"]).optional(),
date_from: z.string().datetime().optional(),
date_to: z.string().datetime().optional(),
date_from: z.iso.datetime().optional(),
date_to: z.iso.datetime().optional(),
customer_email: emailSchema.optional(),
stop_id: uuidSchema.optional(),
});
@@ -102,7 +102,7 @@ export const createStopSchema = z.object({
state: z.string().max(50).optional(),
postal_code: z.string().max(20).optional(),
country: z.string().max(100).optional(),
scheduled_at: z.string().datetime(),
scheduled_at: z.iso.datetime(),
notes: z.string().max(1000).optional(),
product_ids: z.array(uuidSchema).optional(),
});
@@ -114,7 +114,7 @@ export const updateStopSchema = z.object({
city: z.string().max(100).optional(),
state: z.string().max(50).optional(),
postal_code: z.string().max(20).optional(),
scheduled_at: z.string().datetime().optional(),
scheduled_at: z.iso.datetime().optional(),
status: z.enum(["scheduled", "in_progress", "completed", "cancelled"]).optional(),
notes: z.string().max(1000).optional(),
product_ids: z.array(uuidSchema).optional(),
@@ -123,8 +123,8 @@ export const updateStopSchema = z.object({
export const stopFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
status: z.enum(["scheduled", "in_progress", "completed", "cancelled"]).optional(),
date_from: z.string().datetime().optional(),
date_to: z.string().datetime().optional(),
date_from: z.iso.datetime().optional(),
date_to: z.iso.datetime().optional(),
});
// Communication Campaigns
@@ -137,7 +137,7 @@ export const createCampaignSchema = z.object({
type: z.enum(["email", "sms"]).default("email"),
segment_id: uuidSchema.optional(),
contact_ids: z.array(uuidSchema).optional(),
scheduled_at: z.string().datetime().optional(),
scheduled_at: z.iso.datetime().optional(),
});
export const updateCampaignSchema = z.object({
@@ -146,7 +146,7 @@ export const updateCampaignSchema = z.object({
subject: z.string().min(1).max(500).optional(),
content: z.string().min(1).optional(),
status: z.enum(["draft", "scheduled", "sending", "sent", "cancelled"]).optional(),
scheduled_at: z.string().datetime().optional(),
scheduled_at: z.iso.datetime().optional(),
});
// Communication Contacts
@@ -202,7 +202,7 @@ export const createWaterLogSchema = z.object({
gallons: z.number().positive(),
duration_minutes: z.number().int().nonnegative().optional(),
notes: z.string().max(500).optional(),
logged_at: z.string().datetime().optional(),
logged_at: z.iso.datetime().optional(),
});
export const updateWaterLogSchema = z.object({
@@ -215,8 +215,8 @@ export const updateWaterLogSchema = z.object({
export const waterLogFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
field_id: uuidSchema.optional(),
date_from: z.string().datetime().optional(),
date_to: z.string().datetime().optional(),
date_from: z.iso.datetime().optional(),
date_to: z.iso.datetime().optional(),
});
// Wholesale
@@ -296,8 +296,8 @@ export const updateAdminUserSchema = z.object({
// Reports
export const reportFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
start_date: z.string().datetime(),
end_date: z.string().datetime(),
start_date: z.iso.datetime(),
end_date: z.iso.datetime(),
group_by: z.enum(["day", "week", "month"]).default("day"),
});
+197
View File
@@ -0,0 +1,197 @@
/**
* Welcome email sender — extracted from the welcome-sequence cron handler
* to keep it out of the `"use server"` action surface. Cron handlers
* already validate `Authorization: Bearer ${CRON_SECRET}`, so the
* sender itself does not need to re-check; running this code outside
* a server action also means a malicious caller can't directly hit
* `sendWelcomeEmail` via the Next.js Server Actions endpoint.
*/
type WelcomeEmailContent = {
subject: string;
heading: string;
body: string;
cta_text: string;
cta_url: string;
};
function welcomeEmailTemplates(): Record<string, Record<number, WelcomeEmailContent>> {
return {
en: {
1: {
subject: "Welcome to {brand} — here's what to expect",
heading: "You're in!",
body: "Thanks for subscribing to {brand}'s wholesale updates. Here's what you can expect:\n\n• New product announcements before anyone else\n• Exclusive wholesale pricing\n• Seasonal availability alerts\n• Quick reorder from your saved preferences",
cta_text: "Explore wholesale catalog",
cta_url: "/wholesale",
},
2: {
subject: "How wholesale ordering works with {brand}",
heading: "Ordering is simple",
body: "Here's how our wholesale process works:\n\n1. Browse the catalog and add items to your cart\n2. Checkout and pay online, or request an invoice\n3. Choose your pickup date at checkout\n4. We'll have your order ready when you arrive\n\nNo account required to browse — sign up only when you're ready to order.",
cta_text: "See current availability",
cta_url: "/wholesale/portal",
},
3: {
subject: "Your first order is waiting — {brand} wholesale",
heading: "Ready to try us out?",
body: "If you've been thinking about placing your first wholesale order with {brand}, now's a great time.\n\nOur current seasonal selection includes produce from our farm and partner growers, sourced for freshness and quality.\n\nQuestions? Reply to this email — we read every message.",
cta_text: "Start my first order",
cta_url: "/wholesale/register",
},
4: {
subject: "You're all set — wholesale updates from {brand}",
heading: "You're all set",
body: "You're now fully set up to receive wholesale updates from {brand}.\n\nWe'll send you occasional emails about new products, seasonal availability, and any special offers. No spam — just the useful stuff.\n\nYou can unsubscribe at any time.",
cta_text: "Browse the catalog",
cta_url: "/wholesale/portal",
},
},
es: {
1: {
subject: "Bienvenido a {brand} — esto es lo que puedes esperar",
heading: "¡Bienvenido!",
body: "Gracias por suscribirte a las actualizaciones mayoristas de {brand}. Esto es lo que puedes esperar:\n\n• Anuncios de nuevos productos antes que nadie\n• Precios exclusivos al por mayor\n• Alertas de disponibilidad por temporada\n• Reorden rápido desde tus preferencias guardadas",
cta_text: "Explorar catálogo mayorista",
cta_url: "/wholesale",
},
2: {
subject: "Cómo funciona el pedido al por mayor con {brand}",
heading: "Ordenar es simple",
body: "Así funciona nuestro proceso mayorista:\n\n1. Explora el catálogo y agrega artículos a tu carrito\n2. Paga en línea o solicita una factura\n3. Elige tu fecha de recogida al pagar\n4. Tendremos tu pedido listo cuando llegues\n\nNo necesitas cuenta para explorar — regístrate solo cuando estés listo para ordenar.",
cta_text: "Ver disponibilidad actual",
cta_url: "/wholesale/portal",
},
3: {
subject: "Tu primer pedido está esperando — {brand} mayorista",
heading: "¿Listo para probarnos?",
body: "Si has estado pensando en hacer tu primer pedido al por mayor con {brand}, ahora es un excelente momento.\n\nNuestra selección actual de temporada incluye productos de nuestra granja y productores asociados, seleccionados por su frescura y calidad.\n\n¿Preguntas? Responde a este correo — leemos cada mensaje.",
cta_text: "Comenzar mi primer pedido",
cta_url: "/wholesale/register",
},
4: {
subject: "Todo listo — actualizaciones mayoristas de {brand}",
heading: "Todo está listo",
body: "Ahora estás completamente configurado para recibir actualizaciones mayoristas de {brand}.\n\nTe enviaremos correos ocasionales sobre nuevos productos, disponibilidad por temporada y ofertas especiales. Sin spam — solo cosas útiles.\n\nPuedes darte de baja en cualquier momento.",
cta_text: "Explorar el catálogo",
cta_url: "/wholesale/portal",
},
},
};
}
function buildWelcomeEmail(params: {
brandName: string;
contactName: string | null;
locale: string;
step: number;
ctaUrl: string;
}): { subject: string; html: string; text: string } {
const { brandName, contactName, locale, step, ctaUrl } = params;
const t = welcomeEmailTemplates()[locale]?.[step] ?? welcomeEmailTemplates().en[step];
const greeting = locale === "es"
? (contactName ? `Hola ${contactName}` : `Hola`)
: (contactName ? `Hi ${contactName}` : `Hi there`);
const fullCtaUrl = ctaUrl.startsWith("http") ? ctaUrl : `https://route-commerce-platform.vercel.app${ctaUrl}`;
const footerText = locale === "es"
? "Te suscribiste a actualizaciones mayoristas de {brand}. Cancela la suscripción en cualquier momento."
: "You subscribed to wholesale updates from {brand}. Unsubscribe at any time.";
const subject = t.subject.replace("{brand}", brandName);
const heading = t.heading;
const body = t.body.replace(/{brand}/g, brandName);
const html = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f0f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#fafafa">
<div style="max-width:560px;margin:0 auto;padding:32px 16px">
<div style="background:#1c1917;border-radius:16px;padding:28px 32px;margin-bottom:24px">
<p style="margin:0 0 4px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#a8a29e">${locale === "es" ? "Bienvenido" : "Welcome"}</p>
<h1 style="margin:0;font-size:22px;font-weight:800;color:#ffffff">${heading}</h1>
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,.6)">${brandName}</p>
</div>
<div style="background:#18181b;border-radius:16px;padding:28px 32px;border:1px solid #27272a;margin-bottom:24px">
<p style="margin:0 0 16px;font-size:14px;color:#fafafa">${greeting},</p>
<p style="margin:0 0 20px;font-size:14px;color:#a8a29e;line-height:1.7;white-space:pre-wrap">${body}</p>
<a href="${fullCtaUrl}" style="display:inline-block;text-align:center;background:#16a34a;color:#fff;text-decoration:none;font-weight:700;font-size:14px;padding:14px 28px;border-radius:8px;margin-top:8px">${t.cta_text}</a>
</div>
<div style="text-align:center;margin-top:24px">
<p style="margin:0;font-size:12px;color:#52525b">${footerText.replace("{brand}", brandName)}</p>
</div>
</div>
</body>
</html>`;
const text = `[${brandName} - ${heading}]\n\n${greeting},\n\n${body}\n\n${t.cta_text}: ${fullCtaUrl}\n\n${footerText.replace("{brand}", brandName)}`;
return { subject, html, text };
}
export type WelcomeSequenceEntry = {
id: string;
brand_id: string;
contact_id: string | null;
contact_email: string;
contact_name: string | null;
brand_name: string | null;
locale: string;
sequence_step: number;
last_email_sent_at: string | null;
next_email_at: string | null;
status: string;
created_at: string;
};
export type SendWelcomeEmailResult = { success: boolean; error?: string };
/**
* Build and dispatch a single welcome email step. Intended to be called
* from the cron handler at `/api/email-automation/welcome-sequence`
* (which validates `CRON_SECRET`) — not from a server action surface.
*/
export async function sendWelcomeEmail(
entry: WelcomeSequenceEntry,
step: number,
): Promise<SendWelcomeEmailResult> {
const { brand_name, contact_name, locale, contact_email } = entry;
const t = welcomeEmailTemplates()[locale]?.[step] ?? welcomeEmailTemplates().en[step];
const ctaUrl = t.cta_url;
const { subject, html, text } = buildWelcomeEmail({
brandName: brand_name ?? "Our Farm",
contactName: contact_name,
locale: locale ?? "en",
step,
ctaUrl,
});
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
if (!RESEND_API_KEY) return { success: false, error: "Resend not configured" };
try {
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: process.env.FROM_EMAIL ?? "Route Commerce <no-reply@routecommerce.com>",
to: [contact_email],
subject,
html,
text,
}),
});
if (!res.ok) {
const err = await res.text();
return { success: false, error: err };
}
return { success: true };
} catch (e) {
return { success: false, error: String(e) };
}
}