c434015829
Deploy to route.crispygoat.com / deploy (push) Failing after 17s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
136 lines
4.1 KiB
TypeScript
136 lines
4.1 KiB
TypeScript
/**
|
|
* Drizzle client + brand-scoped query helper.
|
|
*
|
|
* The app connects to Postgres directly via the `pg` driver. Drizzle sits
|
|
* on top, providing typed queries. The `withBrand` wrapper is the only
|
|
* sanctioned way to run a brand-scoped query — it sets the
|
|
* `app.current_brand_id` GUC transaction-locally, and the database's
|
|
* RLS policies enforce brand isolation even if application code forgets
|
|
* a `WHERE brand_id = $1`.
|
|
*
|
|
* Usage (read):
|
|
* const products = await withBrand(brandId, (db) =>
|
|
* db.select().from(productsTable).where(eq(productsTable.active, true)),
|
|
* );
|
|
*
|
|
* Usage (platform admin — sees all brands):
|
|
* const allBrands = await withPlatformAdmin((db) =>
|
|
* db.select().from(brandsTable),
|
|
* );
|
|
*
|
|
* Usage (no brand — for the rare case the query isn't brand-scoped):
|
|
* const plans = await withDb((db) => db.select().from(plansTable));
|
|
*/
|
|
|
|
import "server-only";
|
|
import { Pool, type PoolClient } from "pg";
|
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
import * as schema from "./schema";
|
|
|
|
type Schema = typeof schema;
|
|
export type Db = NodePgDatabase<Schema>;
|
|
|
|
let _pool: Pool | null = null;
|
|
|
|
function getPool(): Pool {
|
|
if (_pool) return _pool;
|
|
const connectionString = process.env.DATABASE_URL;
|
|
if (!connectionString) {
|
|
throw new Error(
|
|
"DATABASE_URL is not set. Add it to .env.local (see .env.example).",
|
|
);
|
|
}
|
|
_pool = new Pool({
|
|
connectionString,
|
|
max: parseInt(process.env.PG_POOL_MAX ?? "10", 10),
|
|
idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10),
|
|
connectionTimeoutMillis: parseInt(
|
|
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "10000",
|
|
10,
|
|
),
|
|
allowExitOnIdle: false,
|
|
});
|
|
_pool.on("error", (err) => {
|
|
console.error("[db] idle client error", err);
|
|
});
|
|
return _pool;
|
|
}
|
|
|
|
/**
|
|
* Run `fn` with a Drizzle client. No brand context is set — the caller
|
|
* is responsible for RLS bypass (e.g. for the `plans` and `add_ons` tables,
|
|
* which are not brand-scoped). For brand-scoped reads, prefer
|
|
* `withBrand` or `withPlatformAdmin`.
|
|
*/
|
|
export async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
|
|
const client = await getPool().connect();
|
|
try {
|
|
const db = drizzle(client, { schema });
|
|
return await fn(db);
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run `fn` inside a transaction with the current brand id set as a
|
|
* transaction-local GUC. RLS policies on brand-scoped tables will allow
|
|
* reads/writes only for rows where `brand_id` matches. Pass `null` to
|
|
* fail open (don't set the GUC) — only useful for the migrations
|
|
* themselves, never for app code.
|
|
*/
|
|
export async function withBrand<T>(
|
|
brandId: string,
|
|
fn: (db: Db) => Promise<T>,
|
|
): Promise<T> {
|
|
return runInTransaction(async (client) => {
|
|
// set_config(setting, value, is_local) — is_local=true makes it
|
|
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
|
|
// leaks across pooled connections.
|
|
await client.query("SELECT set_config('app.current_brand_id', $1, true)", [
|
|
brandId,
|
|
]);
|
|
await client.query("SELECT set_config('app.platform_admin', 'false', true)");
|
|
const db = drizzle(client, { schema });
|
|
return fn(db);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run `fn` as platform admin. RLS policies permit access to all brands.
|
|
* Use sparingly — typically only in the /admin/platform routes.
|
|
*/
|
|
export async function withPlatformAdmin<T>(
|
|
fn: (db: Db) => Promise<T>,
|
|
): Promise<T> {
|
|
return runInTransaction(async (client) => {
|
|
await client.query("SELECT set_config('app.current_brand_id', '', true)");
|
|
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
|
|
const db = drizzle(client, { schema });
|
|
return fn(db);
|
|
});
|
|
}
|
|
|
|
async function runInTransaction<T>(
|
|
fn: (client: PoolClient) => Promise<T>,
|
|
): Promise<T> {
|
|
const client = await getPool().connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
const result = await fn(client);
|
|
await client.query("COMMIT");
|
|
return result;
|
|
} catch (err) {
|
|
try {
|
|
await client.query("ROLLBACK");
|
|
} catch {
|
|
// ignore secondary rollback failure
|
|
}
|
|
throw err;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
export { schema };
|