Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/services/admin/admin-sales-data.server.ts
T

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