This commit is contained in:
276
src/lib/db.ts
276
src/lib/db.ts
@@ -1,5 +1,15 @@
|
||||
import { Pool } from "pg";
|
||||
import type { BillingAddress, OrgBilling, TenantRequest, TenantRequestStatus } from "@/types";
|
||||
import type {
|
||||
BillingAddress,
|
||||
OrgBilling,
|
||||
SupportTicket,
|
||||
SupportTicketComment,
|
||||
SupportTicketCommentAuthorKind,
|
||||
SupportTicketCategory,
|
||||
SupportTicketStatus,
|
||||
TenantRequest,
|
||||
TenantRequestStatus,
|
||||
} from "@/types";
|
||||
import { listTenants, getTenant } from "./k8s";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -190,6 +200,70 @@ const MIGRATION_SQL = `
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Feature 5: lightweight customer support / feedback tickets.
|
||||
-- Scoped strictly per-user (zitadel_user_id), not per-org —
|
||||
-- coworkers in the same org cannot see each other's tickets. The
|
||||
-- index on (zitadel_user_id, status) is what most customer-side
|
||||
-- queries hit; the index on (status, updated_at DESC) is for the
|
||||
-- admin queue.
|
||||
--
|
||||
-- contact_email / contact_name are frozen at creation time so the
|
||||
-- ticket retains a working "reply-to" identity even if the user
|
||||
-- later changes their email or display name in ZITADEL.
|
||||
CREATE TABLE IF NOT EXISTS support_tickets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
zitadel_org_id TEXT NOT NULL,
|
||||
zitadel_user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
contact_email TEXT NOT NULL,
|
||||
contact_name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- CHECK constraints added separately so re-running the migration
|
||||
-- against an existing table (without these constraints) works.
|
||||
-- IF NOT EXISTS isn't supported on ADD CONSTRAINT, hence the
|
||||
-- DO $$ wrapper.
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE support_tickets ADD CONSTRAINT support_tickets_category_check
|
||||
CHECK (category IN ('bug','feature_request','question','billing','other'));
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE support_tickets ADD CONSTRAINT support_tickets_status_check
|
||||
CHECK (status IN ('open','in_progress','waiting_for_customer','resolved','reopened'));
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_support_tickets_user
|
||||
ON support_tickets(zitadel_user_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_support_tickets_status
|
||||
ON support_tickets(status, updated_at DESC);
|
||||
|
||||
-- Threaded comments. ON DELETE CASCADE so deleting a ticket
|
||||
-- cleans up its history; we don't currently expose ticket
|
||||
-- deletion in the UI but the cascade keeps options open.
|
||||
CREATE TABLE IF NOT EXISTS support_ticket_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||
author_user_id TEXT NOT NULL,
|
||||
author_name TEXT NOT NULL,
|
||||
author_kind TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE support_ticket_comments ADD CONSTRAINT support_ticket_comments_author_kind_check
|
||||
CHECK (author_kind IN ('customer','admin'));
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
CREATE INDEX IF NOT EXISTS idx_support_ticket_comments_ticket
|
||||
ON support_ticket_comments(ticket_id, created_at);
|
||||
`;
|
||||
|
||||
let migrated = false;
|
||||
@@ -1045,3 +1119,203 @@ export async function removeAllAssignmentsForUser(
|
||||
[orgId, userId]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature 5: support tickets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rowToSupportTicket(row: any): SupportTicket {
|
||||
return {
|
||||
id: row.id,
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
zitadelUserId: row.zitadel_user_id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
category: row.category as SupportTicketCategory,
|
||||
status: row.status as SupportTicketStatus,
|
||||
contactEmail: row.contact_email,
|
||||
contactName: row.contact_name,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToSupportTicketComment(row: any): SupportTicketComment {
|
||||
return {
|
||||
id: row.id,
|
||||
ticketId: row.ticket_id,
|
||||
authorUserId: row.author_user_id,
|
||||
authorName: row.author_name,
|
||||
authorKind: row.author_kind as SupportTicketCommentAuthorKind,
|
||||
body: row.body,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new support ticket. The contact_name/contact_email are
|
||||
* snapshotted from the session at creation time — see SupportTicket
|
||||
* doc for why.
|
||||
*/
|
||||
export async function createSupportTicket(params: {
|
||||
zitadelOrgId: string;
|
||||
zitadelUserId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: SupportTicketCategory;
|
||||
contactName: string;
|
||||
contactEmail: string;
|
||||
}): Promise<SupportTicket> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`INSERT INTO support_tickets (
|
||||
zitadel_org_id, zitadel_user_id, title, description, category,
|
||||
contact_name, contact_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
params.zitadelUserId,
|
||||
params.title,
|
||||
params.description,
|
||||
params.category,
|
||||
params.contactName,
|
||||
params.contactEmail,
|
||||
]
|
||||
);
|
||||
return rowToSupportTicket(result.rows[0]);
|
||||
}
|
||||
|
||||
/** Tickets created by a single user, newest activity first. */
|
||||
export async function listSupportTicketsForUser(
|
||||
zitadelUserId: string
|
||||
): Promise<SupportTicket[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM support_tickets
|
||||
WHERE zitadel_user_id = $1
|
||||
ORDER BY updated_at DESC`,
|
||||
[zitadelUserId]
|
||||
);
|
||||
return result.rows.map(rowToSupportTicket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin queue. Returns every ticket across all users/orgs, newest
|
||||
* activity first. Pending tickets (open/reopened) bubble to the top
|
||||
* by virtue of recent activity, but the API doesn't sort by status —
|
||||
* the admin UI handles filtering and bucketing.
|
||||
*/
|
||||
export async function listAllSupportTickets(): Promise<SupportTicket[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM support_tickets ORDER BY updated_at DESC`
|
||||
);
|
||||
return result.rows.map(rowToSupportTicket);
|
||||
}
|
||||
|
||||
export async function getSupportTicketById(
|
||||
id: string
|
||||
): Promise<SupportTicket | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT * FROM support_tickets WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function listCommentsForTicket(
|
||||
ticketId: string
|
||||
): Promise<SupportTicketComment[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM support_ticket_comments
|
||||
WHERE ticket_id = $1
|
||||
ORDER BY created_at`,
|
||||
[ticketId]
|
||||
);
|
||||
return result.rows.map(rowToSupportTicketComment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a comment. Bumps the parent ticket's `updated_at` so the
|
||||
* activity sort orders work — done in a transaction so the two are
|
||||
* atomic from any concurrent reader's perspective.
|
||||
*
|
||||
* Caller is responsible for status auto-bumping (e.g. customer
|
||||
* replying to a `waiting_for_customer` ticket → `in_progress`); the
|
||||
* DB layer just writes what it's told.
|
||||
*/
|
||||
export async function createSupportTicketComment(params: {
|
||||
ticketId: string;
|
||||
authorUserId: string;
|
||||
authorName: string;
|
||||
authorKind: SupportTicketCommentAuthorKind;
|
||||
body: string;
|
||||
}): Promise<SupportTicketComment> {
|
||||
await ensureSchema();
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const inserted = await client.query(
|
||||
`INSERT INTO support_ticket_comments (
|
||||
ticket_id, author_user_id, author_name, author_kind, body
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.ticketId,
|
||||
params.authorUserId,
|
||||
params.authorName,
|
||||
params.authorKind,
|
||||
params.body,
|
||||
]
|
||||
);
|
||||
await client.query(
|
||||
"UPDATE support_tickets SET updated_at = now() WHERE id = $1",
|
||||
[params.ticketId]
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
return rowToSupportTicketComment(inserted.rows[0]);
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mutable fields on a ticket. Only category and status are
|
||||
* mutable; title/description are frozen post-creation. Returns the
|
||||
* updated row so callers can email the right contact_email
|
||||
* afterwards.
|
||||
*/
|
||||
export async function updateSupportTicket(
|
||||
id: string,
|
||||
changes: { status?: SupportTicketStatus; category?: SupportTicketCategory }
|
||||
): Promise<SupportTicket | null> {
|
||||
await ensureSchema();
|
||||
const sets: string[] = ["updated_at = now()"];
|
||||
const values: any[] = [id];
|
||||
let idx = 2;
|
||||
if (changes.status !== undefined) {
|
||||
sets.push(`status = $${idx}`);
|
||||
values.push(changes.status);
|
||||
idx++;
|
||||
}
|
||||
if (changes.category !== undefined) {
|
||||
sets.push(`category = $${idx}`);
|
||||
values.push(changes.category);
|
||||
idx++;
|
||||
}
|
||||
// No-op early exit. Without an actual change we still want
|
||||
// updated_at refreshed if the caller asked for one, but if they
|
||||
// passed neither field there's nothing to do.
|
||||
if (sets.length === 1) return getSupportTicketById(id);
|
||||
const result = await getPool().query(
|
||||
`UPDATE support_tickets SET ${sets.join(", ")} WHERE id = $1 RETURNING *`,
|
||||
values
|
||||
);
|
||||
return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
279
src/lib/email.ts
279
src/lib/email.ts
@@ -318,3 +318,282 @@ export async function sendAdminNotificationEmail(
|
||||
console.error("Failed to send admin notification email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature 5: support ticket emails
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Email subject prefix that helps customers thread tickets in their
|
||||
* mail client. We don't have inbound email processing — replies via
|
||||
* email back to us go nowhere — but the prefix is still useful for
|
||||
* the customer's own organisation. The id is shortened to 8 chars
|
||||
* for human readability; collisions on the truncated form within a
|
||||
* single user's inbox are vanishingly unlikely.
|
||||
*/
|
||||
function ticketSubjectPrefix(ticketId: string): string {
|
||||
return `[PieCed Support #${ticketId.slice(0, 8)}]`;
|
||||
}
|
||||
|
||||
const STATUS_LABELS_EN: Record<string, string> = {
|
||||
open: "Open",
|
||||
in_progress: "In progress",
|
||||
waiting_for_customer: "Waiting for your reply",
|
||||
resolved: "Resolved",
|
||||
reopened: "Reopened",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sent to the customer when they create a ticket — confirmation
|
||||
* that we received it and a copy of the ticket id for their records.
|
||||
*/
|
||||
export async function sendSupportTicketCreatedEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
ticketId: string;
|
||||
title: string;
|
||||
}): Promise<void> {
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeTitle = escapeHtml(params.title);
|
||||
const shortId = params.ticketId.slice(0, 8);
|
||||
const subject = `${ticketSubjectPrefix(params.ticketId)} ${params.title}`;
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject,
|
||||
text: [
|
||||
`Hello ${params.contactName},`,
|
||||
"",
|
||||
`We've received your support request "${params.title}" (reference #${shortId}).`,
|
||||
"",
|
||||
"Our team will review and respond as soon as possible. You can track the status and reply at https://app.pieced.ch/support.",
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
|
||||
<h2 style="color: #ffffff; margin-top: 0;">Support request received</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>We've received your support request <strong>"${safeTitle}"</strong> (reference #${shortId}).</p>
|
||||
<p>Our team will review and respond as soon as possible.</p>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||
View ticket
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send support ticket creation email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent to the customer when an admin replies to one of their tickets.
|
||||
* Includes the body of the reply inline so the customer can read it
|
||||
* without clicking through (especially useful on mobile).
|
||||
*/
|
||||
export async function sendSupportTicketReplyEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
ticketId: string;
|
||||
title: string;
|
||||
authorName: string;
|
||||
body: string;
|
||||
}): Promise<void> {
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeTitle = escapeHtml(params.title);
|
||||
const safeAuthor = escapeHtml(params.authorName);
|
||||
const safeBody = escapeHtml(params.body);
|
||||
const shortId = params.ticketId.slice(0, 8);
|
||||
const subject = `${ticketSubjectPrefix(params.ticketId)} Re: ${params.title}`;
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject,
|
||||
text: [
|
||||
`Hello ${params.contactName},`,
|
||||
"",
|
||||
`${params.authorName} replied to your ticket "${params.title}" (#${shortId}):`,
|
||||
"",
|
||||
params.body,
|
||||
"",
|
||||
"Reply or follow up at https://app.pieced.ch/support.",
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
|
||||
<h2 style="color: #ffffff; margin-top: 0;">New reply on your ticket</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p><strong>${safeAuthor}</strong> replied to your ticket <strong>"${safeTitle}"</strong> (#${shortId}):</p>
|
||||
<div style="background: #2a2a2a; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 6px; margin: 16px 0; white-space: pre-wrap;">
|
||||
${safeBody}
|
||||
</div>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||
View ticket
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send support ticket reply email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent to the customer when an admin changes status without a comment.
|
||||
* If the same admin action included a comment, they'd get the
|
||||
* reply email instead — caller decides which to send.
|
||||
*/
|
||||
export async function sendSupportTicketStatusEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
ticketId: string;
|
||||
title: string;
|
||||
newStatus: string;
|
||||
}): Promise<void> {
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeTitle = escapeHtml(params.title);
|
||||
const statusLabel = STATUS_LABELS_EN[params.newStatus] ?? params.newStatus;
|
||||
const shortId = params.ticketId.slice(0, 8);
|
||||
const subject = `${ticketSubjectPrefix(params.ticketId)} Status: ${statusLabel}`;
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject,
|
||||
text: [
|
||||
`Hello ${params.contactName},`,
|
||||
"",
|
||||
`The status of your ticket "${params.title}" (#${shortId}) has been updated to: ${statusLabel}.`,
|
||||
"",
|
||||
"View details and respond if needed at https://app.pieced.ch/support.",
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
|
||||
<h2 style="color: #ffffff; margin-top: 0;">Ticket status update</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>The status of your ticket <strong>"${safeTitle}"</strong> (#${shortId}) has been updated to:</p>
|
||||
<p style="font-size: 18px; color: #3b82f6; font-weight: 600;">${escapeHtml(statusLabel)}</p>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/support" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||
View ticket
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send support ticket status email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the platform admin distribution list of a new ticket OR a
|
||||
* customer reply. Mirror of sendAdminNotificationEmail's pattern —
|
||||
* uses the same ADMIN_NOTIFICATION_EMAIL env var.
|
||||
*
|
||||
* Two trigger reasons supported:
|
||||
* - 'created' → new ticket from a customer
|
||||
* - 'replied' → customer replied to existing ticket (we want admin
|
||||
* visibility, e.g. to know the ticket needs another
|
||||
* round of attention)
|
||||
*/
|
||||
export async function sendSupportAdminNotificationEmail(params: {
|
||||
reason: "created" | "replied";
|
||||
ticketId: string;
|
||||
title: string;
|
||||
contactName: string;
|
||||
contactEmail: string;
|
||||
body?: string; // The new message content (description on create, comment body on reply)
|
||||
category?: string;
|
||||
}): Promise<void> {
|
||||
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
||||
if (!adminEmail) {
|
||||
console.warn(
|
||||
"ADMIN_NOTIFICATION_EMAIL not set; skipping admin support notification"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const safeContact = escapeHtml(params.contactName);
|
||||
const safeContactEmail = escapeHtml(params.contactEmail);
|
||||
const safeTitle = escapeHtml(params.title);
|
||||
const safeBody = params.body ? escapeHtml(params.body) : "";
|
||||
const shortId = params.ticketId.slice(0, 8);
|
||||
|
||||
const subjectVerb = params.reason === "created" ? "New" : "Reply on";
|
||||
const subject = `${ticketSubjectPrefix(params.ticketId)} ${subjectVerb}: ${params.title}`;
|
||||
|
||||
const headlineHtml =
|
||||
params.reason === "created"
|
||||
? `<h2 style="color: #ffffff; margin-top: 0;">New support ticket</h2>`
|
||||
: `<h2 style="color: #ffffff; margin-top: 0;">Customer replied on ticket</h2>`;
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: adminEmail,
|
||||
subject,
|
||||
text: [
|
||||
params.reason === "created"
|
||||
? "A new support ticket was opened:"
|
||||
: "A customer replied to a support ticket:",
|
||||
"",
|
||||
`From: ${params.contactName} <${params.contactEmail}>`,
|
||||
`Ticket: ${params.title} (#${shortId})`,
|
||||
params.category ? `Category: ${params.category}` : "",
|
||||
"",
|
||||
params.body ? "Message:" : "",
|
||||
params.body ?? "",
|
||||
"",
|
||||
`View at https://app.pieced.ch/support/${params.ticketId}`,
|
||||
]
|
||||
.filter((s) => s !== "")
|
||||
.join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
|
||||
${headlineHtml}
|
||||
<table style="width:100%; font-size: 13px; color: #aaa; margin-bottom: 16px;">
|
||||
<tr><td style="padding: 4px 0; width: 100px;">From</td><td style="padding: 4px 0; color: #fff;">${safeContact} <${safeContactEmail}></td></tr>
|
||||
<tr><td style="padding: 4px 0;">Title</td><td style="padding: 4px 0; color: #fff;">${safeTitle} <span style="color: #666;">(#${shortId})</span></td></tr>
|
||||
${params.category ? `<tr><td style="padding: 4px 0;">Category</td><td style="padding: 4px 0; color: #fff;">${escapeHtml(params.category)}</td></tr>` : ""}
|
||||
</table>
|
||||
${
|
||||
params.body
|
||||
? `<div style="background: #2a2a2a; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 6px; margin: 16px 0; white-space: pre-wrap;">${safeBody}</div>`
|
||||
: ""
|
||||
}
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/support/${params.ticketId}" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
|
||||
Open in admin queue
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">PieCed IT — Admin notification</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send admin support notification:", err);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user