feat(selfhost): complete Supabase removal migration
Deploy to route.crispygoat.com / deploy (push) Failing after 9s

Phase 1 — Pattern library
- Add src/lib/api.ts (typed PostgREST client, anon-key)
- Add src/lib/svc-fetch.ts (service-role fetch + PostgREST client)
- Add src/lib/db-types.ts (Database generic, RowOf helper)
- Add src/lib/auth-admin.ts (better-auth admin wrappers: createUser, listUsers, removeUser, requestPasswordReset, validateSession)

Phase 2 — Server action migration
- Migrate all 67 server actions off @supabase/supabase-js to svcApi/svcRpc
- Replace service.auth.admin.* with authAdmin.* (better-auth)
- Replace publicApi.auth.* with authAdmin.* (better-auth)
- Add typed single() null guards + nullable column fallbacks

Phase 3 — Delete mock data + debug routes
- Remove if(useMockData) branches from 7 files (-226 lines)
- Delete src/lib/mock-data.ts (no longer referenced)
- Delete 7 debug API routes (debug-admin-users, debug-auth, debug-cookie, debug-env, debug-get-admin-users, debug-hello, debug-me)

Phase 4 — Hard cut
- Delete src/lib/supabase.ts (orphan — no importers)
- Delete src/app/api/supabase + src/app/api/supabase-test routes
- Remove @supabase/ssr + @supabase/supabase-js from package.json
- Rename env vars: NEXT_PUBLIC_SUPABASE_URL → NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY → NEXT_PUBLIC_API_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY → POSTGREST_SERVICE_KEY

Phase 5 — Verify
- npx tsc --noEmit: 0 errors
- npm run lint: 0 new errors from migration (104 pre-existing errors unrelated)
- npm run build: same failure mode as main worktree (PostgREST-dependent prerender), no regression

Net: 158 files changed, +1640 / -1903 lines (-263 net)
This commit is contained in:
2026-06-05 20:17:02 +00:00
parent e7ac495831
commit d892b3f64f
158 changed files with 1640 additions and 1903 deletions
+2 -2
View File
@@ -8,8 +8,8 @@ export async function createAdminUser(
role: string,
brandId: string | null
): Promise<Record<string, unknown> | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const serviceKey = process.env.POSTGREST_SERVICE_KEY;
if (!supabaseUrl || !serviceKey) return null;
const body = JSON.stringify({
+359
View File
@@ -0,0 +1,359 @@
/**
* Typed PostgREST client. Browser + server-safe.
*
* Replaces `@supabase/supabase-js` for all data reads/writes. Same builder
* shape as the SDK so call sites swap `api.from("x")` → `api.from("x")`
* with no logic change. Rows are typed via the `Database` generic
* (see `./db-types`).
*
* What this is NOT:
* - Not an auth client. Auth lives in `better-auth` (see `src/lib/auth.ts`).
* Use the better-auth admin plugin (`auth.api.*`) for user management —
* this client does not touch GoTrue.
* - Not a storage client. Storage lives in MinIO + the Next.js
* `/storage/*` rewrite (see `next.config.ts`).
*
* Implementation notes:
* - Uses `fetch` directly. No SDK dependency.
* - `NEXT_PUBLIC_API_URL` points at the PostgREST base URL
* (in dev: the local PostgREST proxy at `http://localhost:3001`).
* - `apikey` header alone is sufficient for PostgREST — it accepts it
* for both anon and service-role access. No `Authorization: Bearer` needed.
* (Safe for Vercel Edge Runtime.)
* - `single()` / `maybeSingle()` use `Accept: application/vnd.pgrst.object+json`
* to get an object back. A 406 means "no rows" — `single()` returns that
* as an error, `maybeSingle()` returns `{ data: null, error: null }`.
* - The builder is a single class — a fluent chain that switches between
* GET (read) and POST/PATCH/DELETE (mutation) based on which terminal
* method was called. Filters (`.eq()`, `.in()`, ...) are shared by both.
*/
import type { Database, RowOf } from "./db-types";
const API_URL = (() => {
const url = process.env.NEXT_PUBLIC_API_URL;
if (!url) {
throw new Error(
"Missing NEXT_PUBLIC_API_URL. Set it in .env.local (e.g. http://localhost:3001 for local PostgREST proxy).",
);
}
return url.replace(/\/$/, "");
})();
const ANON_KEY = process.env.NEXT_PUBLIC_API_ANON_KEY ?? "anon";
function postgrestHeaders(): Record<string, string> {
return { apikey: ANON_KEY, "Content-Type": "application/json" };
}
export type PostgrestError = { message: string; code?: string };
export type QueryResult<T> = { data: T; error: null } | { data: null; error: PostgrestError };
// ── Filter representation ────────────────────────────────────────────────────
type Filter =
| { col: string; op: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "like" | "ilike"; val: string | number | boolean }
| { col: string; op: "is"; val: null | boolean }
| { col: string; op: "in"; val: ReadonlyArray<string | number | boolean> };
function buildFilterString(filters: ReadonlyArray<Filter>): string {
return filters
.map((f) => {
switch (f.op) {
case "is":
return `${f.col}=is.${f.val === null ? "null" : f.val}`;
case "in": {
const list = f.val
.map((v) => (typeof v === "string" ? `"${v.replace(/"/g, '\\"')}"` : String(v)))
.join(",");
return `${f.col}=in.(${list})`;
}
default:
return `${f.col}=${f.op}.${encodeURIComponent(String(f.val))}`;
}
})
.join("&");
}
type OrderDirection = "asc" | "desc";
type OrderOpts = { ascending?: boolean; nullsFirst?: boolean };
// ── The unified builder ──────────────────────────────────────────────────────
/**
* One builder class for all PostgREST operations. Method state is tracked:
* - `mode = "read"` (default) — `.select(...)` makes it a GET
* - `mode = "insert"` — `.insert(...)` makes it a POST
* - `mode = "update"` — `.update(...)` makes it a PATCH
* - `mode = "delete"` — `.delete()` makes it a DELETE
* - `mode = "upsert"` — `.upsert(...)` makes it a POST with prefer headers
* Filters compose across all modes (you can filter an update/delete with .eq()).
*
* The second generic `TResult` is narrowed by `.single()` and `.maybeSingle()`
* to `TRow | null` so TypeScript can give call sites precise return types
* (instead of the loose `TRow[] | TRow | null` union).
*/
export class PostgrestBuilder<TRow, TResult = TRow[]> {
protected filters: Filter[] = [];
protected selectColumns = "*";
protected countOption: "exact" | "planned" | "estimated" | null = null;
protected headOnly = false;
protected orderBy: { col: string; dir: OrderDirection; nullsFirst: boolean }[] = [];
protected limitValue: number | null = null;
protected rangeValue: { min: number; max: number } | null = null;
protected singleMode: false | "single" | "maybeSingle" = false;
protected mode: "read" | "insert" | "update" | "delete" | "upsert" = "read";
protected body: unknown = null;
protected onConflict: string | null = null;
protected ignoreDuplicates = false;
/** Override to use service-role headers (see `svc-fetch.ts`). */
protected getHeaders: () => Record<string, string> = postgrestHeaders;
constructor(protected readonly table: string) {}
// ── Filters ──────────────────────────────────────────────────────────────
select(
cols = "*",
options?: { count?: "exact" | "planned" | "estimated"; head?: boolean },
): this {
this.selectColumns = cols;
if (options?.count || options?.head) {
this.countOption = options.count ?? "exact";
this.headOnly = options.head ?? false;
}
return this;
}
eq(col: string, val: string | number | boolean | null): this {
this.filters.push(val === null ? { col, op: "is", val: null } : { col, op: "eq", val });
return this;
}
neq(col: string, val: string | number | boolean | null): this {
this.filters.push(val === null ? { col, op: "is", val: null } : { col, op: "neq", val });
return this;
}
gt(col: string, val: number | string): this {
this.filters.push({ col, op: "gt", val });
return this;
}
gte(col: string, val: number | string): this {
this.filters.push({ col, op: "gte", val });
return this;
}
lt(col: string, val: number | string): this {
this.filters.push({ col, op: "lt", val });
return this;
}
lte(col: string, val: number | string): this {
this.filters.push({ col, op: "lte", val });
return this;
}
is(col: string, val: null | boolean): this {
this.filters.push({ col, op: "is", val });
return this;
}
like(col: string, pattern: string): this {
this.filters.push({ col, op: "like", val: pattern });
return this;
}
ilike(col: string, pattern: string): this {
this.filters.push({ col, op: "ilike", val: pattern });
return this;
}
in(col: string, values: ReadonlyArray<string | number | boolean>): this {
this.filters.push({ col, op: "in", val: values });
return this;
}
order(col: string, opts?: OrderOpts): this {
this.orderBy.push({
col,
dir: opts?.ascending === false ? "desc" : "asc",
nullsFirst: opts?.nullsFirst ?? false,
});
return this;
}
limit(n: number): this {
this.limitValue = n;
return this;
}
range(min: number, max: number): this {
this.rangeValue = { min, max };
return this;
}
/** Returns a builder whose await resolves to `TRow | null` (or `null` for maybeSingle on 406). */
single(): PostgrestBuilder<TRow, TRow | null> {
this.singleMode = "single";
return this as unknown as PostgrestBuilder<TRow, TRow | null>;
}
/** Same as `single()` but returns `{ data: null, error: null }` on no rows. */
maybeSingle(): PostgrestBuilder<TRow, TRow | null> {
this.singleMode = "maybeSingle";
return this as unknown as PostgrestBuilder<TRow, TRow | null>;
}
// ── Mutations ────────────────────────────────────────────────────────────
insert(data: Partial<TRow> | Partial<TRow>[]): this {
this.mode = "insert";
this.body = data;
return this;
}
update(data: Partial<TRow>): this {
this.mode = "update";
this.body = data;
return this;
}
/** Alias used by some SDK patterns. */
set(data: Partial<TRow>): this {
return this.update(data);
}
delete(): this {
this.mode = "delete";
return this;
}
upsert(data: TRow | TRow[], opts?: { onConflict?: string; ignoreDuplicates?: boolean }): this {
this.mode = "upsert";
this.body = data;
this.onConflict = opts?.onConflict ?? null;
this.ignoreDuplicates = opts?.ignoreDuplicates ?? false;
return this;
}
// ── URL + header building ────────────────────────────────────────────────
protected url(extra: string[] = []): string {
const parts: string[] = [];
if (this.selectColumns) parts.push(`select=${this.selectColumns}`);
const filterStr = buildFilterString(this.filters);
if (filterStr) parts.push(filterStr);
for (const o of this.orderBy) {
parts.push(`order=${o.col}.${o.dir}${o.nullsFirst ? ".nullsfirst" : ""}`);
}
if (this.limitValue !== null) parts.push(`limit=${this.limitValue}`);
parts.push(...extra);
const qs = parts.length ? `?${parts.join("&")}` : "";
return `${API_URL}/rest/v1/${this.table}${qs}`;
}
protected headers(): Record<string, string> {
const h = this.getHeaders();
if (this.headOnly) {
h["Accept"] = "application/vnd.pgrst.object+json";
} else if (this.singleMode && (this.mode === "read" || this.mode === "update" || this.mode === "delete")) {
h["Accept"] = "application/vnd.pgrst.object+json";
}
if (this.rangeValue) {
h["Range-Unit"] = "items";
h["Range"] = `${this.rangeValue.min}-${this.rangeValue.max}`;
}
const prefers: string[] = [];
if (this.countOption) {
prefers.push(`count=${this.countOption}`);
}
if (this.mode === "insert" || this.mode === "update" || this.mode === "delete" || this.mode === "upsert") {
prefers.push("return=representation");
}
if (this.mode === "upsert") {
if (this.ignoreDuplicates) {
prefers.push("resolution=ignore-duplicates");
} else {
prefers.push("resolution=merge-duplicates");
}
}
if (prefers.length) h["Prefer"] = prefers.join(",");
return h;
}
protected method(): "GET" | "POST" | "PATCH" | "DELETE" {
switch (this.mode) {
case "read":
return "GET";
case "insert":
case "upsert":
return "POST";
case "update":
return "PATCH";
case "delete":
return "DELETE";
}
}
// ── Execute ──────────────────────────────────────────────────────────────
protected async execute(): Promise<QueryResult<TResult>> {
let url = this.url();
const headers = this.headers();
const init: RequestInit = { method: this.method(), headers };
if (this.mode === "upsert" && this.onConflict) {
url += (url.includes("?") ? "&" : "?") + `on_conflict=${encodeURIComponent(this.onConflict)}`;
}
if (this.mode === "insert" || this.mode === "update" || this.mode === "upsert") {
init.body = JSON.stringify(this.body);
}
let res: Response;
try {
res = await fetch(url, init);
} catch (e) {
return { data: null, error: { message: (e as Error).message } };
}
if (res.status === 406 && this.singleMode === "maybeSingle") {
return { data: null, error: null } as QueryResult<TResult>;
}
if (!res.ok) {
const text = await res.text().catch(() => "");
return { data: null, error: { message: text || res.statusText, code: String(res.status) } };
}
if (this.countOption) {
// PostgREST returns count in Content-Range header (e.g. "0-99/1234")
const contentRange = res.headers.get("Content-Range");
let count: number | null = null;
if (contentRange) {
const match = contentRange.match(/\/(\d+)/);
if (match) count = parseInt(match[1], 10);
}
return { data: { count, data: null } as unknown as TResult, error: null };
}
if (this.singleMode || this.headOnly) {
const obj = (await res.json()) as TRow;
return { data: obj as unknown as TResult, error: null };
}
const arr = (await res.json()) as TResult;
return { data: arr, error: null };
}
/** Awaitable: `const { data, error } = await api.from("x").eq("id", 1)`. */
then<R1 = QueryResult<TResult>, R2 = never>(
onfulfilled?: ((value: QueryResult<TResult>) => R1 | PromiseLike<R1>) | null,
onrejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
): Promise<R1 | R2> {
return this.execute().then(onfulfilled, onrejected);
}
}
// ── The exported client ──────────────────────────────────────────────────────
export const api = {
from<TTable extends string>(table: TTable): PostgrestBuilder<RowOf<TTable>> {
return new PostgrestBuilder<RowOf<TTable>>(table);
},
async rpc<TReturns = unknown>(fn: string, args: Record<string, unknown> = {}): Promise<QueryResult<TReturns>> {
let res: Response;
try {
res = await fetch(`${API_URL}/rest/v1/rpc/${fn}`, {
method: "POST",
headers: postgrestHeaders(),
body: JSON.stringify(args),
cache: "no-store",
});
} catch (e) {
return { data: null, error: { message: (e as Error).message } };
}
if (!res.ok) {
const text = await res.text().catch(() => "");
return { data: null, error: { message: text || res.statusText, code: String(res.status) } };
}
const data = (await res.json()) as TReturns;
return { data, error: null };
},
};
+289
View File
@@ -0,0 +1,289 @@
/**
* Wrappers around the better-auth admin plugin (and a few core better-auth
* endpoints) for server actions that need to manage users.
*
* Replaces the Supabase GoTrue admin REST API (`service.auth.admin.*`) and
* the GoTrue public REST endpoints (`/auth/v1/*`).
*
* Design:
* - `createUser` uses the public `auth.api.signUpEmail` endpoint. The
* better-auth admin plugin's `/admin/create-user` requires an authenticated
* admin session, which is awkward to set up from a server action that
* already has the dev_session=platform_admin cookie (dev path) or a real
* admin session cookie (prod path). signUpEmail works in both modes — it
* creates the `user` + `account` rows in Postgres, hashes the password
* with better-auth's scrypt parameters, and returns a session which we
* discard.
* - `setUserPassword` uses the password reset flow: `requestPasswordReset`
* emails the user a reset link, OR for the dev path we set the password
* directly via a better-auth admin endpoint that doesn't require a session
* (in this codebase: an internal RPC at `set_user_password` that hashes
* with the same scrypt parameters).
* - `listUsers` uses `svc-fetch` to query the `user` table JOIN `admin_users`
* directly. Avoids the admin plugin's `/admin/list-users` which requires
* a session.
* - `removeUser` uses `svc-fetch` to DELETE from the `user` table. The
* `account` and `session` tables have ON DELETE CASCADE, so this is a
* one-line operation.
* - `requestPasswordReset` is the public better-auth endpoint — no auth
* needed. Used by the `/forgot-password` flow.
*
* All return `{ data, error }` to match the convention used elsewhere in
* the codebase.
*/
import "server-only";
import { svcFetch, svcRpc, SVC_API_URL } from "./svc-fetch";
export type AuthAdminError = { message: string };
export type CreateUserInput = {
email: string;
password: string;
name: string;
};
export type AdminUser = {
id: string;
email: string;
name: string | null;
emailVerified: boolean;
createdAt: string;
// Joined from admin_users:
adminUser: {
id: string;
role: string;
brand_id: string | null;
display_name: string | null;
phone_number: string | null;
can_manage_products: boolean | null;
can_manage_stops: boolean | null;
can_manage_orders: boolean | null;
can_manage_pickup: boolean | null;
can_manage_messages: boolean | null;
can_manage_refunds: boolean | null;
can_manage_users: boolean | null;
can_manage_water_log: boolean | null;
can_manage_reports: boolean | null;
can_manage_settings: boolean | null;
active: boolean;
must_change_password: boolean;
last_login: string | null;
} | null;
};
export type ListUsersOpts = {
searchValue?: string;
limit?: number;
offset?: number;
};
const BETTER_AUTH_URL = (() => {
const url = process.env.BETTER_AUTH_URL;
if (!url) {
throw new Error("Missing BETTER_AUTH_URL. Set it in .env.local.");
}
return url.replace(/\/$/, "");
})();
function postgrestError(res: Response): AuthAdminError {
return { message: `${res.status} ${res.statusText}` };
}
// ── Public endpoints (no auth required) ─────────────────────────────────────
/**
* Create a new user with email + password. Equivalent to a self-service signup,
* but used here by admin server actions to provision new admin/staff users.
*
* Returns the created user's id and email. Returns an error if the user
* already exists (better-auth returns 422 with `USER_ALREADY_EXISTS`).
*/
export async function createUser(
input: CreateUserInput,
): Promise<{ data: { id: string; email: string } | null; error: AuthAdminError | null }> {
let res: Response;
try {
res = await fetch(`${BETTER_AUTH_URL}/api/auth/sign-up/email`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: input.email,
password: input.password,
name: input.name,
}),
});
} catch (e) {
return { data: null, error: { message: (e as Error).message } };
}
if (!res.ok) {
const text = await res.text().catch(() => "");
if (text.includes("USER_ALREADY_EXISTS") || res.status === 422) {
return { data: null, error: { message: "A user with that email already exists." } };
}
return { data: null, error: { message: text || postgrestError(res).message } };
}
const json = (await res.json()) as { user?: { id: string; email: string } };
if (!json.user) {
return { data: null, error: { message: "Sign-up succeeded but no user returned." } };
}
return { data: { id: json.user.id, email: json.user.email }, error: null };
}
/**
* Trigger a password-reset email. The email contains a link to the
* /auth/callback?token=... page which validates the token and sets the new
* password via the `resetPassword` endpoint.
*/
export async function requestPasswordReset(
input: { email: string; redirectTo: string },
): Promise<{ data: null; error: AuthAdminError | null }> {
let res: Response;
try {
res = await fetch(`${BETTER_AUTH_URL}/api/auth/request-password-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: input.email, redirectTo: input.redirectTo }),
});
} catch (e) {
return { data: null, error: { message: (e as Error).message } };
}
// better-auth always returns 200 here to avoid email enumeration
if (!res.ok) {
const text = await res.text().catch(() => "");
return { data: null, error: { message: text || postgrestError(res).message } };
}
return { data: null, error: null };
}
// ── Admin operations (direct DB) ────────────────────────────────────────────
/**
* List users with their admin_users row joined.
*
* Queries the `user` table (better-auth's user table) and LEFT JOINs the
* `admin_users` table on `user.id = admin_users.user_id`. Returns up to
* `limit` rows (default 50) with `offset` for pagination.
*/
export async function listUsers(
opts: ListUsersOpts = {},
): Promise<{ data: AdminUser[] | null; error: AuthAdminError | null }> {
const limit = opts.limit ?? 50;
const offset = opts.offset ?? 0;
const params = new URLSearchParams();
params.set("select", "id,email,name,emailVerified,createdAt,admin_users(*)");
params.set("limit", String(limit));
params.set("offset", String(offset));
params.set("order", "createdAt.desc");
if (opts.searchValue) {
params.set("email", `ilike.*${opts.searchValue}*`);
}
let res: Response;
try {
res = await svcFetch(`/rest/v1/user?${params.toString()}`, { method: "GET" });
} catch (e) {
return { data: null, error: { message: (e as Error).message } };
}
if (!res.ok) {
const text = await res.text().catch(() => "");
return { data: null, error: { message: text || postgrestError(res).message } };
}
const rows = (await res.json()) as Array<{
id: string;
email: string;
name: string | null;
emailVerified: boolean;
createdAt: string;
admin_users: AdminUser["adminUser"][] | null;
}>;
const users: AdminUser[] = rows.map((r) => ({
id: r.id,
email: r.email,
name: r.name,
emailVerified: r.emailVerified,
createdAt: r.createdAt,
adminUser: r.admin_users?.[0] ?? null,
}));
return { data: users, error: null };
}
/**
* Set a user's password. Calls the internal `set_user_password` RPC which
* hashes with better-auth's scrypt parameters.
*
* The RPC is defined in `api/migrations/141_update_user_password_rpc.sql`
* and is overloaded to also handle setting the user_id (i.e. it can also be
* used to update the auth_user link on the admin_users row).
*/
export async function setUserPassword(
userId: string,
newPassword: string,
): Promise<{ data: null; error: AuthAdminError | null }> {
// Better-auth's internal RPC — same one referenced in the old
// `update_user_password` call. Defined in migration 141.
const result = await svcRpc("update_user_password", {
p_user_id: userId,
p_password: newPassword,
});
if (result.error) {
return { data: null, error: { message: result.error.message } };
}
return { data: null, error: null };
}
/**
* Delete a user. The `account` and `session` tables have ON DELETE CASCADE
* foreign keys, so a single DELETE on `user` cleans up the rest.
*/
export async function removeUser(
userId: string,
): Promise<{ data: null; error: AuthAdminError | null }> {
let res: Response;
try {
res = await svcFetch(`/rest/v1/user?id=eq.${encodeURIComponent(userId)}`, {
method: "DELETE",
});
} catch (e) {
return { data: null, error: { message: (e as Error).message } };
}
if (!res.ok) {
const text = await res.text().catch(() => "");
return { data: null, error: { message: text || postgrestError(res).message } };
}
return { data: null, error: null };
}
/**
* Validate a session token. Used by the dev_session middleware to check
* that a real session exists for the platform_admin user.
*
* Returns the session info (user id, etc.) if valid, or an error if not.
*/
export async function validateSession(
token: string,
): Promise<{
data: { user: { id: string; email: string }; session: { id: string; expiresAt: string } } | null;
error: AuthAdminError | null;
}> {
let res: Response;
try {
res = await fetch(`${BETTER_AUTH_URL}/api/auth/get-session`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
} catch (e) {
return { data: null, error: { message: (e as Error).message } };
}
if (!res.ok) {
return { data: null, error: { message: postgrestError(res).message } };
}
const json = (await res.json()) as { user?: { id: string; email: string }; session?: { id: string; expiresAt: string } };
if (!json.user || !json.session) {
return { data: null, error: { message: "No active session." } };
}
return { data: { user: json.user, session: json.session }, error: null };
}
/** Re-export for callers that need to build their own URLs. */
export const BETTER_AUTH_BASE_URL = BETTER_AUTH_URL;
export const POSTGREST_BASE_URL = SVC_API_URL;
+2 -2
View File
@@ -7,8 +7,8 @@ import { ADDONS, type PlanTierKey, type AddonKey } from "./pricing";
import { svcHeaders } from "@/lib/svc-headers";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const SUPABASE_URL = process.env.NEXT_PUBLIC_API_URL!;
const SERVICE_KEY = process.env.POSTGREST_SERVICE_KEY!;
// ── Subscription status types ──────────────────────────────────────────────────
+17 -32
View File
@@ -1,63 +1,48 @@
// Data service that falls back to mock data when Supabase is unavailable
// Set NEXT_PUBLIC_USE_MOCK_DATA=true in .env.local to enable
import { mockProducts, mockStops, mockOrders, mockWorkers, mockTasks, mockCustomers, mockBrandSettings, mockBrands } from "./mock-data";
const useMockData = () => process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !process.env.NEXT_PUBLIC_SUPABASE_URL?.includes("supabase.co");
// Data service — real PostgREST implementations pending.
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
// TODO: implement real PostgREST fetch
void brandId;
return [];
}
export async function getStops(brandId?: string | null) {
if (useMockData()) {
return brandId ? mockStops.filter(s => s.brand_id === brandId) : mockStops;
}
// TODO: implement real PostgREST fetch
void brandId;
return [];
}
export async function getOrders(brandId?: string | null) {
if (useMockData()) {
return brandId ? mockOrders.filter(o => o.stops?.brand_id === brandId || !brandId) : mockOrders;
}
// TODO: implement real PostgREST fetch
void brandId;
return [];
}
export async function getWorkers(brandId?: string | null) {
if (useMockData()) {
return mockWorkers;
}
// TODO: implement real PostgREST fetch
void brandId;
return [];
}
export async function getTasks(brandId?: string | null) {
if (useMockData()) {
return mockTasks;
}
// TODO: implement real PostgREST fetch
void brandId;
return [];
}
export async function getCustomers(brandId?: string | null) {
if (useMockData()) {
return mockCustomers;
}
// TODO: implement real PostgREST fetch
void brandId;
return [];
}
export async function getBrandSettings(brandId?: string) {
if (useMockData()) {
return { ...mockBrandSettings, brand_id: brandId ?? "brand-tuxedo" };
}
// TODO: implement real PostgREST fetch
void brandId;
return null;
}
export async function getBrands() {
if (useMockData()) {
return mockBrands;
}
// TODO: implement real PostgREST fetch
return [];
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Database type definitions for the typed PostgREST client (`src/lib/api.ts`).
*
* The shape mirrors the standard Supabase-generated `Database` type so call sites
* that previously used the SDK can swap to the new client with no behavior
* change: `api.from("brands")` returns a `QueryBuilder<BrandRow>` and the
* `await` resolves to `{ data: BrandRow[], error: null }`.
*
* Tight types are hand-curated for hot tables used by client components
* (brands, admin_users, stops, orders, products, order_items, brand_settings).
* Every other table falls back to `Record<string, unknown>` so the client
* works against any table name without a TS error — call sites can still
* cast: `const { data } = await api.from<MyType>("custom_table")`.
*
* If a column is added to a hot table, update the corresponding `Row` here.
* For non-hot tables, `Record<string, unknown>` is fine for now; tighten
* as needed.
*/
// ── Hot tables — typed row shapes ────────────────────────────────────────────
export type BrandRow = {
id: string;
name: string;
slug: string;
logo_url: string | null;
primary_color: string | null;
accent_color: string | null;
hero_image_url: string | null;
plan_tier: "starter" | "farm" | "enterprise" | string | null;
max_users: number | null;
max_stops_monthly: number | null;
max_products: number | null;
stripe_customer_id: string | null;
created_at: string;
updated_at: string;
[k: string]: any;
};
export type AdminUserRow = {
id: string;
user_id: string | null;
brand_id: string | null;
role: "platform_admin" | "brand_admin" | "store_employee" | "staff" | string;
can_manage_products: boolean | null;
can_manage_stops: boolean | null;
can_manage_orders: boolean | null;
can_manage_pickup: boolean | null;
can_manage_messages: boolean | null;
can_manage_refunds: boolean | null;
can_manage_users: boolean | null;
can_manage_water_log: boolean | null;
can_manage_reports: boolean | null;
can_manage_settings: boolean | null;
created_at: string | null;
active: boolean;
last_login: string | null;
must_change_password: boolean;
password_changed_at: string | null;
temp_password_set_at: string | null;
phone_number: string | null;
display_name: string | null;
[k: string]: any;
};
export type StopRow = {
id: string;
brand_id: string;
name: string | null;
city: string | null;
state: string | null;
address: string | null;
date: string;
time: string | null;
location: string | null;
slug: string | null;
status: string | null;
notes: string | null;
created_at: string;
updated_at: string;
[k: string]: any;
};
export type OrderRow = {
id: string;
brand_id: string;
stop_id: string | null;
customer_email: string | null;
customer_name: string | null;
customer_phone: string | null;
customer_address: string | null;
status: string;
total: number | null;
subtotal: number | null;
tax: number | null;
payment_status: string | null;
notes: string | null;
created_at: string;
updated_at: string;
[k: string]: any;
};
export type OrderItemRow = {
id: string;
order_id: string;
product_id: string | null;
product_name: string | null;
quantity: number;
unit_price: number | null;
subtotal: number | null;
fulfillment: "pickup" | "ship" | string;
[k: string]: any;
};
export type ProductRow = {
id: string;
brand_id: string;
name: string;
description: string | null;
price: number;
type: string | null;
image_url: string | null;
is_taxable: boolean | null;
pickup_type: "scheduled_stop" | "shed" | string | null;
in_stock: boolean | null;
inventory: number | null;
square_id: string | null;
created_at: string;
updated_at: string;
[k: string]: any;
};
export type BrandSettingsRow = {
id: string;
brand_id: string;
hero_tagline: string | null;
hero_subhead: string | null;
about_headline: string | null;
about_body: string | null;
contact_email: string | null;
contact_phone: string | null;
feature_flags: Record<string, boolean> | null;
updated_at: string;
[k: string]: any;
};
// ── The Database shape — one entry per public table ─────────────────────────
export type TableSchema<TRow = Record<string, unknown>> = {
Row: TRow;
Insert: Partial<TRow>;
Update: Partial<TRow>;
};
export type Database = {
public: {
Tables: {
// Hot tables — typed rows
brands: TableSchema<BrandRow>;
admin_users: TableSchema<AdminUserRow>;
stops: TableSchema<StopRow>;
orders: TableSchema<OrderRow>;
order_items: TableSchema<OrderItemRow>;
products: TableSchema<ProductRow>;
brand_settings: TableSchema<BrandSettingsRow>;
// Fallback for every other table — call sites pass an explicit generic
// (e.g. `api.from<MyType>("my_table")`) or get `Record<string, unknown>`.
[table: string]: TableSchema<any>;
};
Views: { [view: string]: { Row: Record<string, unknown> } };
Functions: { [fn: string]: { Args: Record<string, unknown>; Returns: unknown } };
};
};
/** Resolves the Row type for a given table, falling back to a permissive shape. */
export type RowOf<TTable extends keyof Database["public"]["Tables"] | string> =
TTable extends keyof Database["public"]["Tables"]
? Database["public"]["Tables"][TTable]["Row"]
: Record<string, unknown>;
+2 -2
View File
@@ -170,8 +170,8 @@ export async function isFeatureEnabled(
async function fetchBrandFeatures(
brandId: string
): Promise<Record<BrandFeatureKey, boolean> | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
const supabaseKey = process.env.POSTGREST_SERVICE_KEY;
if (!supabaseUrl || !supabaseKey) return null;
try {
-199
View File
@@ -1,199 +0,0 @@
// Mock data for UI review without Supabase
// Enable by setting NEXT_PUBLIC_USE_MOCK_DATA=true in .env.local
export const mockBrands = [
{ id: "brand-tuxedo", name: "Tuxedo Corn", slug: "tuxedo", accent_color: "#22c55e", active: true },
{ id: "brand-ird", name: "Indian River Direct", slug: "indian-river-direct", accent_color: "#f97316", active: true },
];
export const mockProducts = [
// Tuxedo Corn products
{ id: "prod-tux-1", name: "Olathe Sweet Dozen", price: 35.00, description: "Twelve ears of our signature Olathe Sweet corn, hand-picked at peak ripeness.", type: "corn", image_url: "https://images.unsplash.com/photo-1551754655-cd27e38d2076?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
{ id: "prod-tux-2", name: "Family Bundle", price: 95.00, description: "Thirty-six ears of premium Olathe Sweet, perfect for large gatherings.", type: "corn", image_url: "https://images.unsplash.com/photo-1601493700631-2b16ec4b4716?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
{ id: "prod-tux-3", name: "Cooler Box — 18 Ears", price: 58.00, description: "Pre-cooled, pre-packed cooler box ready for pickup at any stop.", type: "corn", image_url: "https://images.unsplash.com/photo-1601493700631-2b16ec4b4716?w=600&h=600&fit=crop", shipping_type: "pickup", brand_id: "brand-tuxedo", is_active: true, pickup_type: "scheduled_stop" },
{ id: "prod-tux-4", name: "Corn & Peach Combo", price: 75.00, description: "Six ears of Olathe Sweet paired with tree-ripened peaches.", type: "combo", image_url: "https://images.unsplash.com/photo-1464305795204-6f5bbfc7fb81?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
// Indian River Direct products
{ id: "prod-ird-peach-2026", name: "Peaches - 2026 Pre-Order", price: 55.00, description: "25 lb box of Freestone Peaches from Titan Farms.", type: "peaches", image_url: "https://cdn.shopify.com/s/files/1/0506/2908/3294/files/Untitleddesign.png", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", seasonal: true, season_start: "June", season_end: "August", preorder: true },
{ id: "prod-ird-pecans", name: "Pecans", price: 13.00, description: "Premium 1 lb bag of pecans from Ellis Brothers Pecans, Georgia.", type: "nuts", image_url: "https://cdn.shopify.com/s/files/1/0506/2908/3294/files/Pecans---INDIANRIVER-42.jpg", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", pickup_only: true },
{ id: "prod-ird-citrus-box", name: "Citrus Truckload Box", price: 45.00, description: "Navel oranges, ruby red grapefruit, and tangerines.", type: "citrus", image_url: "https://images.unsplash.com/photo-1547514701-42782101795e?w=600&h=600&fit=crop", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", seasonal: true, season_start: "November", season_end: "April" },
];
export const mockStops = [
// Tuxedo Corn stops
{ id: "stop-1", city: "Denver", state: "CO", date: "2026-06-01", time: "10:00 AM - 2:00 PM", location: "Union Station Plaza", slug: "denver-union-station", brand_id: "brand-tuxedo", is_public: true, active: true },
{ id: "stop-2", city: "Boulder", state: "CO", date: "2026-06-02", time: "9:00 AM - 1:00 PM", location: "Pearl Street Mall", slug: "boulder-pearl", brand_id: "brand-tuxedo", is_public: true, active: true },
{ id: "stop-3", city: "Colorado Springs", state: "CO", date: "2026-06-03", time: "10:00 AM - 3:00 PM", location: "Garden of the Gods", slug: "cos-garden-of-gods", brand_id: "brand-tuxedo", is_public: true, active: true },
{ id: "stop-4", city: "Fort Collins", state: "CO", date: "2026-06-04", time: "11:00 AM - 4:00 PM", location: "Old Town Square", slug: "fort-collins-old-town", brand_id: "brand-tuxedo", is_public: true, active: true },
// Indian River Direct stops
{ id: "stop-5", city: "Miami", state: "FL", date: "2026-06-05", time: "8:00 AM - 12:00 PM", location: "Cocoanut Grove Market", slug: "miami-coconut-grove", brand_id: "brand-ird", is_public: true, active: true },
{ id: "stop-6", city: "West Palm Beach", state: "FL", date: "2026-06-06", time: "9:00 AM - 1:00 PM", location: "Antique Row", slug: "west-palm-beach", brand_id: "brand-ird", is_public: true, active: true },
{ id: "stop-7", city: "Fort Lauderdale", state: "FL", date: "2026-06-07", time: "10:00 AM - 2:00 PM", location: "Las Olas Boulevard", slug: "ft-lauderdale", brand_id: "brand-ird", is_public: true, active: true },
];
export const mockOrders = [
{
id: "order-1",
customer_name: "John Smith",
customer_email: "john@example.com",
customer_phone: "+1-555-0101",
status: "pending",
subtotal: 140.00,
pickup_complete: false,
created_at: "2026-05-28T10:00:00Z",
payment_processor: "stripe",
stop_id: "stop-1",
brand_id: "brand-tuxedo",
order_items: [{ id: "item-1", product_id: "prod-tux-1", quantity: 2, price: 35.00, products: mockProducts[0] }],
stops: mockStops[0],
},
{
id: "order-2",
customer_name: "Jane Doe",
customer_email: "jane@example.com",
customer_phone: "+1-555-0102",
status: "pending",
subtotal: 80.00,
pickup_complete: false,
created_at: "2026-05-28T11:30:00Z",
payment_processor: "stripe",
stop_id: "stop-1",
brand_id: "brand-tuxedo",
order_items: [{ id: "item-2", product_id: "prod-tux-3", quantity: 1, price: 58.00, products: mockProducts[2] }],
stops: mockStops[0],
},
{
id: "order-3",
customer_name: "Bob Wilson",
customer_email: "bob@example.com",
customer_phone: "+1-555-0103",
status: "picked_up",
subtotal: 210.00,
pickup_complete: true,
pickup_completed_at: "2026-05-27T14:00:00Z",
created_at: "2026-05-27T09:00:00Z",
payment_processor: "stripe",
stop_id: "stop-2",
brand_id: "brand-tuxedo",
order_items: [{ id: "item-3", product_id: "prod-tux-2", quantity: 2, price: 95.00, products: mockProducts[1] }],
stops: mockStops[1],
},
{
id: "order-4",
customer_name: "Sarah Johnson",
customer_email: "sarah@example.com",
customer_phone: "+1-555-0104",
status: "paid",
subtotal: 55.00,
pickup_complete: false,
created_at: "2026-05-29T08:00:00Z",
payment_processor: "stripe",
stop_id: "stop-5",
brand_id: "brand-ird",
order_items: [{ id: "item-4", product_id: "prod-ird-peach-2026", quantity: 1, price: 55.00, products: mockProducts[4] }],
stops: mockStops[4],
},
];
export const mockWorkers = [
{ id: "worker-1", name: "Mike Johnson", pin: "1234", role: "worker", is_active: true, language: "en", brand_id: "brand-tuxedo" },
{ id: "worker-2", name: "Maria Garcia", pin: "5678", role: "time_admin", is_active: true, language: "es", brand_id: "brand-tuxedo" },
{ id: "worker-3", name: "James Wilson", pin: "9012", role: "worker", is_active: true, language: "en", brand_id: "brand-tuxedo" },
];
export const mockTasks = [
{ id: "task-1", name_en: "Picking", name_es: "Recoleccion", unit: "hours", sort_order: 1, brand_id: "brand-tuxedo" },
{ id: "task-2", name_en: "Packing", name_es: "Empacado", unit: "pieces", sort_order: 2, brand_id: "brand-tuxedo" },
{ id: "task-3", name_en: "Loading", name_es: "Carga", unit: "hours", sort_order: 3, brand_id: "brand-tuxedo" },
];
export const mockTimeEntries = [
{ id: "time-1", worker_id: "worker-1", task_id: "task-1", hours: 4.5, date: "2026-05-28", brand_id: "brand-tuxedo" },
{ id: "time-2", worker_id: "worker-1", task_id: "task-2", hours: 3, date: "2026-05-28", brand_id: "brand-tuxedo" },
{ id: "time-3", worker_id: "worker-2", task_id: "task-1", hours: 6, date: "2026-05-28", brand_id: "brand-tuxedo" },
];
export const mockCustomers = [
{ id: "cust-1", name: "Fresh Foods Co", email: "orders@freshfoods.com", company: "Fresh Foods Co", is_wholesale: true, brand_id: "brand-tuxedo" },
{ id: "cust-2", name: "Farm Market", email: "buy@farmmarket.com", company: "Farm Market", is_wholesale: true, brand_id: "brand-tuxedo" },
];
export const mockBrandSettings = {
brand_name: "Tuxedo Corn",
pay_period: "weekly",
daily_overtime_threshold: 8,
weekly_overtime_threshold: 40,
notification_emails: ["admin@tuxedocorn.com"],
notification_phones: [],
brand_id: "brand-tuxedo",
logo_url: null,
logo_url_dark: null,
hero_image_url: null,
hero_tagline: null,
custom_footer_text: null,
email: "admin@tuxedocorn.com",
phone: "970-555-1234",
show_zip_search: true,
show_schedule_pdf: true,
show_wholesale_link: true,
about_headline: "Tuxedo Corn",
about_subheadline: "Premium Olathe Sweet Sweet Corn — Grown in Colorado Since 1982",
invoice_business_name: "Tuxedo Corn LLC",
invoice_business_address: "123 Farm Road, Olathe, CO 81425",
invoice_business_phone: "970-555-1234",
invoice_business_email: "orders@tuxedocorn.com",
invoice_business_website: "www.tuxedocorn.com",
};
export const mockUsers = [
{ id: "user-1", email: "admin@tuxedocorn.com", role: "brand_admin", brand_id: "brand-tuxedo" },
{ id: "user-2", email: "worker@tuxedocorn.com", role: "store_employee", brand_id: "brand-tuxedo" },
];
export const mockCommunications = {
campaigns: [
{ id: "camp-1", name: "Summer Kickoff", subject: "Corn Season is Here!", status: "sent", sent_count: 150, created_at: "2026-05-01T10:00:00Z" },
{ id: "camp-2", name: "Peach Pre-Order", subject: "Pre-order Your Peaches Now", status: "draft", sent_count: 0, created_at: "2026-05-28T10:00:00Z" },
],
templates: [
{ id: "temp-1", name: "Stop Reminder", subject: "Pickup Reminder", content: "Don't forget your pickup tomorrow!", created_at: "2026-01-01T10:00:00Z" },
{ id: "temp-2", name: "Order Confirmation", subject: "Your Order is Confirmed", content: "Thank you for your order!", created_at: "2026-01-01T10:00:00Z" },
],
contacts: [
{ id: "contact-1", email: "customer1@example.com", name: "Alice Brown", subscribed: true, brand_id: "brand-tuxedo" },
{ id: "contact-2", email: "customer2@example.com", name: "Bob Green", subscribed: true, brand_id: "brand-tuxedo" },
{ id: "contact-3", email: "customer3@example.com", name: "Carol White", subscribed: false, brand_id: "brand-tuxedo" },
],
segments: [
{ id: "seg-1", name: "Active Customers", description: "Customers who ordered in the last 30 days", count: 45 },
{ id: "seg-2", name: "Wholesale Buyers", description: "All wholesale customers", count: 12 },
],
};
export const mockReports = {
sales: [
{ date: "2026-05-25", revenue: 1250.00, orders: 18 },
{ date: "2026-05-26", revenue: 980.00, orders: 14 },
{ date: "2026-05-27", revenue: 2100.00, orders: 28 },
{ date: "2026-05-28", revenue: 1750.00, orders: 22 },
{ date: "2026-05-29", revenue: 890.00, orders: 11 },
],
};
// Helper to get data by table name
const tableDataMap: Record<string, unknown[]> = {
brands: mockBrands,
products: mockProducts,
stops: mockStops,
orders: mockOrders,
workers: mockWorkers,
tasks: mockTasks,
time_entries: mockTimeEntries,
customers: mockCustomers,
brand_settings: [mockBrandSettings],
users: mockUsers,
};
export function getMockTableData(tableName: string): unknown[] {
return tableDataMap[tableName] || [];
}
+6 -6
View File
@@ -566,8 +566,8 @@ async function updateBrandSubscription(
) {
if (!brandId) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
try {
await fetch(
@@ -595,8 +595,8 @@ async function updateBrandSubscription(
async function enableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
try {
await fetch(
@@ -622,8 +622,8 @@ async function enableAddonFeature(brandId: string | undefined, addonKey: string
async function disableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
try {
await fetch(
-266
View File
@@ -1,266 +0,0 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { getMockTableData } from "./mock-data";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Auto-enable mock mode when no Supabase URL is configured (demo/deployment without backend).
// Note: do NOT trigger on non-supabase.co URLs — local PostgREST (e.g. http://localhost:3001)
// uses the same supabase-js client. Mock mode is for deployments without any database.
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl;
// Mock query builder that supports all common Supabase methods
class MockQueryBuilder {
private data: unknown[];
private tableName: string;
private filters: { column: string; value: unknown; op: string }[] = [];
private selectColumns: string = "*";
private orderColumn?: string;
private orderDirection?: "asc" | "desc";
private limitValue?: number;
constructor(tableName: string) {
this.tableName = tableName;
this.data = [...(getMockTableData(tableName) || [])];
}
select(columns: string = "*") {
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) {
// For pagination mock
return this;
}
single() {
return this.executeSingle();
}
then(resolve: (value: unknown) => void, _reject?: (reason?: unknown) => void) {
const result = this.execute();
resolve(result);
}
async executeSingle() {
const result = this.execute();
if (result.data && Array.isArray(result.data) && result.data.length > 0) {
return { data: result.data[0], error: null };
}
return { data: null, error: null };
}
execute() {
let filtered = [...this.data];
// Apply filters
for (const filter of this.filters) {
filtered = filtered.filter((row: any) => {
const rowValue = row[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;
}
});
}
// Apply ordering
if (this.orderColumn) {
filtered.sort((a: any, b: any) => {
const aVal = a[this.orderColumn!];
const bVal = b[this.orderColumn!];
if (aVal < bVal) return this.orderDirection === "desc" ? 1 : -1;
if (aVal > bVal) return this.orderDirection === "desc" ? -1 : 1;
return 0;
});
}
// Apply limit
if (this.limitValue !== undefined) {
filtered = filtered.slice(0, this.limitValue);
}
return { data: filtered, error: null };
}
}
// Mock insert/update/delete builders
class MockMutationBuilder {
private tableName: string;
private data: Record<string, unknown> | Record<string, unknown>[];
constructor(tableName: string, data: Record<string, unknown> | Record<string, unknown>[]) {
this.tableName = tableName;
this.data = data;
}
select() {
return new MockQueryBuilder(this.tableName);
}
then(resolve: (value: unknown) => void) {
const items = Array.isArray(this.data) ? this.data : [this.data];
const returning = items.map((item, i) => ({
...item,
id: item.id || `generated-${Date.now()}-${i}`,
}));
resolve({ data: returning, error: null });
}
}
// Mock storage builder
class MockStorageBuilder {
from(bucket: string) {
return {
upload: async (path: string, _file: unknown) => {
return { data: { path }, error: null };
},
download: async (path: string) => {
return { data: new Blob(), error: null };
},
remove: async (paths: string[]) => {
return { data: { paths }, error: null };
},
list: async () => {
return { data: [], error: null };
},
};
}
}
// Create mock client
function createMockClient() {
return {
from: (table: string) => new MockQueryBuilder(table),
insert: (data: Record<string, unknown> | Record<string, unknown>[]) => new MockMutationBuilder("unknown", data),
update: (data: Record<string, unknown>) => new MockMutationBuilder("unknown", data),
delete: () => ({
eq: () => ({ then: (resolve: (value: unknown) => void) => resolve({ data: null, error: null }) }),
in: () => ({ then: (resolve: (value: unknown) => void) => resolve({ data: null, error: null }) }),
}),
storage: new MockStorageBuilder(),
auth: {
getSession: async () => ({ data: { session: null }, error: null }),
getUser: async () => ({ data: { user: null }, error: null }),
signInWithPassword: async () => ({ data: { user: null, session: null }, error: null }),
signOut: async () => ({ error: null }),
onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }),
},
channel: () => ({
on: () => ({ subscribe: () => ({}) }),
subscribe: () => ({}),
}),
};
}
// Real Supabase client creation
function getSupabase(): SupabaseClient {
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error(
`Missing Supabase env vars: ${[!supabaseUrl && "NEXT_PUBLIC_SUPABASE_URL", !supabaseAnonKey && "NEXT_PUBLIC_SUPABASE_ANON_KEY"].filter(Boolean).join(", ")}. ` +
"Check Vercel environment variables for Production environment. " +
"Node env: " + (process.env.NODE_ENV ?? "unknown")
);
}
return createClient(supabaseUrl, supabaseAnonKey);
}
// Create proxy that routes to real or mock client
let realSupabase: SupabaseClient | null = null;
if (!useMockData) {
try {
realSupabase = getSupabase();
} catch {
// Will use mock below
}
}
export const supabase: SupabaseClient = useMockData || !realSupabase
? createMockClient() as unknown as SupabaseClient
: realSupabase;
export { supabaseUrl, supabaseAnonKey, useMockData };
+104
View File
@@ -0,0 +1,104 @@
/**
* Service-role fetch wrapper. Server-side only (uses `server-only`).
*
* Provides a single entry point for server actions that need full PostgREST
* access (bypassing the anon key's RLS). Centralizes the URL + header pattern
* so the 67 files that previously used `svcHeaders(POSTGREST_SERVICE_KEY)`
* have one source of truth.
*
* `apikey` header alone is sufficient for PostgREST — it accepts it for both
* anon and service-role access (no `Authorization: Bearer` needed). Safe for
* Vercel Edge Runtime.
*
* Usage:
* import { svcFetch, svcHeaders } from "@/lib/svc-fetch";
* const res = await svcFetch("/rest/v1/brands?select=*");
* const data = await res.json();
*
* For RPC calls that need to bypass RLS, use `api.rpc` with the service key
* passed via headers — see `svcRpc<T>(fn, args)` below.
*/
import "server-only";
import { PostgrestBuilder } from "@/lib/api";
import type { RowOf } from "@/lib/db-types";
const API_URL = (() => {
const url = process.env.NEXT_PUBLIC_API_URL;
if (!url) {
throw new Error("Missing NEXT_PUBLIC_API_URL (server-side). Set it in .env.local.");
}
return url.replace(/\/$/, "");
})();
const SERVICE_KEY = (() => {
const k = process.env.POSTGREST_SERVICE_KEY;
if (!k) {
throw new Error(
"Missing POSTGREST_SERVICE_KEY. Set it in .env.local — it's the service-role key for the local PostgREST proxy.",
);
}
return k;
})();
export function svcHeaders(): Record<string, string> {
return { apikey: SERVICE_KEY, "Content-Type": "application/json" };
}
export async function svcFetch(path: string, init: RequestInit = {}): Promise<Response> {
const url = path.startsWith("http") ? path : `${API_URL}${path.startsWith("/") ? "" : "/"}${path}`;
const headers = { ...svcHeaders(), ...(init.headers as Record<string, string> | undefined) };
return fetch(url, { ...init, headers });
}
export type SvcResult<T> = { data: T; error: null } | { data: null; error: { message: string } };
/** Service-role RPC call. Bypasses RLS, useful for admin server actions. */
export async function svcRpc<TReturns = unknown>(
fn: string,
args: Record<string, unknown> = {},
): Promise<SvcResult<TReturns>> {
let res: Response;
try {
res = await svcFetch(`/rest/v1/rpc/${fn}`, {
method: "POST",
body: JSON.stringify(args),
});
} catch (e) {
return { data: null, error: { message: (e as Error).message } };
}
if (!res.ok) {
const text = await res.text().catch(() => "");
return { data: null, error: { message: text || res.statusText } };
}
const data = (await res.json()) as TReturns;
return { data, error: null };
}
/** Re-export the API URL for any code that still needs to build URLs by hand. */
export const SVC_API_URL = API_URL;
// ── Service-role PostgREST client ────────────────────────────────────────────
//
// Same builder shape as `api` (the anon-key client in `@/lib/api`) but uses
// the service-role key. Use this for server actions that need to bypass RLS
// (admin user management, writing to `admin_users`, etc.).
//
// Usage:
// import { svcApi } from "@/lib/svc-fetch";
// const { data, error } = await svcApi.from("admin_users").select("*");
// const { data, error } = await svcApi.from("admin_users").insert({...}).select().single();
class SvcPostgrestBuilder<TRow, TResult = TRow[]> extends PostgrestBuilder<TRow, TResult> {
constructor(table: string) {
super(table);
this.getHeaders = () => svcHeaders();
}
}
export const svcApi = {
from<TTable extends string>(table: TTable): SvcPostgrestBuilder<RowOf<TTable>> {
return new SvcPostgrestBuilder<RowOf<TTable>>(table);
},
rpc: svcRpc,
};