feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s

- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject,
  deleteObject, presigned URL helpers, and BUCKETS constant
- Wire product images, brand logos, and water log photos to MinIO via the
  new storage client
- Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call)
- Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge
  Function proxy)
- Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass
  brand_settings.logo_url from all call sites
- Update email templates to use dynamic logoUrl instead of hardcoded Supabase
  bucket URLs
- Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage,
  TimeTrackingFieldClient; use brand_settings props + local public/ fallback
- Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to
  public/ for local development
- Add MinIO env vars to .env.example (endpoint, access key, secret, buckets)
- Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props
- Fix admin/users.ts logoUrl type (null → undefined for optional string)
- Remove stale sb- cookie from wholesale-auth
- Migrate tuxedo/about page to remove supabase import and use pool query for
  wholesale_settings lookup
This commit is contained in:
openclaw
2026-06-09 11:32:17 -06:00
parent bb464bda65
commit 916ad39176
349 changed files with 6706 additions and 3343 deletions
+32 -9
View File
@@ -3,6 +3,29 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
// Stripe API version type
type StripeApiVersion = "2026-05-27.dahlia";
// Type for plan info response
type PlanInfo = {
plan_tier: string;
plan_name: string | null;
max_users: number;
max_stops_monthly: number;
max_products: number;
usage: { users: number; stops_this_month: number; products: number } | null;
};
// Type for wholesale order
type WholesaleOrder = {
id: string;
created_at: Date;
customer_name: string;
total: number;
status: string;
[key: string]: unknown;
};
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
@@ -15,7 +38,7 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
// Get stripe_customer_id from tenants table
const custRes = await pool.query<{ stripe_customer_id: string | null }>(
"SELECT stripe_customer_id FROM tenants WHERE id = $1 LIMIT 1",
"SELECT stripe_customer_id FROM brands WHERE id = $1 LIMIT 1",
[brandId]
);
const stripeCustomerId = custRes.rows[0]?.stripe_customer_id;
@@ -25,7 +48,7 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
@@ -74,7 +97,7 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
}
}
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> {
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: PlanInfo; error?: string }> {
// Replicate get_brand_plan_info via a JOIN on tenants + plans
const res = await pool.query<{
plan_tier: string;
@@ -91,14 +114,14 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'users', (SELECT count(*)::int FROM admin_user_brands tu WHERE tu.brand_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
WHERE s.brand_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
WHERE p.brand_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
FROM brands t
WHERE t.id = $1`,
[brandId]
);
@@ -111,7 +134,7 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
// get_brand_features returns JSONB — a single object, not an array
const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
"SELECT feature_flags FROM brand_settings WHERE tenant_id = $1 LIMIT 1",
"SELECT feature_flags FROM brand_settings WHERE brand_id = $1 LIMIT 1",
[brandId]
);
const flags = res.rows[0]?.feature_flags ?? {};
@@ -123,7 +146,7 @@ export async function getEnabledAddons(brandId: string): Promise<Record<string,
return out;
}
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<WholesaleOrder[]> {
try {
const res = await pool.query(
"SELECT * FROM get_wholesale_orders($1)",