feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
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:
@@ -3,7 +3,7 @@
|
||||
import { and, desc, eq, SQL } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import { campaigns, emailTemplates } from "@/db/schema";
|
||||
|
||||
export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
@@ -83,7 +83,7 @@ function stripHtml(html: string | null): string {
|
||||
function rowToCampaign(c: CampaignRow, t?: TemplateRow | null): Campaign {
|
||||
return {
|
||||
id: c.id,
|
||||
brand_id: c.tenantId,
|
||||
brand_id: c.brandId,
|
||||
name: c.name,
|
||||
subject: t?.subject ?? null,
|
||||
body_text: stripHtml(t?.bodyHtml ?? null),
|
||||
@@ -111,7 +111,7 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
|
||||
|
||||
try {
|
||||
const rows = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
? await withBrand(activeBrandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
campaign: campaigns,
|
||||
@@ -178,7 +178,7 @@ export async function upsertCampaign(params: {
|
||||
const subject = params.subject ?? "";
|
||||
const bodyHtml = params.body_html ?? (params.body_text ? `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>` : "<p></p>");
|
||||
|
||||
const { row, template } = await withTenant(params.brand_id, async (db) => {
|
||||
const { row, template } = await withBrand(params.brand_id, async (db) => {
|
||||
// Resolve / create the linked email_templates row.
|
||||
let templateId: string | null = params.template_id ?? null;
|
||||
let templateRow: TemplateRow | null = null;
|
||||
@@ -187,7 +187,7 @@ export async function upsertCampaign(params: {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(and(eq(emailTemplates.id, templateId), eq(emailTemplates.tenantId, params.brand_id)))
|
||||
.where(and(eq(emailTemplates.id, templateId), eq(emailTemplates.brandId, params.brand_id)))
|
||||
.limit(1);
|
||||
if (existing[0]) {
|
||||
const updated = await db
|
||||
@@ -207,7 +207,7 @@ export async function upsertCampaign(params: {
|
||||
const inserted = await db
|
||||
.insert(emailTemplates)
|
||||
.values({
|
||||
tenantId: params.brand_id,
|
||||
brandId: params.brand_id,
|
||||
name: params.name,
|
||||
subject,
|
||||
bodyHtml,
|
||||
@@ -229,7 +229,7 @@ export async function upsertCampaign(params: {
|
||||
scheduledFor: scheduled,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(campaigns.id, params.id), eq(campaigns.tenantId, params.brand_id)))
|
||||
.where(and(eq(campaigns.id, params.id), eq(campaigns.brandId, params.brand_id)))
|
||||
.returning();
|
||||
if (!updated[0]) {
|
||||
return { row: null, template: null };
|
||||
@@ -239,7 +239,7 @@ export async function upsertCampaign(params: {
|
||||
const inserted = await db
|
||||
.insert(campaigns)
|
||||
.values({
|
||||
tenantId: params.brand_id,
|
||||
brandId: params.brand_id,
|
||||
name: params.name,
|
||||
templateId,
|
||||
status,
|
||||
@@ -274,8 +274,8 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
|
||||
|
||||
try {
|
||||
if (activeBrandId) {
|
||||
await withTenant(activeBrandId, (db) =>
|
||||
db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId))),
|
||||
await withBrand(activeBrandId, (db) =>
|
||||
db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.brandId, activeBrandId))),
|
||||
);
|
||||
} else {
|
||||
await withPlatformAdmin((db) => db.delete(campaigns).where(eq(campaigns.id, campaignId)));
|
||||
@@ -297,12 +297,12 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
|
||||
|
||||
try {
|
||||
const result = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
? await withBrand(activeBrandId, (db) =>
|
||||
db
|
||||
.select({ campaign: campaigns, template: emailTemplates })
|
||||
.from(campaigns)
|
||||
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
|
||||
.where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId)))
|
||||
.where(and(eq(campaigns.id, campaignId), eq(campaigns.brandId, activeBrandId)))
|
||||
.limit(1)
|
||||
.then((r) => r[0] ?? null),
|
||||
)
|
||||
@@ -318,7 +318,7 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (adminUser.role === "brand_admin" && result.campaign.tenantId !== adminUser.brand_id) {
|
||||
if (adminUser.role === "brand_admin" && result.campaign.brandId !== adminUser.brand_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import { files } from "@/db/schema";
|
||||
import {
|
||||
importContactsBatch,
|
||||
@@ -45,11 +45,11 @@ export async function uploadContactsToBucket(
|
||||
const storageKey = `imports/${brandId}/${timestamp}-${safeName}`;
|
||||
|
||||
try {
|
||||
const [row] = await withTenant(brandId, (db) =>
|
||||
const [row] = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.insert(files)
|
||||
.values({
|
||||
tenantId: brandId,
|
||||
brandId: brandId,
|
||||
storageKey,
|
||||
mimeType: "text/csv",
|
||||
sizeBytes: file.size,
|
||||
@@ -130,7 +130,7 @@ export async function listImportHistory(
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
storageKey: files.storageKey,
|
||||
@@ -138,7 +138,7 @@ export async function listImportHistory(
|
||||
createdAt: files.createdAt,
|
||||
})
|
||||
.from(files)
|
||||
.where(and(eq(files.tenantId, brandId), eq(files.purpose, "contact_import")))
|
||||
.where(and(eq(files.brandId, brandId), eq(files.purpose, "contact_import")))
|
||||
.orderBy(desc(files.createdAt))
|
||||
.limit(limit),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { brandSettings } from "@/db/schema";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
@@ -56,22 +56,22 @@ function isSegment(v: unknown): v is Segment {
|
||||
}
|
||||
|
||||
async function loadSegments(brandId: string): Promise<Segment[]> {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.where(eq(brandSettings.brandId, brandId))
|
||||
.limit(1),
|
||||
);
|
||||
return readSegments((rows[0]?.flags ?? null) as Record<string, unknown> | null);
|
||||
}
|
||||
|
||||
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
|
||||
await withTenant(brandId, async (db) => {
|
||||
await withBrand(brandId, async (db) => {
|
||||
const existing = await db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.where(eq(brandSettings.brandId, brandId))
|
||||
.limit(1);
|
||||
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
||||
const nextFlags: Record<string, unknown> = {
|
||||
@@ -81,7 +81,7 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise<void>
|
||||
await db
|
||||
.update(brandSettings)
|
||||
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
||||
.where(eq(brandSettings.tenantId, brandId));
|
||||
.where(eq(brandSettings.brandId, brandId));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { and, eq, sql, SQL } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import { campaigns, customers } from "@/db/schema";
|
||||
import type { AudienceRules } from "./campaigns";
|
||||
|
||||
export type AudiencePreviewResult = {
|
||||
count: number;
|
||||
sample_customers: { id: string; email: string; name: string }[];
|
||||
sample_customers: { id: string; email: string; fullName: string | null }[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,23 +36,23 @@ export async function previewCampaignAudience(
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
id: customers.id,
|
||||
email: customers.email,
|
||||
name: customers.name,
|
||||
fullName: customers.fullName,
|
||||
})
|
||||
.from(customers)
|
||||
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)))
|
||||
.where(and(eq(customers.brandId, brandId), eq(customers.emailOptIn, true)))
|
||||
.limit(5),
|
||||
);
|
||||
|
||||
const countRows = await withTenant(brandId, (db) =>
|
||||
const countRows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({ value: sql<number>`count(*)::int` })
|
||||
.from(customers)
|
||||
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true))),
|
||||
.where(and(eq(customers.brandId, brandId), eq(customers.emailOptIn, true))),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -60,7 +60,7 @@ export async function previewCampaignAudience(
|
||||
sample_customers: rows.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email ?? "",
|
||||
name: r.name,
|
||||
fullName: r.fullName,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
@@ -153,11 +153,11 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
|
||||
}
|
||||
|
||||
const conds: SQL[] = [eq(campaigns.id, campaignId)];
|
||||
if (activeBrandId) conds.push(eq(campaigns.tenantId, activeBrandId));
|
||||
if (activeBrandId) conds.push(eq(campaigns.brandId, activeBrandId));
|
||||
|
||||
try {
|
||||
const updated = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
? await withBrand(activeBrandId, (db) =>
|
||||
db
|
||||
.update(campaigns)
|
||||
.set({ status: "sent", sentAt: new Date() })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { brandSettings } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
@@ -47,15 +47,15 @@ function readFlag(
|
||||
|
||||
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
|
||||
try {
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
const rows = await withBrand(brandId, (db) =>
|
||||
db
|
||||
.select({
|
||||
tenantId: brandSettings.tenantId,
|
||||
brandId: brandSettings.brandId,
|
||||
featureFlags: brandSettings.featureFlags,
|
||||
updatedAt: brandSettings.updatedAt,
|
||||
})
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, brandId))
|
||||
.where(eq(brandSettings.brandId, brandId))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
@@ -65,8 +65,8 @@ export async function getCommunicationSettings(brandId: string): Promise<Communi
|
||||
const flags = (row.featureFlags ?? {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
id: row.tenantId,
|
||||
brand_id: row.tenantId,
|
||||
id: row.brandId,
|
||||
brand_id: row.brandId,
|
||||
default_sender_email: readFlag(flags, "comm_default_sender_email"),
|
||||
default_sender_name: readFlag(flags, "comm_default_sender_name"),
|
||||
reply_to_email: readFlag(flags, "comm_reply_to_email"),
|
||||
@@ -96,11 +96,11 @@ export async function upsertCommunicationSettings(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await withTenant(params.brand_id, async (db) => {
|
||||
const updated = await withBrand(params.brand_id, async (db) => {
|
||||
const existing = await db
|
||||
.select({ flags: brandSettings.featureFlags })
|
||||
.from(brandSettings)
|
||||
.where(eq(brandSettings.tenantId, params.brand_id))
|
||||
.where(eq(brandSettings.brandId, params.brand_id))
|
||||
.limit(1);
|
||||
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
|
||||
const nextFlags: Record<string, unknown> = {
|
||||
@@ -115,9 +115,9 @@ export async function upsertCommunicationSettings(params: {
|
||||
const result = await db
|
||||
.update(brandSettings)
|
||||
.set({ featureFlags: nextFlags, updatedAt: new Date() })
|
||||
.where(eq(brandSettings.tenantId, params.brand_id))
|
||||
.where(eq(brandSettings.brandId, params.brand_id))
|
||||
.returning({
|
||||
tenantId: brandSettings.tenantId,
|
||||
brandId: brandSettings.brandId,
|
||||
updatedAt: brandSettings.updatedAt,
|
||||
});
|
||||
return result[0] ?? null;
|
||||
@@ -128,8 +128,8 @@ export async function upsertCommunicationSettings(params: {
|
||||
}
|
||||
|
||||
const settings: CommunicationSettings = {
|
||||
id: updated.tenantId,
|
||||
brand_id: updated.tenantId,
|
||||
id: updated.brandId,
|
||||
brand_id: updated.brandId,
|
||||
default_sender_email: params.sender_email ?? null,
|
||||
default_sender_name: params.sender_name ?? null,
|
||||
reply_to_email: params.reply_to_email ?? null,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { and, eq, sql, SQL } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { customers, orders, campaigns, emailTemplates } from "@/db/schema";
|
||||
|
||||
export type StopBlastResult =
|
||||
@@ -37,8 +37,8 @@ export async function sendStopBlast(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
|
||||
const recipientRows = await withTenant(params.brandId, async (db) => {
|
||||
const conds: SQL[] = [eq(customers.brandId, params.brandId)];
|
||||
const recipientRows = await withBrand(params.brandId, async (db) => {
|
||||
// Distinct customers from the tenant's recent orders.
|
||||
const orderCustomers = await db
|
||||
.selectDistinct({ id: customers.id })
|
||||
@@ -46,7 +46,7 @@ export async function sendStopBlast(params: {
|
||||
.innerJoin(orders, eq(orders.customerId, customers.id))
|
||||
.where(
|
||||
and(
|
||||
eq(orders.tenantId, params.brandId),
|
||||
eq(orders.brandId, params.brandId),
|
||||
params.channel === "sms"
|
||||
? eq(customers.smsOptIn, true)
|
||||
: params.channel === "email"
|
||||
@@ -81,11 +81,11 @@ export async function sendStopBlast(params: {
|
||||
// Persist a draft campaign for traceability. No template link — the
|
||||
// stop-blast content is supplied inline and not yet modeled.
|
||||
const bodyHtml = `<p style="white-space:pre-wrap">${escapeHtml(params.body)}</p>`;
|
||||
const inserted = await withTenant(params.brandId, async (db) => {
|
||||
const inserted = await withBrand(params.brandId, async (db) => {
|
||||
const template = await db
|
||||
.insert(emailTemplates)
|
||||
.values({
|
||||
tenantId: params.brandId,
|
||||
brandId: params.brandId,
|
||||
name: `Stop blast ${new Date().toISOString()}`,
|
||||
subject: params.subject ?? "Pickup update",
|
||||
bodyHtml,
|
||||
@@ -96,7 +96,7 @@ export async function sendStopBlast(params: {
|
||||
const campaign = await db
|
||||
.insert(campaigns)
|
||||
.values({
|
||||
tenantId: params.brandId,
|
||||
brandId: params.brandId,
|
||||
name: `Stop blast ${new Date().toISOString()}`,
|
||||
templateId: tplId,
|
||||
status: "sent",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { and, desc, eq, isNotNull } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { withBrand } from "@/db/client";
|
||||
import { customers, orders, campaigns } from "@/db/schema";
|
||||
|
||||
/**
|
||||
@@ -57,13 +57,13 @@ export async function getStopMessagingData(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const [orderRows, campaignRows] = await withTenant(brandId, async (db) => {
|
||||
const [orderRows, campaignRows] = await withBrand(brandId, async (db) => {
|
||||
const o = await db
|
||||
.select({
|
||||
id: orders.id,
|
||||
status: orders.status,
|
||||
placedAt: orders.placedAt,
|
||||
customerName: customers.name,
|
||||
customerName: customers.fullName,
|
||||
customerEmail: customers.email,
|
||||
customerPhone: customers.phone,
|
||||
})
|
||||
@@ -71,7 +71,7 @@ export async function getStopMessagingData(params: {
|
||||
.innerJoin(customers, eq(customers.id, orders.customerId))
|
||||
.where(
|
||||
and(
|
||||
eq(orders.tenantId, brandId),
|
||||
eq(orders.brandId, brandId),
|
||||
isNotNull(customers.email),
|
||||
),
|
||||
)
|
||||
@@ -86,7 +86,7 @@ export async function getStopMessagingData(params: {
|
||||
updatedAt: campaigns.updatedAt,
|
||||
})
|
||||
.from(campaigns)
|
||||
.where(eq(campaigns.tenantId, brandId))
|
||||
.where(eq(campaigns.brandId, brandId))
|
||||
.orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt))
|
||||
.limit(10);
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function getStopMessagingData(params: {
|
||||
// schema; treat "fulfilled" status as picked up).
|
||||
const mappedOrders: StopOrder[] = orderRows.map((o) => ({
|
||||
id: o.id,
|
||||
customer_name: o.customerName,
|
||||
customer_name: o.customerName ?? "",
|
||||
customer_email: o.customerEmail,
|
||||
customer_phone: o.customerPhone,
|
||||
pickup_complete: o.status === "fulfilled",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { withTenant, withPlatformAdmin } from "@/db/client";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import { emailTemplates } from "@/db/schema";
|
||||
|
||||
export type TemplateType = "email_template" | "sms_template" | "internal_note";
|
||||
@@ -20,7 +20,7 @@ export type CampaignType = "marketing" | "operational" | "transactional";
|
||||
*/
|
||||
export type Template = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
body_text: string;
|
||||
@@ -42,7 +42,7 @@ export type UpsertTemplateResult = {
|
||||
function rowToTemplate(row: typeof emailTemplates.$inferSelect): Template {
|
||||
return {
|
||||
id: row.id,
|
||||
tenant_id: row.tenantId,
|
||||
brand_id: row.brandId,
|
||||
name: row.name,
|
||||
subject: row.subject,
|
||||
body_text: stripHtml(row.bodyHtml),
|
||||
@@ -79,7 +79,7 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
|
||||
|
||||
try {
|
||||
const rows = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
? await withBrand(activeBrandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
@@ -121,7 +121,7 @@ export async function upsertTemplate(params: {
|
||||
|
||||
try {
|
||||
const row = params.id
|
||||
? await withTenant(params.brand_id, async (db) => {
|
||||
? await withBrand(params.brand_id, async (db) => {
|
||||
const updated = await db
|
||||
.update(emailTemplates)
|
||||
.set({
|
||||
@@ -133,17 +133,17 @@ export async function upsertTemplate(params: {
|
||||
.where(
|
||||
and(
|
||||
eq(emailTemplates.id, params.id!),
|
||||
eq(emailTemplates.tenantId, params.brand_id),
|
||||
eq(emailTemplates.brandId, params.brand_id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return updated[0] ?? null;
|
||||
})
|
||||
: await withTenant(params.brand_id, async (db) => {
|
||||
: await withBrand(params.brand_id, async (db) => {
|
||||
const inserted = await db
|
||||
.insert(emailTemplates)
|
||||
.values({
|
||||
tenantId: params.brand_id,
|
||||
brandId: params.brand_id,
|
||||
name: params.name,
|
||||
subject: params.subject,
|
||||
bodyHtml,
|
||||
@@ -170,14 +170,14 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
|
||||
|
||||
try {
|
||||
const row = activeBrandId
|
||||
? await withTenant(activeBrandId, (db) =>
|
||||
? await withBrand(activeBrandId, (db) =>
|
||||
db
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(
|
||||
and(
|
||||
eq(emailTemplates.id, templateId),
|
||||
eq(emailTemplates.tenantId, activeBrandId),
|
||||
eq(emailTemplates.brandId, activeBrandId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
@@ -194,7 +194,7 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
if (adminUser.role === "brand_admin" && row.tenantId !== adminUser.brand_id) {
|
||||
if (adminUser.role === "brand_admin" && row.brandId !== adminUser.brand_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user