Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m39s
All checks were successful
Build and Push / build (push) Successful in 1m39s
This commit is contained in:
203
src/lib/db.ts
203
src/lib/db.ts
@@ -530,6 +530,39 @@ const MIGRATION_SQL = `
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_reminders_invoice
|
||||
ON invoice_reminders(invoice_id, level);
|
||||
|
||||
-- Phase 2.5: queue for skills flagged with requiresManualSetup in
|
||||
-- the package catalog. A user-side enable on a flagged skill
|
||||
-- creates a pending row here instead of mutating tenant.spec.packages;
|
||||
-- the operator never sees the skill until admin approves and adds
|
||||
-- it to the spec. Disable is always direct — there's no gate on
|
||||
-- turning a skill off, even one that previously required setup.
|
||||
CREATE TABLE IF NOT EXISTS skill_activation_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_name TEXT NOT NULL,
|
||||
zitadel_org_id TEXT NOT NULL,
|
||||
zitadel_user_id TEXT NOT NULL,
|
||||
skill_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')) DEFAULT 'pending',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
reviewed_by TEXT,
|
||||
rejection_reason TEXT,
|
||||
admin_notes TEXT
|
||||
);
|
||||
-- Only one in-flight request per (tenant, skill). Rejected and
|
||||
-- approved rows don't block new requests; user can retry after a
|
||||
-- rejection by toggling the skill again.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_skill_act_one_pending
|
||||
ON skill_activation_requests (tenant_name, skill_id)
|
||||
WHERE status = 'pending';
|
||||
-- Admin queue lookup — partial index keeps it tiny.
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_act_pending_status
|
||||
ON skill_activation_requests (requested_at DESC)
|
||||
WHERE status = 'pending';
|
||||
-- Per-tenant lookup for the customer UI's pending+rejected display.
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_act_tenant
|
||||
ON skill_activation_requests (tenant_name, requested_at DESC);
|
||||
`;
|
||||
|
||||
let migrated = false;
|
||||
@@ -2590,3 +2623,173 @@ export async function updateInvoicePdf(
|
||||
[invoiceId, pdfBuffer, filename]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill activation requests — Phase 2.5
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type {
|
||||
SkillActivationRequest,
|
||||
SkillActivationStatus,
|
||||
} from "@/types";
|
||||
|
||||
function rowToSkillActivationRequest(row: any): SkillActivationRequest {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantName: row.tenant_name,
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
zitadelUserId: row.zitadel_user_id,
|
||||
skillId: row.skill_id,
|
||||
status: row.status as SkillActivationStatus,
|
||||
requestedAt: row.requested_at?.toISOString?.() ?? row.requested_at,
|
||||
reviewedAt: row.reviewed_at?.toISOString?.() ?? row.reviewed_at ?? null,
|
||||
reviewedBy: row.reviewed_by ?? null,
|
||||
rejectionReason: row.rejection_reason ?? null,
|
||||
adminNotes: row.admin_notes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a pending activation request. Throws a tagged error if a
|
||||
* pending row already exists for the (tenant, skill) — the partial
|
||||
* unique index enforces this. The caller surfaces "request already
|
||||
* pending" to the user rather than letting it 500.
|
||||
*/
|
||||
export async function createSkillActivationRequest(params: {
|
||||
tenantName: string;
|
||||
zitadelOrgId: string;
|
||||
zitadelUserId: string;
|
||||
skillId: string;
|
||||
}): Promise<SkillActivationRequest> {
|
||||
await ensureSchema();
|
||||
try {
|
||||
const result = await getPool().query(
|
||||
`INSERT INTO skill_activation_requests
|
||||
(tenant_name, zitadel_org_id, zitadel_user_id, skill_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.tenantName,
|
||||
params.zitadelOrgId,
|
||||
params.zitadelUserId,
|
||||
params.skillId,
|
||||
]
|
||||
);
|
||||
return rowToSkillActivationRequest(result.rows[0]);
|
||||
} catch (e: any) {
|
||||
if (e?.code === "23505") {
|
||||
const err = new Error(
|
||||
`A pending activation request already exists for ${params.skillId} on ${params.tenantName}.`
|
||||
);
|
||||
(err as any).code = "REQUEST_ALREADY_PENDING";
|
||||
throw err;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSkillActivationRequestById(
|
||||
id: string
|
||||
): Promise<SkillActivationRequest | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT * FROM skill_activation_requests WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
return result.rows.length > 0
|
||||
? rowToSkillActivationRequest(result.rows[0])
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* All pending requests across all tenants — feeds the admin queue
|
||||
* page. Capped to 500 rows for safety; unlikely to ever be hit but
|
||||
* keeps the page bounded.
|
||||
*/
|
||||
export async function listPendingSkillActivationRequests(): Promise<
|
||||
SkillActivationRequest[]
|
||||
> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT * FROM skill_activation_requests
|
||||
WHERE status = 'pending'
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 500`
|
||||
);
|
||||
return result.rows.map(rowToSkillActivationRequest);
|
||||
}
|
||||
|
||||
export async function countPendingSkillActivationRequests(): Promise<number> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT COUNT(*)::int AS c FROM skill_activation_requests WHERE status = 'pending'"
|
||||
);
|
||||
return result.rows[0]?.c ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests visible to a customer for one tenant. Returns:
|
||||
* - pending: rows the user might want to withdraw
|
||||
* - rejected: the most recent rejection per skill, so the user
|
||||
* sees why and can retry
|
||||
* Approved and withdrawn rows are excluded (terminal states with
|
||||
* no user-visible UI effect after the fact).
|
||||
*/
|
||||
export async function listSkillActivationRequestsForTenant(
|
||||
tenantName: string
|
||||
): Promise<SkillActivationRequest[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`WITH ranked AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY skill_id, status
|
||||
ORDER BY requested_at DESC
|
||||
) AS rn
|
||||
FROM skill_activation_requests
|
||||
WHERE tenant_name = $1
|
||||
AND status IN ('pending', 'rejected')
|
||||
)
|
||||
SELECT * FROM ranked WHERE rn = 1
|
||||
ORDER BY requested_at DESC`,
|
||||
[tenantName]
|
||||
);
|
||||
return result.rows.map(rowToSkillActivationRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a request's status. The caller is responsible for the
|
||||
* side effects (K8s patch on approve, email send) — this function
|
||||
* only touches the row.
|
||||
*
|
||||
* Returns null when the row doesn't exist or isn't in 'pending'
|
||||
* status. That null is meaningful: it tells the caller the
|
||||
* transition didn't happen (already approved/rejected by another
|
||||
* admin tab, etc.) and downstream actions should be skipped.
|
||||
*/
|
||||
export async function updateSkillActivationRequestStatus(
|
||||
id: string,
|
||||
newStatus: Exclude<SkillActivationStatus, "pending">,
|
||||
opts: {
|
||||
reviewedBy: string;
|
||||
rejectionReason?: string | null;
|
||||
adminNotes?: string | null;
|
||||
}
|
||||
): Promise<SkillActivationRequest | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`UPDATE skill_activation_requests
|
||||
SET status = $2,
|
||||
reviewed_at = now(),
|
||||
reviewed_by = $3,
|
||||
rejection_reason = $4,
|
||||
admin_notes = COALESCE($5, admin_notes)
|
||||
WHERE id = $1
|
||||
AND status = 'pending'
|
||||
RETURNING *`,
|
||||
[id, newStatus, opts.reviewedBy, opts.rejectionReason ?? null, opts.adminNotes ?? null]
|
||||
);
|
||||
return result.rows.length > 0
|
||||
? rowToSkillActivationRequest(result.rows[0])
|
||||
: null;
|
||||
}
|
||||
|
||||
177
src/lib/email.ts
177
src/lib/email.ts
@@ -723,3 +723,180 @@ export async function sendSupportAdminNotificationEmail(params: {
|
||||
console.error("Failed to send admin support notification:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill activation requests — Phase 2.5
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Three notifications:
|
||||
//
|
||||
// sendSkillActivationAdminNotification — to ADMIN_NOTIFICATION_EMAIL
|
||||
// when a customer requests a
|
||||
// flagged skill.
|
||||
//
|
||||
// sendSkillActivationApprovalEmail — to the customer, on approve.
|
||||
//
|
||||
// sendSkillActivationRejectionEmail — to the customer, on reject,
|
||||
// including the admin's reason.
|
||||
//
|
||||
// All three follow the existing patterns in this file (HTML + plaintext,
|
||||
// escaped vars, best-effort with errors logged not thrown).
|
||||
|
||||
/**
|
||||
* Notify admin (ADMIN_NOTIFICATION_EMAIL) that a customer has
|
||||
* requested activation of a manual-setup skill. The skill name +
|
||||
* tenant + requester are all included so admin can act without
|
||||
* loading the portal.
|
||||
*/
|
||||
export async function sendSkillActivationAdminNotification(params: {
|
||||
tenantName: string;
|
||||
skillId: string;
|
||||
skillName: string;
|
||||
requesterEmail: string;
|
||||
requesterName: string;
|
||||
companyName: string | null;
|
||||
}): Promise<void> {
|
||||
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
||||
if (!adminEmail) return;
|
||||
const safeTenant = escapeHtml(params.tenantName);
|
||||
const safeSkillId = escapeHtml(params.skillId);
|
||||
const safeSkillName = escapeHtml(params.skillName);
|
||||
const safeRequester = escapeHtml(params.requesterName);
|
||||
const safeRequesterEmail = escapeHtml(params.requesterEmail);
|
||||
const safeCompany = params.companyName
|
||||
? escapeHtml(params.companyName)
|
||||
: "—";
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: adminEmail,
|
||||
subject: `[PieCed] Skill activation requested — ${params.skillName} on ${params.tenantName}`,
|
||||
text: [
|
||||
"A customer has requested activation of a manual-setup skill.",
|
||||
"",
|
||||
`Skill: ${params.skillName} (${params.skillId})`,
|
||||
`Tenant: ${params.tenantName}`,
|
||||
`Organization:${params.companyName ?? "—"}`,
|
||||
`Requested by:${params.requesterName} <${params.requesterEmail}>`,
|
||||
"",
|
||||
"Review and act in the admin queue:",
|
||||
"https://app.pieced.ch/admin/skills/pending",
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||
<h2 style="margin: 0 0 16px; color: #10B981;">Skill activation requested</h2>
|
||||
<p>A customer has requested activation of a manual-setup skill.</p>
|
||||
<table style="width:100%; border-collapse: collapse; margin: 12px 0;">
|
||||
<tr><td style="color:#888; padding:4px 0;">Skill</td><td>${safeSkillName} (<code>${safeSkillId}</code>)</td></tr>
|
||||
<tr><td style="color:#888; padding:4px 0;">Tenant</td><td><code>${safeTenant}</code></td></tr>
|
||||
<tr><td style="color:#888; padding:4px 0;">Organization</td><td>${safeCompany}</td></tr>
|
||||
<tr><td style="color:#888; padding:4px 0;">Requested by</td><td>${safeRequester} <${safeRequesterEmail}></td></tr>
|
||||
</table>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/admin/skills/pending" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||
Open 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 skill activation admin notification:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSkillActivationApprovalEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
skillName: string;
|
||||
tenantName: string;
|
||||
}): Promise<void> {
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeSkill = escapeHtml(params.skillName);
|
||||
const safeTenant = escapeHtml(params.tenantName);
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject: `Your skill activation has been approved — ${params.skillName}`,
|
||||
text: [
|
||||
`Hello ${params.contactName},`,
|
||||
"",
|
||||
`Good news — your request to activate "${params.skillName}" on tenant ${params.tenantName} has been approved and the skill is now live.`,
|
||||
"",
|
||||
"You can manage it from your tenant settings.",
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width:560px; padding:24px; background:#1a1a1a; color:#e5e5e5;">
|
||||
<h2 style="margin:0 0 16px; color:#10B981;">Skill approved & activated</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>Your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code> has been approved and the skill is now live.</p>
|
||||
<p>You can manage it from your tenant settings.</p>
|
||||
<p>
|
||||
<a href="https://app.pieced.ch/tenants/${encodeURIComponent(params.tenantName)}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||
Open tenant
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||
<p style="color:#666; font-size:12px;">PieCed IT</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send skill activation approval email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendSkillActivationRejectionEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
skillName: string;
|
||||
tenantName: string;
|
||||
reason: string;
|
||||
}): Promise<void> {
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeSkill = escapeHtml(params.skillName);
|
||||
const safeTenant = escapeHtml(params.tenantName);
|
||||
const safeReason = escapeHtml(params.reason);
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject: `Update on your skill activation request — ${params.skillName}`,
|
||||
text: [
|
||||
`Hello ${params.contactName},`,
|
||||
"",
|
||||
`We were unable to approve your request to activate "${params.skillName}" on tenant ${params.tenantName}.`,
|
||||
"",
|
||||
"Reason from our team:",
|
||||
params.reason,
|
||||
"",
|
||||
"You can try again from your tenant settings once the matter is resolved.",
|
||||
"",
|
||||
"Best regards,",
|
||||
"PieCed IT",
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width:560px; padding:24px; background:#1a1a1a; color:#e5e5e5;">
|
||||
<h2 style="margin:0 0 16px; color:#ef4444;">Activation request not approved</h2>
|
||||
<p>Hello ${safeName},</p>
|
||||
<p>We were unable to approve your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code>.</p>
|
||||
<div style="background:#2a2a2a; border-left:3px solid #ef4444; padding:12px 16px; border-radius:6px; margin:16px 0;">
|
||||
<p style="color:#ccc; font-size:13px; margin:0;"><strong>Reason from our team:</strong></p>
|
||||
<p style="color:#aaa; font-size:13px; margin:8px 0 0 0; white-space:pre-wrap;">${safeReason}</p>
|
||||
</div>
|
||||
<p>You can try again from your tenant settings once the matter is resolved.</p>
|
||||
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||
<p style="color:#666; font-size:12px;">PieCed IT</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send skill activation rejection email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,25 @@ export interface PackageDef {
|
||||
* that the customer is not aware of.
|
||||
*/
|
||||
customProvisioning?: boolean;
|
||||
/**
|
||||
* When true, customer-initiated enable requests are routed through
|
||||
* an admin approval queue (skill_activation_requests) instead of
|
||||
* being applied immediately. Platform-side manual work (hardware
|
||||
* provisioning, third-party account setup, DNS, etc.) happens
|
||||
* between request and approval, so we keep the tenant out of the
|
||||
* spec until that work is done and the operator would otherwise
|
||||
* fail to reconcile.
|
||||
*
|
||||
* Platform admins bypass the gate (direct PATCH from /admin still
|
||||
* applies immediately). Disable is always direct — there's no
|
||||
* gate on turning a skill off.
|
||||
*
|
||||
* Orthogonal to `requiresSecrets` and `customProvisioning`. A skill
|
||||
* can have all three: customer provides credentials, the secrets
|
||||
* are stored, the activation request lands in the admin queue,
|
||||
* admin does the manual work, then approves.
|
||||
*/
|
||||
requiresManualSetup?: boolean;
|
||||
}
|
||||
|
||||
export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
|
||||
Reference in New Issue
Block a user