293 lines
9.5 KiB
TypeScript
293 lines
9.5 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
import type { AdminPermissionName } from "@hivegrid/db/admin-permissions";
|
|
import { adminHasPermission } from "@hivegrid/db/admin-rbac.server";
|
|
import {
|
|
createAdminSalesDataService,
|
|
createPrismaAdminSalesDataRepository,
|
|
previewAdminSalesContactsCsv,
|
|
} from "@hivegrid/db/admin-sales-data.server";
|
|
import {
|
|
buildAdminSalesContactsExportJob,
|
|
buildAdminSalesContactsImportJob,
|
|
enqueueJob,
|
|
queueNames,
|
|
resolveRedisConnectionOptionsFromProcessEnv,
|
|
type TypedQueue,
|
|
} from "@hivegrid/queue";
|
|
|
|
import { runAsAdminIdentity } from "~/services/admin/admin-identity.server";
|
|
import {
|
|
extractWebActorIp,
|
|
extractWebActorUserAgent,
|
|
extractWebRequestId,
|
|
} from "~/utils/audit/withAuditContext";
|
|
import { db } from "~/utils/db.server";
|
|
import { NOT_FOUND_ENVELOPE } from "~/utils/errors.server";
|
|
import {
|
|
deleteFile,
|
|
resolveStorageBucket,
|
|
storeFile,
|
|
} from "~/utils/integrations/storageService";
|
|
import { createMediaDownloadUrl } from "~/utils/integrations/mediaSigning.server";
|
|
|
|
const MAX_IMPORT_BYTES = 5 * 1024 * 1024;
|
|
|
|
type AdminSalesDataPermission = Extract<
|
|
AdminPermissionName,
|
|
| "admin_sales.crm.read"
|
|
| "admin_sales.crm.write"
|
|
| "admin_sales.crm.export"
|
|
>;
|
|
|
|
type ImportQueue = TypedQueue<typeof queueNames.imports>;
|
|
|
|
export type AdminSalesDataOperatorContext = {
|
|
readonly adminUserId: string;
|
|
readonly email: string;
|
|
readonly canWrite: boolean;
|
|
readonly canExport: boolean;
|
|
readonly service: ReturnType<typeof createAdminSalesDataService>;
|
|
readonly actor: (reason: string) => {
|
|
readonly adminUserId: string;
|
|
readonly email: string;
|
|
readonly reason: string;
|
|
readonly requestId: string | null;
|
|
readonly ip: string | null;
|
|
readonly userAgent: string | null;
|
|
};
|
|
};
|
|
|
|
export async function runAsAdminSalesDataOperator<T>(
|
|
request: Request,
|
|
permission: AdminSalesDataPermission,
|
|
operation: (context: AdminSalesDataOperatorContext) => Promise<T>
|
|
): Promise<T> {
|
|
return runAsAdminIdentity(request, permission, async (session) => {
|
|
const admin = await db.adminUser.findFirst({
|
|
where: { id: session.adminUserId, status: "active" },
|
|
select: { id: true, email: true },
|
|
});
|
|
if (!admin) throw Response.json(NOT_FOUND_ENVELOPE, { status: 404 });
|
|
const authorize = async (requestedPermission: AdminPermissionName) => {
|
|
if (!(await adminHasPermission(admin.id, requestedPermission, db))) {
|
|
throw Response.json(NOT_FOUND_ENVELOPE, { status: 404 });
|
|
}
|
|
};
|
|
const [canWrite, canExport] = await Promise.all([
|
|
adminHasPermission(admin.id, "admin_sales.crm.write", db),
|
|
adminHasPermission(admin.id, "admin_sales.crm.export", db),
|
|
]);
|
|
const auditBase = {
|
|
adminUserId: admin.id,
|
|
email: admin.email,
|
|
requestId: extractWebRequestId(request),
|
|
ip: extractWebActorIp(request),
|
|
userAgent: extractWebActorUserAgent(request),
|
|
};
|
|
return operation({
|
|
adminUserId: admin.id,
|
|
email: admin.email,
|
|
canWrite,
|
|
canExport,
|
|
service: createAdminSalesDataService({
|
|
repository: createPrismaAdminSalesDataRepository(db),
|
|
authorize,
|
|
}),
|
|
actor: (reason) => ({ ...auditBase, reason }),
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function prepareAdminSalesContactsImport(
|
|
input: {
|
|
readonly file: File;
|
|
readonly reason: string;
|
|
readonly context: AdminSalesDataOperatorContext;
|
|
},
|
|
dependencies: {
|
|
readonly bucket?: () => string;
|
|
readonly store?: typeof storeFile;
|
|
readonly remove?: typeof deleteFile;
|
|
readonly id?: () => string;
|
|
} = {}
|
|
) {
|
|
const fileName = input.file.name.trim();
|
|
if (!fileName.toLocaleLowerCase("en").endsWith(".csv")) {
|
|
throw new Error("Admin sales contact import requires a CSV file.");
|
|
}
|
|
if (input.file.size <= 0 || input.file.size > MAX_IMPORT_BYTES) {
|
|
throw new Error("Admin sales contact import exceeds the 5 MB limit.");
|
|
}
|
|
const bytes = new Uint8Array(await input.file.arrayBuffer());
|
|
if (bytes.byteLength > MAX_IMPORT_BYTES) {
|
|
throw new Error("Admin sales contact import exceeds the 5 MB limit.");
|
|
}
|
|
const csvText = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
const preview = previewAdminSalesContactsCsv(csvText);
|
|
if (
|
|
!preview.suggestedMapping.some((entry) => entry.endsWith("=firstName")) ||
|
|
!preview.suggestedMapping.some((entry) => entry.endsWith("=lastName"))
|
|
) {
|
|
throw new Error(
|
|
"Admin sales contact import requires recognizable first and last name columns."
|
|
);
|
|
}
|
|
|
|
const bucket = (dependencies.bucket ?? resolveStorageBucket)();
|
|
const storageKey = `admin-sales/imports/${input.context.adminUserId}/${(dependencies.id ?? randomUUID)()}.csv`;
|
|
const store = dependencies.store ?? storeFile;
|
|
const remove = dependencies.remove ?? deleteFile;
|
|
await store({
|
|
bucket,
|
|
id: storageKey,
|
|
content: `data:text/csv;base64,${Buffer.from(bytes).toString("base64")}`,
|
|
throwOnFailure: true,
|
|
scan: {
|
|
context: "formUpload",
|
|
declaredMimeType: "text/csv",
|
|
filename: fileName,
|
|
requireMalwareScan: true,
|
|
},
|
|
});
|
|
try {
|
|
const job = await input.context.service.createImportJob({
|
|
fileName,
|
|
sourceBucket: bucket,
|
|
sourceKey: storageKey,
|
|
sourceHeaders: preview.headers,
|
|
fieldMapping: preview.suggestedMapping,
|
|
idempotencyKey: `admin-sales-import:${input.context.adminUserId}:${storageKey}`,
|
|
actor: input.context.actor(input.reason),
|
|
});
|
|
return { job, preview };
|
|
} catch (error) {
|
|
await remove({ bucket, key: storageKey }).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function confirmAdminSalesContactsImport(input: {
|
|
readonly importJobId: string;
|
|
readonly reason: string;
|
|
readonly context: AdminSalesDataOperatorContext;
|
|
readonly createQueue?: () => Promise<ImportQueue | undefined>;
|
|
}) {
|
|
const actor = input.context.actor(input.reason);
|
|
const job = await input.context.service.getImportJobForQueue({
|
|
jobId: input.importJobId,
|
|
actor,
|
|
});
|
|
if (!job) throw new Error("Admin sales import job was not found.");
|
|
const queue = await (input.createQueue ?? createAdminSalesDataQueue)();
|
|
if (!queue) {
|
|
throw new Error("Admin sales import queue is not available.");
|
|
}
|
|
const descriptor = buildAdminSalesContactsImportJob({
|
|
importJobId: job.id,
|
|
requestedByAdminUserId: job.requestedByAdminUserId,
|
|
requestId: actor.requestId ?? undefined,
|
|
});
|
|
await enqueueJob(queue, descriptor, {
|
|
tenantId: null,
|
|
idempotencyKey: `admin-sales-import:${job.id}`,
|
|
});
|
|
await input.context.service.markImportJobQueued({
|
|
jobId: job.id,
|
|
actor,
|
|
});
|
|
return job.id;
|
|
}
|
|
|
|
export async function requestAdminSalesContactsExport(input: {
|
|
readonly contactIds: readonly string[];
|
|
readonly reason: string;
|
|
readonly context: AdminSalesDataOperatorContext;
|
|
readonly createQueue?: () => Promise<ImportQueue | undefined>;
|
|
readonly requestId?: string;
|
|
}) {
|
|
const actor = input.context.actor(input.reason);
|
|
const requestId = input.requestId ?? randomUUID();
|
|
const job = await input.context.service.createExportJob({
|
|
selectedContactIds: input.contactIds,
|
|
idempotencyKey: `admin-sales-export:${input.context.adminUserId}:${requestId}`,
|
|
actor,
|
|
});
|
|
const queue = await (input.createQueue ?? createAdminSalesDataQueue)();
|
|
if (!queue) throw new Error("Admin sales export queue is not available.");
|
|
const descriptor = buildAdminSalesContactsExportJob({
|
|
exportJobId: job.id,
|
|
requestedByAdminUserId: job.requestedByAdminUserId,
|
|
requestId: actor.requestId ?? undefined,
|
|
});
|
|
await enqueueJob(queue, descriptor, {
|
|
tenantId: null,
|
|
idempotencyKey: `admin-sales-export:${job.id}`,
|
|
});
|
|
await input.context.service.markExportJobQueued({
|
|
jobId: job.id,
|
|
actor,
|
|
});
|
|
return job.id;
|
|
}
|
|
|
|
export async function resolveAdminSalesDataDownload(input: {
|
|
readonly jobId: string;
|
|
readonly kind: "import-error" | "export";
|
|
}): Promise<string> {
|
|
const bucket = resolveStorageBucket();
|
|
if (input.kind === "import-error") {
|
|
const job = await db.adminImportJob.findFirst({
|
|
where: {
|
|
id: input.jobId,
|
|
status: "completed_with_errors",
|
|
errorReportBucket: bucket,
|
|
errorReportKey: `admin-sales/import-errors/${input.jobId}.csv`,
|
|
},
|
|
select: { errorReportKey: true },
|
|
});
|
|
if (!job?.errorReportKey) {
|
|
throw Response.json(NOT_FOUND_ENVELOPE, { status: 404 });
|
|
}
|
|
return createMediaDownloadUrl({
|
|
key: job.errorReportKey,
|
|
filename: `admin-sales-importfehler-${input.jobId}.csv`,
|
|
ttlSeconds: 60,
|
|
});
|
|
}
|
|
const job = await db.adminExportJob.findFirst({
|
|
where: {
|
|
id: input.jobId,
|
|
status: "completed",
|
|
artifactBucket: bucket,
|
|
artifactKey: `admin-sales/exports/${input.jobId}.csv`,
|
|
expiresAt: { gt: new Date() },
|
|
},
|
|
select: { artifactKey: true },
|
|
});
|
|
if (!job?.artifactKey) {
|
|
throw Response.json(NOT_FOUND_ENVELOPE, { status: 404 });
|
|
}
|
|
return createMediaDownloadUrl({
|
|
key: job.artifactKey,
|
|
filename: "admin-sales-contacts.csv",
|
|
ttlSeconds: 60,
|
|
});
|
|
}
|
|
|
|
async function createAdminSalesDataQueue(): Promise<ImportQueue | undefined> {
|
|
if (
|
|
process.env.IMPORT_QUEUE_ENABLED !== "true" ||
|
|
process.env.IMPORT_QUEUE_RUNTIME_READY !== "true"
|
|
) {
|
|
return undefined;
|
|
}
|
|
const connection = resolveRedisConnectionOptionsFromProcessEnv();
|
|
if (!connection) return undefined;
|
|
const runtimeModule = "@hivegrid/queue/runtime";
|
|
const { createQueue } = (await import(
|
|
/* @vite-ignore */ runtimeModule
|
|
)) as typeof import("@hivegrid/queue/runtime");
|
|
return createQueue(queueNames.imports, { connection });
|
|
}
|