feat(selfhost): complete Supabase removal migration
Deploy to route.crispygoat.com / deploy (push) Failing after 9s
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:
@@ -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
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] || [];
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user