Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/leads/marketingIntegrations.db.server.ts
T

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 {};
}