diff --git a/Pulse360_Runtime_Bug_Fixes_2026-08-08.md b/Pulse360_Runtime_Bug_Fixes_2026-08-08.md index 446fc7e..c2fb5c3 100644 --- a/Pulse360_Runtime_Bug_Fixes_2026-08-08.md +++ b/Pulse360_Runtime_Bug_Fixes_2026-08-08.md @@ -11,6 +11,8 @@ - MVP 2 needs a separate System Admin persona for platform adoption, audit, AI usage, and behavioral metadata monitoring without exposing review scores/comments. - HR report export and self-improvement CSV export attempted to write files to a hard-coded server-side Windows path. - Users needed a self-service way to edit basic profile details. +- MVP 2 analytics needed cleaner event tables instead of storing every downstream metric only in `audit_log`. +- HR needed editable workforce dimensions for job grade, employment type, conversion-hire status, gender, and ethnicity. ## Fixes Applied @@ -31,6 +33,9 @@ - CSV export now creates a browser-side CSV download instead of writing to `C:\Users\...`. - Added `/profile` and `/api/profile` so users can edit first name, last name, email, and job title. - Removed `next/font/google` usage so production builds do not depend on fetching Google Fonts. +- Added dedicated event projection tables for auth, profile updates, AI usage, AI HITL decisions, nominations, and reviews while keeping `audit_log` as the immutable governance feed. +- Added HR-editable employee workforce fields: `employment_type`, `conversion_hire_status`, `gender`, and `ethnicity`. +- Updated the System Admin dashboard to read login and AI token metrics from the dedicated event tables and show aggregated gender/employment context without exposing ethnicity. ## Verification @@ -51,3 +56,5 @@ - Confirm the HR report preview includes score cards and analytics bar charts before approval. - Download a self-improvement plan and confirm the file is a `.csv`. - Visit My Profile and confirm basic details can be saved. +- As HR Admin, create or edit an employee and confirm job grade, employment type, conversion-hire status, gender, and ethnicity persist. +- As System Admin, confirm adoption, AI token, gender, and employment mix cards update without showing employee-level ethnicity. diff --git a/README.md b/README.md index 4029a31..64b1b97 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Scripts are applied in order on first container start: | `07_seed_employees.sql` | 57 synthetic employees with manager relationships | | `08_add_password_hash.sql` | bcrypt password hashes for all 57 employees | -### Data Model (11 tables) +### Data Model (17 tables) | Table | Purpose | |---|---| @@ -196,7 +196,15 @@ Scripts are applied in order on first container start: | `review` | Submitted reviews per reviewer per subject | | `review_rating` | Individual question answers (score or text) | | `review_result` | Computed aggregates written at CALCULATION phase | -| `audit_log` | Append-only event log | +| `audit_log` | Append-only raw governance event log | +| `auth_event` | Login success/failure projection for adoption and access monitoring | +| `profile_event` | Profile update projection showing which fields changed without exposing old/new values | +| `ai_usage_event` | AI feature usage, status, model, and token metadata | +| `ai_hitl_decision` | Human-in-the-loop accept/edit/discard decisions for AI-generated content | +| `nomination_event` | Nomination lifecycle events with employee/reviewer and department snapshots | +| `review_event` | Review draft/submission metadata without review comments or scores | + +The `employee` table now also carries HR-managed workforce dimensions: job grade, employment type, conversion-hire status, gender, and ethnicity. System Admin analytics read from dedicated event tables and aggregated workforce dimensions. Ethnicity is intentionally kept for HR demographic reporting and is not displayed on the System Admin dashboard. ### Verify the database diff --git a/pulse360/prisma/migrations/000004_employee_demographics_and_event_tables/migration.sql b/pulse360/prisma/migrations/000004_employee_demographics_and_event_tables/migration.sql new file mode 100644 index 0000000..36e78ac --- /dev/null +++ b/pulse360/prisma/migrations/000004_employee_demographics_and_event_tables/migration.sql @@ -0,0 +1,168 @@ +CREATE TYPE "employment_type" AS ENUM ('INTERNSHIP', 'LEARNERSHIP', 'CONTRACT', 'PERMANENT'); +CREATE TYPE "conversion_hire_status" AS ENUM ('NO', 'YES', 'PENDING_DECISION', 'REVIEWED'); +CREATE TYPE "gender" AS ENUM ('WOMAN', 'MAN', 'NON_BINARY', 'OTHER', 'PREFER_NOT_TO_SAY'); +CREATE TYPE "ethnicity" AS ENUM ('BLACK', 'WHITE', 'COLOURED', 'ASIAN', 'INDIAN', 'OTHER', 'PREFER_NOT_TO_SAY'); +CREATE TYPE "auth_event_status" AS ENUM ('SUCCESS', 'FAILURE'); +CREATE TYPE "ai_feature" AS ENUM ('SUGGEST_COMMENTS', 'THEME_SUMMARY', 'IMPROVEMENT_PLAN', 'ANALYTICS_REPORT'); +CREATE TYPE "ai_usage_status" AS ENUM ('SUCCESS', 'ERROR'); +CREATE TYPE "ai_hitl_decision_type" AS ENUM ('ACCEPTED', 'EDITED', 'DISCARDED'); +CREATE TYPE "nomination_event_action" AS ENUM ('CREATED', 'REMOVED', 'SUBMITTED', 'APPROVED', 'REJECTED', 'BULK_APPROVED'); +CREATE TYPE "review_event_action" AS ENUM ('DRAFT_SAVED', 'SUBMITTED'); + +ALTER TABLE "employee" + ADD COLUMN "employment_type" "employment_type" NOT NULL DEFAULT 'PERMANENT', + ADD COLUMN "conversion_hire_status" "conversion_hire_status" NOT NULL DEFAULT 'NO', + ADD COLUMN "gender" "gender", + ADD COLUMN "ethnicity" "ethnicity"; + +CREATE TABLE "auth_event" ( + "id" BIGSERIAL NOT NULL, + "actor_id" INTEGER, + "email" TEXT, + "role" "employee_role", + "department_id" INTEGER, + "department_name" VARCHAR(100), + "status" "auth_event_status" NOT NULL, + "failure_reason" VARCHAR(80), + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "auth_event_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "profile_event" ( + "id" BIGSERIAL NOT NULL, + "actor_id" INTEGER NOT NULL, + "employee_id" INTEGER NOT NULL, + "changed_fields" JSONB NOT NULL, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "profile_event_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ai_usage_event" ( + "id" BIGSERIAL NOT NULL, + "actor_id" INTEGER, + "feature" "ai_feature" NOT NULL, + "model" VARCHAR(120), + "status" "ai_usage_status" NOT NULL, + "stub" BOOLEAN NOT NULL DEFAULT false, + "input_tokens" INTEGER NOT NULL DEFAULT 0, + "output_tokens" INTEGER NOT NULL DEFAULT 0, + "total_tokens" INTEGER NOT NULL DEFAULT 0, + "cycle_id" INTEGER, + "entity_type" VARCHAR(50), + "entity_id" INTEGER, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ai_usage_event_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ai_hitl_decision" ( + "id" BIGSERIAL NOT NULL, + "ai_usage_event_id" BIGINT, + "actor_id" INTEGER, + "feature" "ai_feature" NOT NULL, + "decision" "ai_hitl_decision_type" NOT NULL, + "cycle_id" INTEGER, + "entity_type" VARCHAR(50), + "entity_id" INTEGER, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ai_hitl_decision_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "nomination_event" ( + "id" BIGSERIAL NOT NULL, + "actor_id" INTEGER, + "nomination_id" INTEGER, + "cycle_id" INTEGER, + "employee_id" INTEGER, + "reviewer_id" INTEGER, + "action" "nomination_event_action" NOT NULL, + "previous_approval_status" "nomination_approval", + "approval_status" "nomination_approval", + "submission_status" "nomination_submission", + "employee_department_id" INTEGER, + "reviewer_department_id" INTEGER, + "employee_department_name" VARCHAR(100), + "reviewer_department_name" VARCHAR(100), + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "nomination_event_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "review_event" ( + "id" BIGSERIAL NOT NULL, + "actor_id" INTEGER, + "review_id" INTEGER, + "cycle_id" INTEGER, + "employee_id" INTEGER, + "reviewer_id" INTEGER, + "action" "review_event_action" NOT NULL, + "status" "review_status", + "rating_count" INTEGER NOT NULL DEFAULT 0, + "has_do_well_comment" BOOLEAN NOT NULL DEFAULT false, + "has_improve_comment" BOOLEAN NOT NULL DEFAULT false, + "has_attention_comment" BOOLEAN NOT NULL DEFAULT false, + "would_pick_for_team" BOOLEAN, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "review_event_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "auth_event_created_at_idx" ON "auth_event"("created_at"); +CREATE INDEX "auth_event_actor_id_created_at_idx" ON "auth_event"("actor_id", "created_at"); +CREATE INDEX "auth_event_status_created_at_idx" ON "auth_event"("status", "created_at"); + +CREATE INDEX "profile_event_created_at_idx" ON "profile_event"("created_at"); +CREATE INDEX "profile_event_actor_id_created_at_idx" ON "profile_event"("actor_id", "created_at"); +CREATE INDEX "profile_event_employee_id_created_at_idx" ON "profile_event"("employee_id", "created_at"); + +CREATE INDEX "ai_usage_event_created_at_idx" ON "ai_usage_event"("created_at"); +CREATE INDEX "ai_usage_event_actor_id_created_at_idx" ON "ai_usage_event"("actor_id", "created_at"); +CREATE INDEX "ai_usage_event_feature_created_at_idx" ON "ai_usage_event"("feature", "created_at"); +CREATE INDEX "ai_usage_event_cycle_id_created_at_idx" ON "ai_usage_event"("cycle_id", "created_at"); + +CREATE INDEX "ai_hitl_decision_created_at_idx" ON "ai_hitl_decision"("created_at"); +CREATE INDEX "ai_hitl_decision_actor_id_created_at_idx" ON "ai_hitl_decision"("actor_id", "created_at"); +CREATE INDEX "ai_hitl_decision_feature_created_at_idx" ON "ai_hitl_decision"("feature", "created_at"); +CREATE INDEX "ai_hitl_decision_decision_created_at_idx" ON "ai_hitl_decision"("decision", "created_at"); + +CREATE INDEX "nomination_event_created_at_idx" ON "nomination_event"("created_at"); +CREATE INDEX "nomination_event_action_created_at_idx" ON "nomination_event"("action", "created_at"); +CREATE INDEX "nomination_event_cycle_id_created_at_idx" ON "nomination_event"("cycle_id", "created_at"); +CREATE INDEX "nomination_event_employee_id_created_at_idx" ON "nomination_event"("employee_id", "created_at"); +CREATE INDEX "nomination_event_reviewer_id_created_at_idx" ON "nomination_event"("reviewer_id", "created_at"); + +CREATE INDEX "review_event_created_at_idx" ON "review_event"("created_at"); +CREATE INDEX "review_event_action_created_at_idx" ON "review_event"("action", "created_at"); +CREATE INDEX "review_event_cycle_id_created_at_idx" ON "review_event"("cycle_id", "created_at"); +CREATE INDEX "review_event_employee_id_created_at_idx" ON "review_event"("employee_id", "created_at"); +CREATE INDEX "review_event_reviewer_id_created_at_idx" ON "review_event"("reviewer_id", "created_at"); + +ALTER TABLE "auth_event" ADD CONSTRAINT "auth_event_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "employee"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "auth_event" ADD CONSTRAINT "auth_event_department_id_fkey" FOREIGN KEY ("department_id") REFERENCES "department"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "profile_event" ADD CONSTRAINT "profile_event_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "profile_event" ADD CONSTRAINT "profile_event_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employee"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "ai_usage_event" ADD CONSTRAINT "ai_usage_event_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "employee"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ai_usage_event" ADD CONSTRAINT "ai_usage_event_cycle_id_fkey" FOREIGN KEY ("cycle_id") REFERENCES "review_cycle"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "ai_hitl_decision" ADD CONSTRAINT "ai_hitl_decision_ai_usage_event_id_fkey" FOREIGN KEY ("ai_usage_event_id") REFERENCES "ai_usage_event"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ai_hitl_decision" ADD CONSTRAINT "ai_hitl_decision_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "employee"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ai_hitl_decision" ADD CONSTRAINT "ai_hitl_decision_cycle_id_fkey" FOREIGN KEY ("cycle_id") REFERENCES "review_cycle"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "nomination_event" ADD CONSTRAINT "nomination_event_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "employee"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "nomination_event" ADD CONSTRAINT "nomination_event_nomination_id_fkey" FOREIGN KEY ("nomination_id") REFERENCES "nomination"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "nomination_event" ADD CONSTRAINT "nomination_event_cycle_id_fkey" FOREIGN KEY ("cycle_id") REFERENCES "review_cycle"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "nomination_event" ADD CONSTRAINT "nomination_event_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employee"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "nomination_event" ADD CONSTRAINT "nomination_event_reviewer_id_fkey" FOREIGN KEY ("reviewer_id") REFERENCES "employee"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "nomination_event" ADD CONSTRAINT "nomination_event_employee_department_id_fkey" FOREIGN KEY ("employee_department_id") REFERENCES "department"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "nomination_event" ADD CONSTRAINT "nomination_event_reviewer_department_id_fkey" FOREIGN KEY ("reviewer_department_id") REFERENCES "department"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "review_event" ADD CONSTRAINT "review_event_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "employee"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "review_event" ADD CONSTRAINT "review_event_review_id_fkey" FOREIGN KEY ("review_id") REFERENCES "review"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "review_event" ADD CONSTRAINT "review_event_cycle_id_fkey" FOREIGN KEY ("cycle_id") REFERENCES "review_cycle"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "review_event" ADD CONSTRAINT "review_event_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employee"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "review_event" ADD CONSTRAINT "review_event_reviewer_id_fkey" FOREIGN KEY ("reviewer_id") REFERENCES "employee"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/pulse360/prisma/schema.prisma b/pulse360/prisma/schema.prisma index ff7f95c..ce84049 100644 --- a/pulse360/prisma/schema.prisma +++ b/pulse360/prisma/schema.prisma @@ -17,6 +17,95 @@ enum EmployeeRole { @@map("employee_role") } +enum EmploymentType { + INTERNSHIP + LEARNERSHIP + CONTRACT + PERMANENT + + @@map("employment_type") +} + +enum ConversionHireStatus { + NO + YES + PENDING_DECISION + REVIEWED + + @@map("conversion_hire_status") +} + +enum Gender { + WOMAN + MAN + NON_BINARY + OTHER + PREFER_NOT_TO_SAY + + @@map("gender") +} + +enum Ethnicity { + BLACK + WHITE + COLOURED + ASIAN + INDIAN + OTHER + PREFER_NOT_TO_SAY + + @@map("ethnicity") +} + +enum AuthEventStatus { + SUCCESS + FAILURE + + @@map("auth_event_status") +} + +enum AiFeature { + SUGGEST_COMMENTS + THEME_SUMMARY + IMPROVEMENT_PLAN + ANALYTICS_REPORT + + @@map("ai_feature") +} + +enum AiUsageStatus { + SUCCESS + ERROR + + @@map("ai_usage_status") +} + +enum AiHitlDecisionType { + ACCEPTED + EDITED + DISCARDED + + @@map("ai_hitl_decision_type") +} + +enum NominationEventAction { + CREATED + REMOVED + SUBMITTED + APPROVED + REJECTED + BULK_APPROVED + + @@map("nomination_event_action") +} + +enum ReviewEventAction { + DRAFT_SAVED + SUBMITTED + + @@map("review_event_action") +} + enum CyclePhase { DRAFT NOMINATE @@ -74,6 +163,9 @@ model Department { name String @unique @db.VarChar(100) description String? employees Employee[] + authEvents AuthEvent[] + nominationEventsAsEmployeeDept NominationEvent[] @relation("NominationEventEmployeeDepartment") + nominationEventsAsReviewerDept NominationEvent[] @relation("NominationEventReviewerDepartment") @@map("department") } @@ -86,6 +178,10 @@ model Employee { email String @unique jobTitle String? @db.VarChar(200) @map("job_title") jobGrade String? @db.VarChar(60) @map("job_grade") + employmentType EmploymentType @default(PERMANENT) @map("employment_type") + conversionHireStatus ConversionHireStatus @default(NO) @map("conversion_hire_status") + gender Gender? + ethnicity Ethnicity? passwordHash String? @map("password_hash") departmentId Int @map("department_id") managerId Int? @map("manager_id") @@ -105,6 +201,17 @@ model Employee { reviewsReceived Review[] @relation("ReviewEmployee") results ReviewResult[] auditActions AuditLog[] + authEvents AuthEvent[] + profileEventsActed ProfileEvent[] @relation("ProfileEventActor") + profileEventsReceived ProfileEvent[] @relation("ProfileEventEmployee") + aiUsageEvents AiUsageEvent[] + aiHitlDecisions AiHitlDecision[] + nominationEventsActed NominationEvent[] @relation("NominationEventActor") + nominationEventsAsEmployee NominationEvent[] @relation("NominationEventEmployee") + nominationEventsAsReviewer NominationEvent[] @relation("NominationEventReviewer") + reviewEventsActed ReviewEvent[] @relation("ReviewEventActor") + reviewEventsAsEmployee ReviewEvent[] @relation("ReviewEventEmployee") + reviewEventsAsReviewer ReviewEvent[] @relation("ReviewEventReviewer") @@map("employee") } @@ -126,6 +233,10 @@ model ReviewCycle { nominations Nomination[] reviews Review[] results ReviewResult[] + aiUsageEvents AiUsageEvent[] + aiHitlDecisions AiHitlDecision[] + nominationEvents NominationEvent[] + reviewEvents ReviewEvent[] @@map("review_cycle") } @@ -186,6 +297,7 @@ model Nomination { cycle ReviewCycle @relation(fields: [cycleId], references: [id], onDelete: Cascade) employee Employee @relation("NominationEmployee", fields: [employeeId], references: [id]) reviewer Employee @relation("NominationReviewer", fields: [reviewerId], references: [id]) + events NominationEvent[] @@unique([cycleId, employeeId, reviewerId]) @@map("nomination") @@ -207,6 +319,7 @@ model Review { reviewer Employee @relation("ReviewReviewer", fields: [reviewerId], references: [id]) employee Employee @relation("ReviewEmployee", fields: [employeeId], references: [id]) ratings ReviewRating[] + events ReviewEvent[] @@unique([cycleId, reviewerId, employeeId]) @@map("review") @@ -259,3 +372,156 @@ model AuditLog { @@map("audit_log") } + +model AuthEvent { + id BigInt @id @default(autoincrement()) + actorId Int? @map("actor_id") + email String? + role EmployeeRole? + departmentId Int? @map("department_id") + departmentName String? @db.VarChar(100) @map("department_name") + status AuthEventStatus + failureReason String? @db.VarChar(80) @map("failure_reason") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + actor Employee? @relation(fields: [actorId], references: [id], onDelete: SetNull) + department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull) + + @@index([createdAt]) + @@index([actorId, createdAt]) + @@index([status, createdAt]) + @@map("auth_event") +} + +model ProfileEvent { + id BigInt @id @default(autoincrement()) + actorId Int @map("actor_id") + employeeId Int @map("employee_id") + changedFields Json @map("changed_fields") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + actor Employee @relation("ProfileEventActor", fields: [actorId], references: [id]) + employee Employee @relation("ProfileEventEmployee", fields: [employeeId], references: [id]) + + @@index([createdAt]) + @@index([actorId, createdAt]) + @@index([employeeId, createdAt]) + @@map("profile_event") +} + +model AiUsageEvent { + id BigInt @id @default(autoincrement()) + actorId Int? @map("actor_id") + feature AiFeature + model String? @db.VarChar(120) + status AiUsageStatus + stub Boolean @default(false) + inputTokens Int @default(0) @map("input_tokens") + outputTokens Int @default(0) @map("output_tokens") + totalTokens Int @default(0) @map("total_tokens") + cycleId Int? @map("cycle_id") + entityType String? @db.VarChar(50) @map("entity_type") + entityId Int? @map("entity_id") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + actor Employee? @relation(fields: [actorId], references: [id], onDelete: SetNull) + cycle ReviewCycle? @relation(fields: [cycleId], references: [id], onDelete: SetNull) + decisions AiHitlDecision[] + + @@index([createdAt]) + @@index([actorId, createdAt]) + @@index([feature, createdAt]) + @@index([cycleId, createdAt]) + @@map("ai_usage_event") +} + +model AiHitlDecision { + id BigInt @id @default(autoincrement()) + aiUsageEventId BigInt? @map("ai_usage_event_id") + actorId Int? @map("actor_id") + feature AiFeature + decision AiHitlDecisionType + cycleId Int? @map("cycle_id") + entityType String? @db.VarChar(50) @map("entity_type") + entityId Int? @map("entity_id") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + aiUsageEvent AiUsageEvent? @relation(fields: [aiUsageEventId], references: [id], onDelete: SetNull) + actor Employee? @relation(fields: [actorId], references: [id], onDelete: SetNull) + cycle ReviewCycle? @relation(fields: [cycleId], references: [id], onDelete: SetNull) + + @@index([createdAt]) + @@index([actorId, createdAt]) + @@index([feature, createdAt]) + @@index([decision, createdAt]) + @@map("ai_hitl_decision") +} + +model NominationEvent { + id BigInt @id @default(autoincrement()) + actorId Int? @map("actor_id") + nominationId Int? @map("nomination_id") + cycleId Int? @map("cycle_id") + employeeId Int? @map("employee_id") + reviewerId Int? @map("reviewer_id") + action NominationEventAction + previousApprovalStatus NominationApproval? @map("previous_approval_status") + approvalStatus NominationApproval? @map("approval_status") + submissionStatus NominationSubmission? @map("submission_status") + employeeDepartmentId Int? @map("employee_department_id") + reviewerDepartmentId Int? @map("reviewer_department_id") + employeeDepartmentName String? @db.VarChar(100) @map("employee_department_name") + reviewerDepartmentName String? @db.VarChar(100) @map("reviewer_department_name") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + actor Employee? @relation("NominationEventActor", fields: [actorId], references: [id], onDelete: SetNull) + nomination Nomination? @relation(fields: [nominationId], references: [id], onDelete: SetNull) + cycle ReviewCycle? @relation(fields: [cycleId], references: [id], onDelete: SetNull) + employee Employee? @relation("NominationEventEmployee", fields: [employeeId], references: [id], onDelete: SetNull) + reviewer Employee? @relation("NominationEventReviewer", fields: [reviewerId], references: [id], onDelete: SetNull) + employeeDepartment Department? @relation("NominationEventEmployeeDepartment", fields: [employeeDepartmentId], references: [id], onDelete: SetNull) + reviewerDepartment Department? @relation("NominationEventReviewerDepartment", fields: [reviewerDepartmentId], references: [id], onDelete: SetNull) + + @@index([createdAt]) + @@index([action, createdAt]) + @@index([cycleId, createdAt]) + @@index([employeeId, createdAt]) + @@index([reviewerId, createdAt]) + @@map("nomination_event") +} + +model ReviewEvent { + id BigInt @id @default(autoincrement()) + actorId Int? @map("actor_id") + reviewId Int? @map("review_id") + cycleId Int? @map("cycle_id") + employeeId Int? @map("employee_id") + reviewerId Int? @map("reviewer_id") + action ReviewEventAction + status ReviewStatus? + ratingCount Int @default(0) @map("rating_count") + hasDoWellComment Boolean @default(false) @map("has_do_well_comment") + hasImproveComment Boolean @default(false) @map("has_improve_comment") + hasAttentionComment Boolean @default(false) @map("has_attention_comment") + wouldPickForTeam Boolean? @map("would_pick_for_team") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + actor Employee? @relation("ReviewEventActor", fields: [actorId], references: [id], onDelete: SetNull) + review Review? @relation(fields: [reviewId], references: [id], onDelete: SetNull) + cycle ReviewCycle? @relation(fields: [cycleId], references: [id], onDelete: SetNull) + employee Employee? @relation("ReviewEventEmployee", fields: [employeeId], references: [id], onDelete: SetNull) + reviewer Employee? @relation("ReviewEventReviewer", fields: [reviewerId], references: [id], onDelete: SetNull) + + @@index([createdAt]) + @@index([action, createdAt]) + @@index([cycleId, createdAt]) + @@index([employeeId, createdAt]) + @@index([reviewerId, createdAt]) + @@map("review_event") +} diff --git a/pulse360/src/app/(app)/employees/[id]/page.tsx b/pulse360/src/app/(app)/employees/[id]/page.tsx index 41689e9..789f968 100644 --- a/pulse360/src/app/(app)/employees/[id]/page.tsx +++ b/pulse360/src/app/(app)/employees/[id]/page.tsx @@ -13,6 +13,11 @@ export default function EmployeeFormPage() { const [lastName, setLastName] = useState(""); const [email, setEmail] = useState(""); const [jobTitle, setJobTitle] = useState(""); + const [jobGrade, setJobGrade] = useState(""); + const [employmentType, setEmploymentType] = useState("PERMANENT"); + const [conversionHireStatus, setConversionHireStatus] = useState("NO"); + const [gender, setGender] = useState(""); + const [ethnicity, setEthnicity] = useState(""); const [role, setRole] = useState("EMPLOYEE"); const [departmentId, setDepartmentId] = useState(""); const [managerId, setManagerId] = useState(""); @@ -38,6 +43,11 @@ export default function EmployeeFormPage() { const emp = await eRes.json(); setFirstName(emp.firstName ?? ""); setLastName(emp.lastName ?? ""); setEmail(emp.email ?? ""); setJobTitle(emp.jobTitle ?? ""); + setJobGrade(emp.jobGrade ?? ""); + setEmploymentType(emp.employmentType ?? "PERMANENT"); + setConversionHireStatus(emp.conversionHireStatus ?? "NO"); + setGender(emp.gender ?? ""); + setEthnicity(emp.ethnicity ?? ""); setRole(emp.role ?? "EMPLOYEE"); setDepartmentId(String(emp.departmentId ?? "")); setManagerId(String(emp.managerId ?? "")); setIsActive(emp.isActive ?? true); } @@ -51,7 +61,16 @@ export default function EmployeeFormPage() { e.preventDefault(); setError(""); setSaving(true); const body = { - firstName, lastName, email, jobTitle, role, + firstName, + lastName, + email, + jobTitle, + jobGrade, + employmentType, + conversionHireStatus, + gender: gender || null, + ethnicity: ethnicity || null, + role, departmentId: Number(departmentId), managerId: managerId ? Number(managerId) : null, isActive, @@ -66,8 +85,10 @@ export default function EmployeeFormPage() { if (loading) return
Loading…
; + const inputClass = "w-full rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm text-gray-950 placeholder:text-gray-400 focus:border-[#0f1f3d] focus:outline-none focus:ring-2 focus:ring-[#0f1f3d]/20"; + return ( -
+

{isEdit ? "Edit Employee" : "Add Employee"}

{isEdit ? "Update employee details" : "Create a new employee account"}

@@ -79,29 +100,37 @@ export default function EmployeeFormPage() {
setFirstName(e.target.value)} - className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-[#0f1f3d] focus:outline-none focus:ring-2 focus:ring-[#0f1f3d]/20" /> + className={inputClass} />
setLastName(e.target.value)} - className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-[#0f1f3d] focus:outline-none focus:ring-2 focus:ring-[#0f1f3d]/20" /> + className={inputClass} />
setEmail(e.target.value)} - className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-[#0f1f3d] focus:outline-none focus:ring-2 focus:ring-[#0f1f3d]/20" /> + className={inputClass} />
-
- - setJobTitle(e.target.value)} - className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-[#0f1f3d] focus:outline-none focus:ring-2 focus:ring-[#0f1f3d]/20" /> +
+
+ + setJobTitle(e.target.value)} + className={inputClass} /> +
+
+ + setJobGrade(e.target.value)} + placeholder="Example: C3, Manager, Executive" + className={inputClass} /> +
setDepartmentId(e.target.value)} - className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-[#0f1f3d] focus:outline-none focus:ring-2 focus:ring-[#0f1f3d]/20"> + className={inputClass}> {departments.map(d => )}
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
diff --git a/pulse360/src/app/(app)/system-admin/page.tsx b/pulse360/src/app/(app)/system-admin/page.tsx index 1173244..d60f59e 100644 --- a/pulse360/src/app/(app)/system-admin/page.tsx +++ b/pulse360/src/app/(app)/system-admin/page.tsx @@ -79,10 +79,10 @@ export default async function SystemAdminDashboard() { }); const cycleWhere = cycle ? { cycleId: cycle.id } : {}; - const [employees, nominations, reviews, auditLogs, aiLogs] = await Promise.all([ + const [employees, nominations, reviews, auditLogs, authEvents, aiUsageEvents, aiDecisionEvents] = await Promise.all([ prisma.employee.findMany({ where: { isActive: true, role: { not: "SYSTEM_ADMIN" } }, - select: { id: true, department: { select: { name: true } }, jobGrade: true }, + select: { id: true, department: { select: { name: true } }, jobGrade: true, gender: true, employmentType: true, conversionHireStatus: true }, }), prisma.nomination.findMany({ where: cycleWhere, @@ -107,14 +107,24 @@ export default async function SystemAdminDashboard() { orderBy: { createdAt: "desc" }, take: 250, }), - prisma.auditLog.findMany({ - where: { action: { startsWith: "AI_" }, createdAt: { gte: since } }, + prisma.authEvent.findMany({ + where: { createdAt: { gte: since } }, + orderBy: { createdAt: "desc" }, + take: 500, + }), + prisma.aiUsageEvent.findMany({ + where: { createdAt: { gte: since } }, + orderBy: { createdAt: "desc" }, + take: 500, + }), + prisma.aiHitlDecision.findMany({ + where: { createdAt: { gte: since } }, orderBy: { createdAt: "desc" }, take: 500, }), ]); - const loginEvents = auditLogs.filter((row) => row.action === "LOGIN_SUCCEEDED"); + const loginEvents = authEvents.filter((row) => row.status === "SUCCESS"); const uniqueLoginUsers = new Set(loginEvents.map((row) => row.actorId).filter(Boolean)).size; const submittedNominations = nominations.filter((row) => row.submissionStatus === "SUBMITTED").length; const pendingApprovals = nominations.filter((row) => row.approvalStatus === "PENDING").length; @@ -124,16 +134,15 @@ export default async function SystemAdminDashboard() { const missingCommentReviews = reviews.filter((row) => row.status === "SUBMITTED" && (!row.doWellComment || !row.improveComment) ).length; - const aiGenerationLogs = aiLogs.filter((row) => row.action !== "AI_HITL_DECISION"); - const aiDecisionLogs = aiLogs.filter((row) => row.action === "AI_HITL_DECISION"); - const aiSuccesses = aiGenerationLogs.filter((row) => (row.metadata as any)?.status === "success").length; - const aiStubbed = aiGenerationLogs.filter((row) => (row.metadata as any)?.stub === true).length; - const aiTokens = aiGenerationLogs.reduce((sum, row) => sum + Number((row.metadata as any)?.totalTokens ?? 0), 0); - const aiInputTokens = aiGenerationLogs.reduce((sum, row) => sum + Number((row.metadata as any)?.promptTokens ?? 0), 0); - const aiOutputTokens = aiGenerationLogs.reduce((sum, row) => sum + Number((row.metadata as any)?.completionTokens ?? 0), 0); - const aiAccepted = aiDecisionLogs.filter((row) => (row.metadata as any)?.decision === "accepted").length; - const aiEdited = aiDecisionLogs.filter((row) => (row.metadata as any)?.decision === "edited").length; - const aiDiscarded = aiDecisionLogs.filter((row) => (row.metadata as any)?.decision === "discarded").length; + const aiGenerationLogs = aiUsageEvents; + const aiSuccesses = aiGenerationLogs.filter((row) => row.status === "SUCCESS").length; + const aiStubbed = aiGenerationLogs.filter((row) => row.stub).length; + const aiTokens = aiGenerationLogs.reduce((sum, row) => sum + row.totalTokens, 0); + const aiInputTokens = aiGenerationLogs.reduce((sum, row) => sum + row.inputTokens, 0); + const aiOutputTokens = aiGenerationLogs.reduce((sum, row) => sum + row.outputTokens, 0); + const aiAccepted = aiDecisionEvents.filter((row) => row.decision === "ACCEPTED").length; + const aiEdited = aiDecisionEvents.filter((row) => row.decision === "EDITED").length; + const aiDiscarded = aiDecisionEvents.filter((row) => row.decision === "DISCARDED").length; const departmentCounts = employees.reduce>((acc, employee) => { const dept = employee.department.name; @@ -141,6 +150,18 @@ export default async function SystemAdminDashboard() { return acc; }, {}); + const genderCounts = employees.reduce>((acc, employee) => { + const label = employee.gender?.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (char) => char.toUpperCase()) ?? "Not captured"; + acc[label] = (acc[label] ?? 0) + 1; + return acc; + }, {}); + + const employmentTypeCounts = employees.reduce>((acc, employee) => { + const label = employee.employmentType.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (char) => char.toUpperCase()); + acc[label] = (acc[label] ?? 0) + 1; + return acc; + }, {}); + const nominationFlows = new Map(); const reviewerMap = new Map }>(); const pendingBySubjectDept = new Map(); @@ -396,6 +417,44 @@ export default async function SystemAdminDashboard() { ))}
+ +
+
+
+ {Object.entries(genderCounts) + .sort((a, b) => b[1] - a[1]) + .map(([gender, count]) => ( +
+
+ {gender} + {count} +
+
+
+
+
+ ))} +
+
+ +
+
+ {Object.entries(employmentTypeCounts) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => ( +
+
+ {type} + {count} +
+
+
+
+
+ ))} +
+
+
); } diff --git a/pulse360/src/app/api/employees/[id]/route.ts b/pulse360/src/app/api/employees/[id]/route.ts index 747c28f..193be01 100644 --- a/pulse360/src/app/api/employees/[id]/route.ts +++ b/pulse360/src/app/api/employees/[id]/route.ts @@ -2,8 +2,13 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { NextResponse } from "next/server"; +import { writeAuditEvent } from "@/lib/audit"; const HR_MANAGED_ROLES = ["EMPLOYEE", "LINE_MANAGER", "HR_ADMIN"]; +const EMPLOYMENT_TYPES = ["INTERNSHIP", "LEARNERSHIP", "CONTRACT", "PERMANENT"]; +const CONVERSION_HIRE_STATUSES = ["NO", "YES", "PENDING_DECISION", "REVIEWED"]; +const GENDERS = ["WOMAN", "MAN", "NON_BINARY", "OTHER", "PREFER_NOT_TO_SAY"]; +const ETHNICITIES = ["BLACK", "WHITE", "COLOURED", "ASIAN", "INDIAN", "OTHER", "PREFER_NOT_TO_SAY"]; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const session = await getServerSession(authOptions); @@ -16,6 +21,11 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str }); if (!employee) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if ((session.user as any).role !== "HR_ADMIN") { + const { ethnicity: _ethnicity, ...safeEmployee } = employee; + return NextResponse.json(safeEmployee); + } + return NextResponse.json(employee); } @@ -25,15 +35,103 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const { id } = await params; - const { firstName, lastName, email, jobTitle, role, departmentId, managerId, isActive } = await req.json(); + const { + firstName, + lastName, + email, + jobTitle, + jobGrade, + employmentType, + conversionHireStatus, + gender, + ethnicity, + role, + departmentId, + managerId, + isActive, + } = await req.json(); if (!HR_MANAGED_ROLES.includes(role)) { return NextResponse.json({ error: "This role cannot be managed from HR employee screens" }, { status: 400 }); } + if (employmentType && !EMPLOYMENT_TYPES.includes(employmentType)) { + return NextResponse.json({ error: "Invalid employment type" }, { status: 400 }); + } + if (conversionHireStatus && !CONVERSION_HIRE_STATUSES.includes(conversionHireStatus)) { + return NextResponse.json({ error: "Invalid conversion hire status" }, { status: 400 }); + } + if (gender && !GENDERS.includes(gender)) { + return NextResponse.json({ error: "Invalid gender" }, { status: 400 }); + } + if (ethnicity && !ETHNICITIES.includes(ethnicity)) { + return NextResponse.json({ error: "Invalid ethnicity" }, { status: 400 }); + } + + const before = await prisma.employee.findUnique({ + where: { id: Number(id) }, + select: { + firstName: true, + lastName: true, + email: true, + jobTitle: true, + jobGrade: true, + employmentType: true, + conversionHireStatus: true, + gender: true, + ethnicity: true, + role: true, + departmentId: true, + managerId: true, + isActive: true, + }, + }); const employee = await prisma.employee.update({ where: { id: Number(id) }, - data: { firstName, lastName, email: email.toLowerCase(), jobTitle, role, departmentId, managerId: managerId ?? null, isActive }, + data: { + firstName: firstName.trim(), + lastName: lastName.trim(), + email: email.toLowerCase().trim(), + jobTitle: typeof jobTitle === "string" && jobTitle.trim() ? jobTitle.trim() : null, + jobGrade: typeof jobGrade === "string" && jobGrade.trim() ? jobGrade.trim() : null, + employmentType: employmentType ?? "PERMANENT", + conversionHireStatus: conversionHireStatus ?? "NO", + gender: gender || null, + ethnicity: ethnicity || null, + role, + departmentId, + managerId: managerId ?? null, + isActive, + }, }); + await writeAuditEvent({ + actorId: Number((session.user as any).id), + action: "EMPLOYEE_UPDATED", + entityType: "employee", + entityId: employee.id, + metadata: { + changedFields: { + firstName: before?.firstName !== employee.firstName, + lastName: before?.lastName !== employee.lastName, + email: before?.email !== employee.email, + jobTitle: before?.jobTitle !== employee.jobTitle, + jobGrade: before?.jobGrade !== employee.jobGrade, + employmentType: before?.employmentType !== employee.employmentType, + conversionHireStatus: before?.conversionHireStatus !== employee.conversionHireStatus, + gender: before?.gender !== employee.gender, + ethnicity: before?.ethnicity !== employee.ethnicity, + role: before?.role !== employee.role, + departmentId: before?.departmentId !== employee.departmentId, + managerId: before?.managerId !== employee.managerId, + isActive: before?.isActive !== employee.isActive, + }, + role: employee.role, + departmentId: employee.departmentId, + employmentType: employee.employmentType, + conversionHireStatus: employee.conversionHireStatus, + gender: employee.gender, + }, + }).catch(() => {}); + return NextResponse.json(employee); } diff --git a/pulse360/src/app/api/employees/route.ts b/pulse360/src/app/api/employees/route.ts index 447cf38..c0f1eef 100644 --- a/pulse360/src/app/api/employees/route.ts +++ b/pulse360/src/app/api/employees/route.ts @@ -3,8 +3,13 @@ import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; +import { writeAuditEvent } from "@/lib/audit"; const HR_MANAGED_ROLES = ["EMPLOYEE", "LINE_MANAGER", "HR_ADMIN"]; +const EMPLOYMENT_TYPES = ["INTERNSHIP", "LEARNERSHIP", "CONTRACT", "PERMANENT"]; +const CONVERSION_HIRE_STATUSES = ["NO", "YES", "PENDING_DECISION", "REVIEWED"]; +const GENDERS = ["WOMAN", "MAN", "NON_BINARY", "OTHER", "PREFER_NOT_TO_SAY"]; +const ETHNICITIES = ["BLACK", "WHITE", "COLOURED", "ASIAN", "INDIAN", "OTHER", "PREFER_NOT_TO_SAY"]; export async function POST(req: Request) { const session = await getServerSession(authOptions); @@ -12,7 +17,21 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - const { firstName, lastName, email, jobTitle, role, departmentId, managerId, isActive } = await req.json(); + const { + firstName, + lastName, + email, + jobTitle, + jobGrade, + employmentType, + conversionHireStatus, + gender, + ethnicity, + role, + departmentId, + managerId, + isActive, + } = await req.json(); if (!firstName || !lastName || !email || !departmentId) { return NextResponse.json({ error: "firstName, lastName, email and departmentId are required" }, { status: 400 }); } @@ -20,13 +39,54 @@ export async function POST(req: Request) { if (!HR_MANAGED_ROLES.includes(employeeRole)) { return NextResponse.json({ error: "This role cannot be managed from HR employee screens" }, { status: 400 }); } + if (employmentType && !EMPLOYMENT_TYPES.includes(employmentType)) { + return NextResponse.json({ error: "Invalid employment type" }, { status: 400 }); + } + if (conversionHireStatus && !CONVERSION_HIRE_STATUSES.includes(conversionHireStatus)) { + return NextResponse.json({ error: "Invalid conversion hire status" }, { status: 400 }); + } + if (gender && !GENDERS.includes(gender)) { + return NextResponse.json({ error: "Invalid gender" }, { status: 400 }); + } + if (ethnicity && !ETHNICITIES.includes(ethnicity)) { + return NextResponse.json({ error: "Invalid ethnicity" }, { status: 400 }); + } const passwordHash = await bcrypt.hash("Pulse360!Employee", 12); const employee = await prisma.employee.create({ - data: { firstName, lastName, email: email.toLowerCase(), jobTitle, role: employeeRole, departmentId, managerId: managerId ?? null, isActive: isActive ?? true, passwordHash }, + data: { + firstName: firstName.trim(), + lastName: lastName.trim(), + email: email.toLowerCase().trim(), + jobTitle: typeof jobTitle === "string" && jobTitle.trim() ? jobTitle.trim() : null, + jobGrade: typeof jobGrade === "string" && jobGrade.trim() ? jobGrade.trim() : null, + employmentType: employmentType ?? "PERMANENT", + conversionHireStatus: conversionHireStatus ?? "NO", + gender: gender || null, + ethnicity: ethnicity || null, + role: employeeRole, + departmentId, + managerId: managerId ?? null, + isActive: isActive ?? true, + passwordHash, + }, }); + await writeAuditEvent({ + actorId: Number((session.user as any).id), + action: "EMPLOYEE_CREATED", + entityType: "employee", + entityId: employee.id, + metadata: { + role: employee.role, + departmentId: employee.departmentId, + employmentType: employee.employmentType, + conversionHireStatus: employee.conversionHireStatus, + gender: employee.gender, + }, + }).catch(() => {}); + return NextResponse.json(employee, { status: 201 }); } diff --git a/pulse360/src/lib/audit.ts b/pulse360/src/lib/audit.ts index 96adc3c..320cc13 100644 --- a/pulse360/src/lib/audit.ts +++ b/pulse360/src/lib/audit.ts @@ -25,6 +25,7 @@ export async function writeAuditEvent({ metadata: metadata ?? {}, }, }); + await writeEventProjection({ actorId, action, entityType, entityId, metadata }).catch(() => {}); } export function personAuditSnapshot(person: { @@ -33,13 +34,195 @@ export function personAuditSnapshot(person: { lastName: string; email?: string | null; jobTitle?: string | null; - department?: { name: string } | null; + department?: { id?: number | null; name: string } | null; }) { return { id: person.id, name: `${person.firstName} ${person.lastName}`, email: person.email ?? null, jobTitle: person.jobTitle ?? null, + departmentId: person.department?.id ?? null, department: person.department?.name ?? null, }; } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function metadataRecord(metadata: Prisma.InputJsonValue | undefined): Record { + return isRecord(metadata) ? metadata : {}; +} + +function asNumber(value: unknown): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value : null; +} + +function asBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function enumValue(value: unknown, allowed: readonly T[]): T | null { + return typeof value === "string" && allowed.includes(value as T) ? (value as T) : null; +} + +function personSnapshot(value: unknown) { + return isRecord(value) ? value : {}; +} + +function featureFromAction(action: string, metadata: Record) { + const fromMetadata = asString(metadata.feature)?.replace(/[-\s]/g, "_").toUpperCase(); + const feature = fromMetadata ?? ({ + AI_SUGGEST_COMMENTS: "SUGGEST_COMMENTS", + AI_THEME_SUMMARY: "THEME_SUMMARY", + AI_IMPROVEMENT_PLAN: "IMPROVEMENT_PLAN", + AI_ANALYTICS_REPORT: "ANALYTICS_REPORT", + } as Record)[action]; + + return enumValue(feature, ["SUGGEST_COMMENTS", "THEME_SUMMARY", "IMPROVEMENT_PLAN", "ANALYTICS_REPORT"] as const); +} + +function decisionFromMetadata(metadata: Record) { + const decision = asString(metadata.decision)?.toUpperCase(); + return enumValue(decision, ["ACCEPTED", "EDITED", "DISCARDED"] as const); +} + +async function writeEventProjection(input: AuditInput) { + const metadata = metadataRecord(input.metadata); + const actorId = input.actorId ?? null; + const entityId = input.entityId ?? null; + + if (input.action === "LOGIN_SUCCEEDED" || input.action === "LOGIN_FAILED") { + const role = enumValue(metadata.role, ["SYSTEM_ADMIN", "HR_ADMIN", "LINE_MANAGER", "EMPLOYEE"] as const); + await prisma.authEvent.create({ + data: { + actorId, + email: asString(metadata.email), + role, + departmentId: asNumber(metadata.departmentId), + departmentName: asString(metadata.department), + status: input.action === "LOGIN_SUCCEEDED" ? "SUCCESS" : "FAILURE", + failureReason: asString(metadata.reason), + metadata: metadata as Prisma.InputJsonObject, + }, + }); + return; + } + + if ((input.action === "PROFILE_UPDATED" || input.action === "EMPLOYEE_UPDATED") && actorId && entityId) { + await prisma.profileEvent.create({ + data: { + actorId, + employeeId: entityId, + changedFields: (isRecord(metadata.changedFields) ? metadata.changedFields : {}) as Prisma.InputJsonObject, + metadata: metadata as Prisma.InputJsonObject, + }, + }); + return; + } + + if (input.action.startsWith("AI_") && input.action !== "AI_HITL_DECISION") { + const feature = featureFromAction(input.action, metadata); + if (!feature) return; + await prisma.aiUsageEvent.create({ + data: { + actorId, + feature, + model: asString(metadata.model), + status: metadata.status === "error" ? "ERROR" : "SUCCESS", + stub: Boolean(metadata.stub), + inputTokens: asNumber(metadata.promptTokens) ?? asNumber(metadata.inputTokens) ?? 0, + outputTokens: asNumber(metadata.completionTokens) ?? asNumber(metadata.outputTokens) ?? 0, + totalTokens: asNumber(metadata.totalTokens) ?? 0, + cycleId: asNumber(metadata.cycleId), + entityType: input.entityType, + entityId, + metadata: metadata as Prisma.InputJsonObject, + }, + }); + return; + } + + if (input.action === "AI_HITL_DECISION") { + const feature = featureFromAction(input.action, metadata); + const decision = decisionFromMetadata(metadata); + if (!feature || !decision) return; + await prisma.aiHitlDecision.create({ + data: { + actorId, + feature, + decision, + cycleId: asNumber(metadata.cycleId), + entityType: input.entityType, + entityId, + metadata: metadata as Prisma.InputJsonObject, + }, + }); + return; + } + + const nominationAction = ({ + NOMINATION_CREATED: "CREATED", + NOMINATION_REMOVED: "REMOVED", + NOMINATIONS_SUBMITTED: "SUBMITTED", + NOMINATION_APPROVED: "APPROVED", + NOMINATION_REJECTED: "REJECTED", + NOMINATION_APPROVED_BULK: "BULK_APPROVED", + } as Record)[input.action]; + + if (nominationAction) { + const employee = personSnapshot(metadata.employee); + const reviewer = personSnapshot(metadata.reviewer); + await prisma.nominationEvent.create({ + data: { + actorId, + nominationId: input.action === "NOMINATIONS_SUBMITTED" ? null : entityId, + cycleId: asNumber(metadata.cycleId), + employeeId: asNumber(employee.id), + reviewerId: asNumber(reviewer.id), + action: nominationAction as any, + previousApprovalStatus: enumValue(metadata.previousApprovalStatus, ["PENDING", "APPROVED", "REJECTED"] as const), + approvalStatus: enumValue(metadata.approvalStatus, ["PENDING", "APPROVED", "REJECTED"] as const), + submissionStatus: enumValue(metadata.submissionStatus, ["DRAFT", "SUBMITTED"] as const), + employeeDepartmentId: asNumber(employee.departmentId), + reviewerDepartmentId: asNumber(reviewer.departmentId), + employeeDepartmentName: asString(employee.department), + reviewerDepartmentName: asString(reviewer.department), + metadata: metadata as Prisma.InputJsonObject, + }, + }); + return; + } + + const reviewAction = ({ + REVIEW_DRAFT_SAVED: "DRAFT_SAVED", + REVIEW_SUBMITTED: "SUBMITTED", + } as Record)[input.action]; + + if (reviewAction) { + const employee = personSnapshot(metadata.employee); + const reviewer = personSnapshot(metadata.reviewer); + await prisma.reviewEvent.create({ + data: { + actorId, + reviewId: entityId, + cycleId: asNumber(metadata.cycleId), + employeeId: asNumber(employee.id), + reviewerId: asNumber(reviewer.id), + action: reviewAction as any, + status: enumValue(metadata.status, ["DRAFT", "SUBMITTED"] as const), + ratingCount: asNumber(metadata.ratingCount) ?? 0, + hasDoWellComment: Boolean(metadata.hasDoWellComment), + hasImproveComment: Boolean(metadata.hasImproveComment), + hasAttentionComment: Boolean(metadata.hasAttentionComment), + wouldPickForTeam: asBoolean(metadata.wouldPickForTeam), + metadata: metadata as Prisma.InputJsonObject, + }, + }); + } +} diff --git a/pulse360/src/lib/auth.ts b/pulse360/src/lib/auth.ts index 6900515..ee24053 100644 --- a/pulse360/src/lib/auth.ts +++ b/pulse360/src/lib/auth.ts @@ -46,7 +46,7 @@ export const authOptions: NextAuthOptions = { action: "LOGIN_FAILED", entityType: "auth", entityId: employee.id, - metadata: { email: employee.email, reason: "invalid_password", role: employee.role }, + metadata: { email: employee.email, reason: "invalid_password", role: employee.role }, }).catch(() => {}); return null; } @@ -57,7 +57,9 @@ export const authOptions: NextAuthOptions = { entityType: "auth", entityId: employee.id, metadata: { + email: employee.email, role: employee.role, + departmentId: employee.departmentId, department: employee.department.name, }, }).catch(() => {});