Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/hrSurveyService.server.ts
T

201 lines
12 KiB
TypeScript

// P17 HR-Hub workflows (AK-P17b) — surveys domain service.
//
// Surveys may be anonymous. For an anonymous survey the submission stores a
// non-reversible per-(tenant,survey,user) hash (`submitterHash`) instead of the
// user id, which enforces one-submission-per-user WITHOUT allowing
// re-identification. Aggregated results are gated by hr.surveys.results at the
// route layer. Every tenant access runs inside withTenantRLS().
import { createHash } from "node:crypto";
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
import { requireValidHrAudienceWithTx } from "./hrAudience.server";
import { HrServiceError } from "./hrService.server";
const HR_AUDIT_MODULE = "hr";
export type HrSurveyItemType = "text" | "rating_5" | "nps" | "choice" | "boolean" | "date";
export type HrSurveyStatus = "draft" | "open" | "closed";
export type HrSurveyResultVisibility = "admins" | "participants" | "everyone";
export type HrAudienceKind = "all" | "team" | "location";
export type SurveyRow = {
id: string;
tenantId: string;
authorUserId: string;
title: string;
description: string | null;
isAnonymous: boolean;
resultVisibility: HrSurveyResultVisibility;
audienceKind: HrAudienceKind;
audienceRef: string | null;
status: HrSurveyStatus;
opensAt: Date | null;
closesAt: Date | null;
createdAt: Date;
updatedAt: Date;
archivedAt: Date | null;
};
export type SurveyItemRow = { id: string; surveyId: string; itemType: HrSurveyItemType; prompt: string; required: boolean; options: string[]; sortOrder: number };
/** Non-reversible submitter hash for anonymous dedup. Includes the tenant so the
* same user id in different tenants never collides; not reversible to the user. */
function submitterHashFor(tenantId: string, surveyId: string, userId: string): string {
return createHash("sha256").update(`${tenantId}:${surveyId}:${userId}`).digest("hex");
}
export type CreateSurveyInput = { tenantId: string; authorUserId: string; title: string; description?: string | null; isAnonymous?: boolean; resultVisibility?: HrSurveyResultVisibility; audienceKind?: HrAudienceKind; audienceRef?: string | null; isDemoData?: boolean };
export async function createSurvey(input: CreateSurveyInput): Promise<SurveyRow> {
const title = input.title.trim();
if (!title) throw new HrServiceError("hr.error.surveyTitleRequired");
const audienceKind = input.audienceKind ?? "all";
const audienceRef = audienceKind === "all" ? null : (input.audienceRef ?? null);
const created = (await withTenantRLS(input.tenantId, async (tx) => {
await requireValidHrAudienceWithTx(tx, { tenantId: input.tenantId, kind: audienceKind, ref: audienceRef });
return tx.hrSurvey.create({
data: { tenantId: input.tenantId, authorUserId: input.authorUserId, title: title.slice(0, 255), description: input.description ?? null, isAnonymous: input.isAnonymous ?? false, resultVisibility: input.resultVisibility ?? "admins", audienceKind, audienceRef, isDemoData: input.isDemoData ?? false },
});
})) as SurveyRow;
await createAuditEvent({ tenantId: input.tenantId, actorId: input.authorUserId, actorType: "user", action: "HR_SURVEY_CREATE", targetType: "HrSurvey", targetId: created.id, sourceModule: HR_AUDIT_MODULE, outcome: "success" });
return created;
}
export type UpdateSurveyInput = Partial<Omit<CreateSurveyInput, "tenantId" | "authorUserId">> & { tenantId: string; actorUserId: string; surveyId: string };
export async function updateSurvey(input: UpdateSurveyInput): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.hrSurvey.findFirst({
where: { id: input.surveyId, tenantId: input.tenantId },
select: { id: true, audienceKind: true, audienceRef: true },
});
if (!existing) throw new CatalogError(foundationErrors.notFound, { surveyId: input.surveyId });
const audienceKind = input.audienceKind ?? existing.audienceKind;
const audienceRef = audienceKind === "all" ? null : input.audienceRef !== undefined ? input.audienceRef : existing.audienceRef;
await requireValidHrAudienceWithTx(tx, { tenantId: input.tenantId, kind: audienceKind, ref: audienceRef });
await tx.hrSurvey.update({
where: { id: input.surveyId },
data: {
...(input.title !== undefined ? { title: input.title.trim().slice(0, 255) } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
...(input.isAnonymous !== undefined ? { isAnonymous: input.isAnonymous } : {}),
...(input.resultVisibility !== undefined ? { resultVisibility: input.resultVisibility } : {}),
audienceKind,
audienceRef,
},
});
});
}
export async function setSurveyStatus(input: { tenantId: string; actorUserId: string; surveyId: string; status: HrSurveyStatus }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) =>
tx.hrSurvey.updateMany({
where: { id: input.surveyId, tenantId: input.tenantId },
data: { status: input.status, ...(input.status === "open" ? { opensAt: new Date() } : {}), ...(input.status === "closed" ? { closesAt: new Date() } : {}) },
}),
);
await createAuditEvent({ tenantId: input.tenantId, actorId: input.actorUserId, actorType: "user", action: "HR_SURVEY_STATUS", targetType: "HrSurvey", targetId: input.surveyId, sourceModule: HR_AUDIT_MODULE, outcome: "success", metadata: { status: input.status } });
}
export async function archiveSurvey(input: { tenantId: string; actorUserId: string; surveyId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) => tx.hrSurvey.updateMany({ where: { id: input.surveyId, tenantId: input.tenantId }, data: { archivedAt: new Date(), status: "closed" } }));
}
export async function addItem(input: { tenantId: string; surveyId: string; itemType: HrSurveyItemType; prompt: string; required?: boolean; options?: string[]; sortOrder?: number }): Promise<SurveyItemRow> {
const prompt = input.prompt.trim();
if (!prompt) throw new HrServiceError("hr.error.surveyPromptRequired");
if (input.itemType === "choice" && (!input.options || input.options.length === 0)) throw new HrServiceError("hr.error.surveyChoiceNeedsOptions");
const created = await withTenantRLS(input.tenantId, (tx) =>
tx.hrSurveyItem.create({ data: { tenantId: input.tenantId, surveyId: input.surveyId, itemType: input.itemType, prompt: prompt.slice(0, 1000), required: input.required ?? false, options: input.options ?? [], sortOrder: input.sortOrder ?? 0 } }),
);
return created as SurveyItemRow;
}
export async function removeItem(input: { tenantId: string; surveyId: string; itemId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) => tx.hrSurveyItem.deleteMany({ where: { id: input.itemId, tenantId: input.tenantId, surveyId: input.surveyId } }));
}
export async function listSurveys(tenantId: string, opts?: { includeArchived?: boolean }): Promise<SurveyRow[]> {
const rows = await withTenantRLS(tenantId, (tx) => tx.hrSurvey.findMany({ where: { tenantId, ...(opts?.includeArchived ? {} : { archivedAt: null }) }, orderBy: { createdAt: "desc" } }));
return rows as SurveyRow[];
}
export async function getSurveyWithItems(tenantId: string, surveyId: string): Promise<{ survey: SurveyRow; items: SurveyItemRow[] }> {
return withTenantRLS(tenantId, async (tx) => {
const survey = await tx.hrSurvey.findFirst({ where: { id: surveyId, tenantId, archivedAt: null } });
if (!survey) throw new CatalogError(foundationErrors.notFound, { surveyId });
const items = await tx.hrSurveyItem.findMany({ where: { tenantId, surveyId }, orderBy: { sortOrder: "asc" } });
return { survey: survey as SurveyRow, items: items as SurveyItemRow[] };
});
}
/** True when the caller already submitted (by identity or anonymous hash). */
export async function hasSubmitted(tenantId: string, surveyId: string, userId: string, isAnonymous: boolean): Promise<boolean> {
const where = isAnonymous ? { tenantId, surveyId, submitterHash: submitterHashFor(tenantId, surveyId, userId) } : { tenantId, surveyId, submitterUserId: userId };
const existing = await withTenantRLS(tenantId, (tx) => tx.hrSurveySubmission.findFirst({ where, select: { id: true } }));
return Boolean(existing);
}
export type SubmitResponseInput = { tenantId: string; surveyId: string; userId: string; answers: Record<string, unknown> };
export async function submitResponse(input: SubmitResponseInput): Promise<void> {
const { survey } = await getSurveyWithItems(input.tenantId, input.surveyId);
if (survey.status !== "open") throw new HrServiceError("hr.error.surveyNotOpen");
const submitterUserId = survey.isAnonymous ? null : input.userId;
const submitterHash = survey.isAnonymous ? submitterHashFor(input.tenantId, input.surveyId, input.userId) : null;
try {
await withTenantRLS(input.tenantId, (tx) =>
tx.hrSurveySubmission.create({ data: { tenantId: input.tenantId, surveyId: input.surveyId, submitterUserId, submitterHash, answers: input.answers as object } }),
);
} catch {
// Unique (surveyId, submitterUserId|submitterHash) violation ⇒ already done.
throw new HrServiceError("hr.error.surveyAlreadySubmitted");
}
// Audit WITHOUT the user id for anonymous surveys (no re-identification).
await createAuditEvent({ tenantId: input.tenantId, actorId: survey.isAnonymous ? null : input.userId, actorType: survey.isAnonymous ? "system" : "user", action: "HR_SURVEY_SUBMIT", targetType: "HrSurvey", targetId: input.surveyId, sourceModule: HR_AUDIT_MODULE, outcome: "success" });
}
export type SurveyItemResult = { itemId: string; itemType: HrSurveyItemType; prompt: string; responseCount: number; distribution: Record<string, number>; average: number | null; textResponses: string[] };
/** Aggregate results per item. Caller must hold hr.surveys.results (route gate).
* Returns raw numbers only — chart rendering is the VM/UI's job. */
export async function getResults(tenantId: string, surveyId: string): Promise<{ submissionCount: number; items: SurveyItemResult[] }> {
const { items } = await getSurveyWithItems(tenantId, surveyId);
const submissions = await withTenantRLS(tenantId, (tx) => tx.hrSurveySubmission.findMany({ where: { tenantId, surveyId }, select: { answers: true } }));
const results: SurveyItemResult[] = items.map((item) => {
const distribution: Record<string, number> = {};
const textResponses: string[] = [];
let sum = 0;
let numericCount = 0;
let responseCount = 0;
for (const submission of submissions) {
const answers = (submission.answers ?? {}) as Record<string, unknown>;
const value = answers[item.id];
if (value === undefined || value === null || value === "") continue;
responseCount += 1;
if (item.itemType === "text" || item.itemType === "date") {
textResponses.push(String(value).slice(0, 500));
} else if (item.itemType === "boolean") {
const key = value ? "true" : "false";
distribution[key] = (distribution[key] ?? 0) + 1;
} else if (item.itemType === "choice") {
const key = String(value);
distribution[key] = (distribution[key] ?? 0) + 1;
} else {
// rating_5 / nps — numeric distribution + average.
const num = Number(value);
if (Number.isFinite(num)) {
const key = String(num);
distribution[key] = (distribution[key] ?? 0) + 1;
sum += num;
numericCount += 1;
}
}
}
return { itemId: item.id, itemType: item.itemType, prompt: item.prompt, responseCount, distribution, average: numericCount > 0 ? sum / numericCount : null, textResponses };
});
return { submissionCount: submissions.length, items: results };
}