feat(storage): MinIO object storage, Neon Auth, Supabase removal
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
This commit is contained in:
openclaw
2026-06-09 11:32:17 -06:00
parent bb464bda65
commit c434015829
348 changed files with 6616 additions and 3096 deletions
+37 -45
View File
@@ -4,7 +4,7 @@ import { and, eq, ilike, or, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { parseCSVWithLimits } from "@/lib/csv-parser";
import { buildImportPreview } from "@/lib/column-detector";
import { withTenant, withPlatformAdmin } from "@/db/client";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { customers } from "@/db/schema";
export type ContactSource = "order" | "import" | "manual" | "admin";
@@ -19,7 +19,7 @@ export type ContactSource = "order" | "import" | "manual" | "admin";
*/
export type Contact = {
id: string;
tenant_id: string;
brand_id: string;
email: string | null;
phone: string | null;
full_name: string;
@@ -101,23 +101,9 @@ export type GetContactsResult = {
};
function rowToContact(row: typeof customers.$inferSelect): Contact {
// Derive first/last name from the stored single `name` column.
// Multi-word names split on the first whitespace; single-word names
// populate first_name and leave last_name null.
const fullName = row.name ?? "";
const trimmed = fullName.trim();
let firstName: string | null = null;
let lastName: string | null = null;
if (trimmed) {
const idx = trimmed.indexOf(" ");
if (idx === -1) {
firstName = trimmed;
} else {
firstName = trimmed.slice(0, idx);
const rest = trimmed.slice(idx + 1).trim();
lastName = rest.length > 0 ? rest : null;
}
}
const firstName = row.firstName;
const lastName = row.lastName;
const fullName = [firstName, lastName].filter(Boolean).join(" ") || "";
// Derive unsubscribed_at: the new schema only has opt-in flags, not
// a timestamp. Mark the unsubscribe moment as updatedAt if opted out
@@ -126,10 +112,10 @@ function rowToContact(row: typeof customers.$inferSelect): Contact {
return {
id: row.id,
tenant_id: row.tenantId,
brand_id: row.brandId,
email: row.email,
phone: row.phone,
full_name: row.name,
full_name: row.fullName ?? "",
first_name: firstName,
last_name: lastName,
source: "manual",
@@ -162,13 +148,13 @@ export async function getContacts(params: {
const search = params.search?.trim() ?? "";
try {
const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
const conds: SQL[] = [eq(customers.brandId, params.brandId)];
if (search) {
const like = `%${search}%`;
conds.push(or(ilike(customers.name, like), ilike(customers.email, like))!);
conds.push(or(ilike(customers.firstName, like), ilike(customers.email, like))!);
}
const rows = await withTenant(params.brandId, async (db) => {
const rows = await withBrand(params.brandId, async (db) => {
const [items, countRows] = await Promise.all([
db
.select()
@@ -233,7 +219,7 @@ export async function upsertContact(contact: {
return { success: false, error: "Not authorized for this brand" };
}
const name =
const fullName =
contact.full_name?.trim() ||
[contact.first_name, contact.last_name].filter(Boolean).join(" ").trim() ||
contact.email ||
@@ -243,18 +229,20 @@ export async function upsertContact(contact: {
try {
if (contact.id) {
const contactId = contact.id;
const updated = await withTenant(contact.brand_id, (db) =>
const updated = await withBrand(contact.brand_id, (db) =>
db
.update(customers)
.set({
name,
fullName,
email: contact.email ?? null,
phone: contact.phone ?? null,
firstName: contact.first_name ?? null,
lastName: contact.last_name ?? null,
smsOptIn: contact.sms_opt_in ?? false,
emailOptIn: contact.email_opt_in ?? true,
updatedAt: new Date(),
})
.where(and(eq(customers.id, contactId), eq(customers.tenantId, contact.brand_id)))
.where(and(eq(customers.id, contactId), eq(customers.brandId, contact.brand_id)))
.returning(),
);
const row = updated[0];
@@ -262,23 +250,25 @@ export async function upsertContact(contact: {
return { success: true, contact: rowToContact(row) };
}
// INSERT — de-dupe on (tenant_id, email) when email is provided
// INSERT — de-dupe on (brand_id, email) when email is provided
const contactEmail = contact.email;
if (contactEmail) {
const existing = await withTenant(contact.brand_id, (db) =>
const existing = await withBrand(contact.brand_id, (db) =>
db
.select()
.from(customers)
.where(and(eq(customers.email, contactEmail), eq(customers.tenantId, contact.brand_id)))
.where(and(eq(customers.email, contactEmail), eq(customers.brandId, contact.brand_id)))
.limit(1),
);
if (existing[0]) {
const updated = await withTenant(contact.brand_id, (db) =>
const updated = await withBrand(contact.brand_id, (db) =>
db
.update(customers)
.set({
name,
fullName,
phone: contact.phone ?? null,
firstName: contact.first_name ?? null,
lastName: contact.last_name ?? null,
smsOptIn: contact.sms_opt_in ?? existing[0].smsOptIn,
emailOptIn: contact.email_opt_in ?? existing[0].emailOptIn,
updatedAt: new Date(),
@@ -292,14 +282,16 @@ export async function upsertContact(contact: {
}
}
const inserted = await withTenant(contact.brand_id, (db) =>
const inserted = await withBrand(contact.brand_id, (db) =>
db
.insert(customers)
.values({
tenantId: contact.brand_id,
name,
brandId: contact.brand_id,
fullName,
email: contact.email ?? null,
phone: contact.phone ?? null,
firstName: contact.first_name ?? null,
lastName: contact.last_name ?? null,
smsOptIn: contact.sms_opt_in ?? false,
emailOptIn: contact.email_opt_in ?? true,
})
@@ -328,7 +320,7 @@ export type ImportContactsResult = {
* The legacy `import_communication_contacts_batch` RPC is replaced with
* an in-process batch: parse → dedupe → upsert per row, returning the
* same ImportResult shape. This avoids a round-trip to the DB for each
* row and keeps the call inside the `withTenant` transaction.
* row and keeps the call inside the `withBrand` transaction.
*/
export async function importContactsBatch(params: {
brandId: string;
@@ -403,14 +395,14 @@ export async function optOutContact(params: {
}
try {
await withTenant(params.brandId, (db) =>
await withBrand(params.brandId, (db) =>
db
.update(customers)
.set({
...(params.method === "email" ? { emailOptIn: false } : { smsOptIn: false }),
updatedAt: new Date(),
})
.where(and(eq(customers.email, params.email), eq(customers.tenantId, params.brandId))),
.where(and(eq(customers.email, params.email), eq(customers.brandId, params.brandId))),
);
return { success: true };
} catch (err) {
@@ -434,10 +426,10 @@ export async function deleteContact(id: string, brandId?: string): Promise<{ suc
try {
if (brandId) {
await withTenant(brandId, (db) =>
await withBrand(brandId, (db) =>
db
.delete(customers)
.where(and(eq(customers.id, id), eq(customers.tenantId, brandId))),
.where(and(eq(customers.id, id), eq(customers.brandId, brandId))),
);
} else {
// platform_admin fallback — by id only
@@ -492,20 +484,20 @@ export async function exportContacts(params: {
try {
while (true) {
const rows = await withTenant(effectiveBrandId, (db) =>
const rows = await withBrand(effectiveBrandId, (db) =>
db
.select()
.from(customers)
.where(
params.search
? and(
eq(customers.tenantId, effectiveBrandId),
eq(customers.brandId, effectiveBrandId),
or(
ilike(customers.name, `%${params.search}%`),
ilike(customers.firstName, `%${params.search}%`),
ilike(customers.email, `%${params.search}%`),
),
)
: eq(customers.tenantId, effectiveBrandId),
: eq(customers.brandId, effectiveBrandId),
)
.limit(batchSize)
.offset(offset)