201 lines
12 KiB
TypeScript
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 };
|
|
}
|