Files
route-commerce/src/actions/communications/templates.ts
T
openclaw c86e97e816
Deploy to route.crispygoat.com / deploy (push) Failing after 7s
feat(storage): MinIO object storage, Neon Auth, Supabase removal
- 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
2026-06-09 12:21:57 -06:00

215 lines
6.1 KiB
TypeScript

"use server";
import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { emailTemplates } from "@/db/schema";
export type TemplateType = "email_template" | "sms_template" | "internal_note";
export type CampaignType = "marketing" | "operational" | "transactional";
/**
* The new `email_templates` table stores `name`, `subject`, and
* `body_html`. Legacy `communication_templates` rows also tracked
* `body_text`, `template_type`, `campaign_type`, and `created_by`.
* The fields below mirror the new columns. `body_text` is
* intentionally absent — clients that need a plain-text version can
* strip HTML. `template_type` and `campaign_type` are not stored; the
* UI surfaces "email" as the only type until further schema work.
*/
export type Template = {
id: string;
brand_id: string;
name: string;
subject: string;
body_text: string;
body_html: string;
template_type: TemplateType;
campaign_type: CampaignType | null;
created_at: string;
updated_at: string;
};
export type UpsertTemplateResult = {
success: true;
template: Template;
} | {
success: false;
error: string;
};
function rowToTemplate(row: typeof emailTemplates.$inferSelect): Template {
return {
id: row.id,
brand_id: row.brandId,
name: row.name,
subject: row.subject,
body_text: stripHtml(row.bodyHtml),
body_html: row.bodyHtml,
template_type: "email_template",
campaign_type: null,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}
function stripHtml(html: string): string {
return html
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export async function getCommunicationTemplates(brandId?: string): Promise<{ success: true; templates: Template[] } | { success: false; error: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
if (adminUser.role === "brand_admin" && activeBrandId && activeBrandId !== adminUser.brand_id) {
return { success: true, templates: [] };
}
try {
const rows = activeBrandId
? await withBrand(activeBrandId, (db) =>
db
.select()
.from(emailTemplates)
.orderBy(emailTemplates.updatedAt),
)
: await withPlatformAdmin((db) =>
db
.select()
.from(emailTemplates)
.orderBy(emailTemplates.updatedAt),
);
return { success: true, templates: rows.map(rowToTemplate) };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to fetch templates",
};
}
}
export async function upsertTemplate(params: {
id?: string;
brand_id: string;
name: string;
subject: string;
body_text: string;
body_html?: string;
template_type: TemplateType;
campaign_type?: CampaignType;
}): Promise<UpsertTemplateResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const bodyHtml =
params.body_html && params.body_html.length > 0
? params.body_html
: `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>`;
try {
const row = params.id
? await withBrand(params.brand_id, async (db) => {
const updated = await db
.update(emailTemplates)
.set({
name: params.name,
subject: params.subject,
bodyHtml,
updatedAt: new Date(),
})
.where(
and(
eq(emailTemplates.id, params.id!),
eq(emailTemplates.brandId, params.brand_id),
),
)
.returning();
return updated[0] ?? null;
})
: await withBrand(params.brand_id, async (db) => {
const inserted = await db
.insert(emailTemplates)
.values({
brandId: params.brand_id,
name: params.name,
subject: params.subject,
bodyHtml,
})
.returning();
return inserted[0] ?? null;
});
if (!row) return { success: false, error: "Failed to save template" };
return { success: true, template: rowToTemplate(row) };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to save template",
};
}
}
export async function getTemplateById(templateId: string): Promise<Template | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
const activeBrandId = await getActiveBrandId(adminUser);
try {
const row = activeBrandId
? await withBrand(activeBrandId, (db) =>
db
.select()
.from(emailTemplates)
.where(
and(
eq(emailTemplates.id, templateId),
eq(emailTemplates.brandId, activeBrandId),
),
)
.limit(1)
.then((r) => r[0] ?? null),
)
: await withPlatformAdmin((db) =>
db
.select()
.from(emailTemplates)
.where(eq(emailTemplates.id, templateId))
.limit(1)
.then((r) => r[0] ?? null),
);
if (!row) return null;
if (adminUser.role === "brand_admin" && row.brandId !== adminUser.brand_id) {
return null;
}
return rowToTemplate(row);
} catch {
return null;
}
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}