186 lines
5.9 KiB
TypeScript
186 lines
5.9 KiB
TypeScript
/**
|
|
* Server-side helpers for `MarketingIntegration` (Phase 05 #79).
|
|
*
|
|
* `configJson` stores the raw object — but every string field whose key
|
|
* matches an entry in {@link SENSITIVE_KEYS} is encrypted at rest with
|
|
* AES-256-GCM via the shared CryptoApi helper. Round-trip decryption
|
|
* happens on read so call-sites operate on plain configs.
|
|
*
|
|
* The admin UI never re-displays decrypted secrets; on edit the field
|
|
* is masked + replaced only when the operator types a new value.
|
|
*/
|
|
|
|
import CryptoApi from "~/utils/api/.server/CryptoApi";
|
|
import { db } from "~/utils/db.server";
|
|
import { Prisma } from "@toolctrl/db/prisma-browser";
|
|
|
|
export type MarketingIntegrationType = "slack" | "discord" | "mailchimp" | "hubspot" | "brevo" | "webhook";
|
|
|
|
export interface MarketingIntegrationRecord {
|
|
id: string;
|
|
type: MarketingIntegrationType;
|
|
name: string;
|
|
enabled: boolean;
|
|
triggerEvents: string[];
|
|
config: Record<string, unknown>;
|
|
lastDeliveredAt: Date | null;
|
|
lastError: string | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
const SENSITIVE_KEYS = new Set([
|
|
"webhookUrl",
|
|
"apiKey",
|
|
"accessToken",
|
|
"signingSecret",
|
|
]);
|
|
|
|
const CRYPTO_TAG = "enc:";
|
|
|
|
function encryptSensitive(config: Record<string, unknown>): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(config)) {
|
|
if (SENSITIVE_KEYS.has(k) && typeof v === "string" && v.length > 0 && !v.startsWith(CRYPTO_TAG)) {
|
|
out[k] = `${CRYPTO_TAG}${CryptoApi.encrypt(v)}`;
|
|
} else {
|
|
out[k] = v;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function decryptSensitive(config: Record<string, unknown>): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(config)) {
|
|
if (SENSITIVE_KEYS.has(k) && typeof v === "string" && v.startsWith(CRYPTO_TAG)) {
|
|
try {
|
|
out[k] = CryptoApi.decrypt(v.slice(CRYPTO_TAG.length));
|
|
} catch {
|
|
out[k] = "";
|
|
}
|
|
} else {
|
|
out[k] = v;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Mask secret values for admin display. Returns the input config with
|
|
* every {@link SENSITIVE_KEYS} value replaced by `***`.
|
|
*/
|
|
export function maskConfig(config: Record<string, unknown>): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(config)) {
|
|
out[k] = SENSITIVE_KEYS.has(k) && typeof v === "string" && v.length > 0 ? "***" : v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function listMarketingIntegrations(): Promise<MarketingIntegrationRecord[]> {
|
|
const rows = await db.marketingIntegration.findMany({
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
type: r.type as MarketingIntegrationType,
|
|
name: r.name,
|
|
enabled: r.enabled,
|
|
triggerEvents: r.triggerEvents,
|
|
config: decryptSensitive(asObject(r.configJson)),
|
|
lastDeliveredAt: r.lastDeliveredAt,
|
|
lastError: r.lastError,
|
|
createdAt: r.createdAt,
|
|
updatedAt: r.updatedAt,
|
|
}));
|
|
}
|
|
|
|
export async function getMarketingIntegrationDecrypted(id: string): Promise<MarketingIntegrationRecord | null> {
|
|
const r = await db.marketingIntegration.findUnique({ where: { id } });
|
|
if (!r) return null;
|
|
return {
|
|
id: r.id,
|
|
type: r.type as MarketingIntegrationType,
|
|
name: r.name,
|
|
enabled: r.enabled,
|
|
triggerEvents: r.triggerEvents,
|
|
config: decryptSensitive(asObject(r.configJson)),
|
|
lastDeliveredAt: r.lastDeliveredAt,
|
|
lastError: r.lastError,
|
|
createdAt: r.createdAt,
|
|
updatedAt: r.updatedAt,
|
|
};
|
|
}
|
|
|
|
export interface CreateMarketingIntegrationInput {
|
|
type: MarketingIntegrationType;
|
|
name: string;
|
|
enabled?: boolean;
|
|
triggerEvents: string[];
|
|
config: Record<string, unknown>;
|
|
}
|
|
|
|
export async function createMarketingIntegration(input: CreateMarketingIntegrationInput): Promise<string> {
|
|
const row = await db.marketingIntegration.create({
|
|
data: {
|
|
type: input.type,
|
|
name: input.name,
|
|
enabled: input.enabled ?? true,
|
|
triggerEvents: input.triggerEvents,
|
|
configJson: encryptSensitive(input.config) as Prisma.InputJsonValue,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
return row.id;
|
|
}
|
|
|
|
export interface UpdateMarketingIntegrationInput {
|
|
name?: string;
|
|
enabled?: boolean;
|
|
triggerEvents?: string[];
|
|
/** Partial config patch — only keys present overwrite. */
|
|
config?: Record<string, unknown>;
|
|
}
|
|
|
|
export async function updateMarketingIntegration(id: string, input: UpdateMarketingIntegrationInput): Promise<void> {
|
|
const data: Prisma.MarketingIntegrationUpdateInput = {};
|
|
if (input.name !== undefined) data.name = input.name;
|
|
if (input.enabled !== undefined) data.enabled = input.enabled;
|
|
if (input.triggerEvents !== undefined) data.triggerEvents = input.triggerEvents;
|
|
if (input.config !== undefined) {
|
|
// Merge existing config with patch then re-encrypt.
|
|
const existing = await db.marketingIntegration.findUnique({ where: { id }, select: { configJson: true } });
|
|
if (existing) {
|
|
const decrypted = decryptSensitive(asObject(existing.configJson));
|
|
const merged = { ...decrypted, ...input.config };
|
|
data.configJson = encryptSensitive(merged) as Prisma.InputJsonValue;
|
|
} else {
|
|
data.configJson = encryptSensitive(input.config) as Prisma.InputJsonValue;
|
|
}
|
|
}
|
|
await db.marketingIntegration.update({ where: { id }, data });
|
|
}
|
|
|
|
export async function deleteMarketingIntegration(id: string): Promise<void> {
|
|
await db.marketingIntegration.delete({ where: { id } });
|
|
}
|
|
|
|
export async function recordIntegrationDelivery(
|
|
id: string,
|
|
result: { ok: boolean; errorTag?: string | null }
|
|
): Promise<void> {
|
|
await db.marketingIntegration.update({
|
|
where: { id },
|
|
data: {
|
|
lastDeliveredAt: result.ok ? new Date() : undefined,
|
|
lastError: result.ok ? null : result.errorTag ?? "unknown_error",
|
|
},
|
|
});
|
|
}
|
|
|
|
function asObject(v: unknown): Record<string, unknown> {
|
|
if (v && typeof v === "object" && !Array.isArray(v)) return v as Record<string, unknown>;
|
|
return {};
|
|
}
|