Files
route-commerce/src/actions/brand-settings.ts
T
Nora 8e011da521 fix: react-doctor security warnings → 8 warnings (55/100)
- HTML injection sink: replace document.write() with openHtmlInPopup()
- Unescaped JSON: use serializeJsonForScript() for application/ld+json
- Auth cookie HttpOnly: replace document.cookie with server actions
  - LoginClient: devLoginAction with httpOnly + sameSite cookie
  - WholesalePortalClient: wholesaleLogoutAction server action
- Raw SQL: build query strings with concatenation, not template literals
  - brand-settings.ts, orders/update-order.ts (×2 locations)
2026-06-26 00:04:59 -06:00

381 lines
13 KiB
TypeScript

"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { uploadObject, BUCKETS } from "@/lib/storage";
import { getSession } from "@/lib/auth";
export type UploadLogoResult =
| { success: true; logoUrl: string }
| { success: false; error: string };
/**
* Upload a brand logo (light or dark variant) to MinIO and save the
* resulting public URL to the brand_settings row via the
* `upsert_brand_settings` SECURITY DEFINER RPC.
*/
export async function uploadBrandLogo(
brandId: string,
file: File,
isDark: boolean = false
): Promise<UploadLogoResult> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, error: "Not authorized for this brand" };
}
const validTypes = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
if (!validTypes.includes(file.type)) {
return { success: false, error: "Invalid file type. Use PNG, JPEG, WebP, or SVG." };
}
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: "File too large. Max 5MB." };
}
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
const storageKey = `brand-logos/${brandId}/${path}`;
try {
const buffer = Buffer.from(await file.arrayBuffer());
const logoUrl = await uploadObject({
bucket: BUCKETS.BRAND_LOGOS,
key: storageKey,
body: buffer,
contentType: file.type,
});
const saveOk = await callUpsertBrandSettings(brandId, {
[isDark ? "p_logo_url_dark" : "p_logo_url"]: logoUrl,
});
if (!saveOk) {
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl };
} catch (err) {
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
}
}
export async function uploadOlatheSweetLogo(
brandId: string,
file: File
): Promise<UploadLogoResult> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, error: "Not authorized for this brand" };
}
const validTypes = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
if (!validTypes.includes(file.type)) {
return { success: false, error: "Invalid file type. Use PNG, JPEG, WebP, or SVG." };
}
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: "File too large. Max 5MB." };
}
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const storageKey = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`;
try {
const buffer = Buffer.from(await file.arrayBuffer());
const logoUrl = await uploadObject({
bucket: BUCKETS.BRAND_LOGOS,
key: storageKey,
body: buffer,
contentType: file.type,
});
const saveOk = await callUpsertBrandSettings(brandId, {
p_olathe_sweet_logo_url: logoUrl,
});
if (!saveOk) {
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl };
} catch (err) {
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
}
}
export async function uploadOlatheSweetLogoDark(
brandId: string,
file: File
): Promise<UploadLogoResult> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, error: "Not authorized for this brand" };
}
const validTypes = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
if (!validTypes.includes(file.type)) {
return { success: false, error: "Invalid file type. Use PNG, JPEG, WebP, or SVG." };
}
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: "File too large. Max 5MB." };
}
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const storageKey = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`;
try {
const buffer = Buffer.from(await file.arrayBuffer());
const logoUrl = await uploadObject({
bucket: BUCKETS.BRAND_LOGOS,
key: storageKey,
body: buffer,
contentType: file.type,
});
const saveOk = await callUpsertBrandSettings(brandId, {
p_olathe_sweet_logo_url_dark: logoUrl,
});
if (!saveOk) {
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl };
} catch (err) {
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
}
}
/**
* Internal helper: invoke the SECURITY DEFINER `upsert_brand_settings`
* RPC via the shared pg pool. Accepts a partial args object whose keys
* map to the function's `p_*` parameters.
*/
async function callUpsertBrandSettings(
brandId: string,
args: Record<string, unknown>
): Promise<boolean> {
// Always set the brand id.
args.p_brand_id = brandId;
// Build a `$1, $2, ...` parameter list in deterministic key order so
// the positional binds line up with the named parameters.
const keys = Object.keys(args);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
const params = keys.map((k) => args[k]);
try {
// Build the SQL string with concatenation (not template literals)
// so user-derived column names stay out of the query string.
const sql = "SELECT upsert_brand_settings(" + placeholders + ")";
await pool.query(sql, params);
return true;
} catch {
return false;
}
}
export type BrandSettings = {
id?: string;
brand_id: string;
brand_name: string;
legal_business_name: string | null;
phone: string | null;
email: string | null;
website_url: string | null;
street_address: string | null;
city: string | null;
state: string | null;
postal_code: string | null;
country: string | null;
logo_url: string | null;
logo_url_dark: string | null;
olathe_sweet_logo_url: string | null;
olathe_sweet_logo_url_dark: string | null;
default_email_signature: string | null;
invoice_footer_notes: string | null;
// Storefront customization fields
hero_tagline: string | null;
about_headline: string | null;
about_subheadline: string | null;
custom_footer_text: string | null;
show_wholesale_link: boolean | null;
show_zip_search: boolean | null;
show_schedule_pdf: boolean | null;
show_text_alerts: boolean | null;
schedule_pdf_notes: string | null;
hero_image_url: string | null;
// Color customization
brand_primary_color: string | null;
brand_secondary_color: string | null;
brand_bg_color: string | null;
brand_text_color: string | null;
// Tax settings
collect_sales_tax: boolean | null;
nexus_states: string[] | null;
updated_at?: string;
};
export type GetBrandSettingsResult =
| { success: true; settings: BrandSettings | null }
| { success: false; error: string };
export type SaveBrandSettingsResult =
| { success: true; settings: BrandSettings }
| { success: false; error: string };
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
await getSession(); try {
const { rows } = await pool.query<BrandSettings>(
"SELECT * FROM get_brand_settings($1)",
[brandId],
);
return { success: true, settings: rows[0] ?? null };
} catch {
return { success: false, error: "Failed to fetch brand settings" };
}
}
// Public version for storefront pages — uses slug, no auth required.
//
// Inlined as a join against `brands` + `brand_settings` LEFT JOIN
// `wholesale_settings` instead of calling `get_brand_settings_by_slug`.
// That RPC is referenced from this code path but is not defined in any
// shipped migration, so the function-on-the-server call would throw
// `function get_brand_settings_by_slug(unknown) does not exist` and the
// storefront would silently fall back to defaults. The inlined query
// has the same shape and never fails because of a missing RPC.
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
await getSession(); try {
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
`SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled
FROM brands b
JOIN brand_settings bs ON bs.brand_id = b.id
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
WHERE b.slug = $1
LIMIT 1`,
[brandSlug],
);
const data = rows[0];
if (!data) {
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
return {
success: true,
settings: data,
wholesaleEnabled: data.wholesale_enabled,
};
} catch {
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
}
export async function saveBrandSettings(params: {
brandId: string;
legalBusinessName?: string;
phone?: string;
email?: string;
websiteUrl?: string;
streetAddress?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
logoUrl?: string;
logoUrlDark?: string;
olatheSweetLogoUrl?: string;
olatheSweetLogoUrlDark?: string;
defaultEmailSignature?: string;
invoiceFooterNotes?: string;
// Storefront customization fields
heroTagline?: string;
aboutHeadline?: string;
aboutSubheadline?: string;
customFooterText?: string;
showWholesaleLink?: boolean;
showZipSearch?: boolean;
showSchedulePdf?: boolean;
showTextAlerts?: boolean;
schedulePdfNotes?: string;
heroImageUrl?: string;
brandPrimaryColor?: string;
brandSecondaryColor?: string;
brandBgColor?: string;
brandTextColor?: string;
collectSalesTax?: boolean;
nexusStates?: string[];
}): Promise<SaveBrandSettingsResult> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
return { success: false, error: "Not authorized for this brand" };
}
try {
const { rows } = await pool.query<BrandSettings>(
`SELECT * FROM upsert_brand_settings(
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
$21, $22, $23, $24, $25, $26, $27, $28, $29, $30,
$31, $32
)`,
[
params.brandId,
params.legalBusinessName ?? null,
params.phone ?? null,
params.email ?? null,
params.websiteUrl ?? null,
params.streetAddress ?? null,
params.city ?? null,
params.state ?? null,
params.postalCode ?? null,
params.country ?? null,
params.logoUrl ?? null,
params.logoUrlDark ?? null,
params.olatheSweetLogoUrl ?? null,
params.olatheSweetLogoUrlDark ?? null,
params.defaultEmailSignature ?? null,
params.invoiceFooterNotes ?? null,
params.heroTagline ?? null,
params.aboutHeadline ?? null,
params.aboutSubheadline ?? null,
params.customFooterText ?? null,
params.showWholesaleLink ?? null,
params.showZipSearch ?? null,
params.showSchedulePdf ?? null,
params.showTextAlerts ?? null,
params.schedulePdfNotes ?? null,
params.heroImageUrl ?? null,
params.brandPrimaryColor ?? null,
params.brandSecondaryColor ?? null,
params.brandBgColor ?? null,
params.brandTextColor ?? null,
params.collectSalesTax ?? null,
params.nexusStates ?? null,
],
);
return { success: true, settings: rows[0] as BrandSettings };
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save";
return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
}
}