Employee Renewal System (ERS) — v0.1 Spec (Code-Ready)
Version: 0.1.0
Stack: Postgres + Next.js (App Router) + VPS (Hostinger) + local filesystem uploads
Auth: Email + Password (no 2FA)
Notifications: In-app + Email
Audit Log: Included
Admin Settings: Included
UI Footer: Show app version on every page
1) Goal
Internal system to manage employee contract renewals:
- HR uploads renewal packet (offer + performance snapshot)
- Manager submits renewal justification when requested
- CEO reviews, compares compensation vs peers (same grade), then approves/rejects
- CEO can digitally sign the offer letter by placing a signature onto the PDF
Primary outcomes:
- Track renewals, salary, grade
- Compare salary vs peers at same grade
- Capture justification and decision
- Full audit trail
2) Roles & Permissions (RBAC)
Roles
- CEO (Admin): full access; final decision; signature management
- HR: create & manage renewal cases, upload docs, request manager justification, email notifications
- Manager: respond to assigned justification requests; view assigned packets
Permission Rules (hard)
- HR & Manager cannot edit a case after CEO decision (
locked_atset). - After CEO signs the offer (
status=SIGNED), case is closed for all roles (read-only). - CEO decision requires:
- Latest OFFER_LETTER exists
- PerformanceSnapshot exists
- ManagerJustification exists (v0 hard rule)
3) Key Entities (Data Model)
3.1 User
- id (uuid)
- email (unique)
- name
- role (enum: CEO | HR | MANAGER)
- password_hash
- is_active (bool default true)
- created_at, updated_at
3.2 Employee
- id (uuid)
- employee_code (unique, optional)
- full_name
- email (unique, optional)
- department
- role_title
- grade (free text e.g. "G5")
- manager_user_id (fk users.id)
- employment_type (enum: PERMANENT | CONTRACT)
- status (enum: ACTIVE | INACTIVE)
- created_at, updated_at
3.3 RenewalCase
- id (uuid)
- employee_id (fk)
- contract_start_date, contract_end_date
- renewal_start_date, renewal_end_date
- current_monthly_salary (numeric)
- proposed_monthly_salary (numeric)
- currency (default "MYR")
- proposed_grade (string nullable) // free text; if null use employee.grade
- status (enum):
- DRAFT
- PENDING_MANAGER
- READY_FOR_CEO
- APPROVED
- REJECTED
- SIGNED
- justification_request_sent_at (timestamptz nullable)
- manager_justification_due_at (timestamptz nullable)
CEO decision:
- ceo_decision (enum: NONE | APPROVE | REJECT)
- ceo_decision_at (timestamptz nullable)
- ceo_decision_notes (text nullable)
Locking:
- locked_at (timestamptz nullable)
Meta:
- created_by_user_id (fk users.id)
- created_at, updated_at
3.4 PerformanceSnapshot (per RenewalCase)
- id (uuid)
- renewal_case_id (fk)
- review_period (string e.g. "2025 H2")
- overall_score (numeric nullable)
- score_breakdown (jsonb nullable)
- hr_notes (text nullable)
- manager_comments (text nullable)
- created_at, updated_at
3.5 ManagerJustification (one per RenewalCase)
- id (uuid)
- renewal_case_id (fk unique)
- manager_user_id (fk users.id nullable for guest submissions)
- recommendation (enum: RENEW | DO_NOT_RENEW | RENEW_WITH_CONDITIONS)
- justification (text)
- conditions (text nullable)
- submitted_at (timestamptz nullable)
- submitted_by_name (text nullable)
- submitted_by_email (text nullable)
- last_updated_at (timestamptz)
3.5b GuestJustificationToken (one-time external link)
- id (uuid)
- renewal_case_id (fk)
- token_hash (unique)
- manager_name (nullable)
- manager_email
- created_by_user_id (fk users.id)
- expires_at
- used_at (nullable)
- revoked_at (nullable)
- created_at
3.6 Document (versioned)
- id (uuid)
- renewal_case_id (fk)
- doc_type (enum: OFFER_LETTER | SIGNED_OFFER | PERFORMANCE_REPORT | OTHER)
- file_name
- mime_type
- file_size
- storage_key (string) // relative path in VPS filesystem
- uploaded_by_user_id (fk users.id)
- uploaded_at (timestamptz)
- version (int default 1)
- is_latest (bool default true)
Versioning rule:
- Uploading same (renewal_case_id + doc_type) again:
- set previous is_latest=false
- increment version
3.7 EmployeeCompensation (for peer comparison)
We start empty and fill over time from approved renewals.
- id (uuid)
- employee_id (fk)
- effective_date (date)
- monthly_salary (numeric)
- grade (string)
- source (enum: RENEWAL_APPROVED | MANUAL)
- renewal_case_id (fk nullable)
- created_at
Creation rule:
- When CEO approves a renewal case:
- insert EmployeeCompensation using proposed salary + effective_date=renewal_start_date
- grade = proposed_grade ?? employee.grade
3.8 InAppNotification
- id (uuid)
- user_id (fk)
- type (enum: JUSTIFICATION_REQUESTED | CASE_READY_FOR_CEO | CEO_DECIDED | CASE_SIGNED)
- title (string)
- body (text)
- link_path (string) // e.g. /manager/requests/{id}
- is_read (bool default false)
- created_at
3.9 EmailOutbox (durable outbound queue)
- id (uuid)
- to_email
- subject
- body_html
- body_text
- status (enum: PENDING | SENT | FAILED)
- provider_message_id (string nullable)
- error_message (text nullable)
- attempts (int default 0)
- last_attempt_at (timestamptz nullable)
- created_at
3.10 AuditLog (required)
- id (uuid)
- actor_user_id (fk)
- entity_type (enum: RENEWAL_CASE | DOCUMENT | JUSTIFICATION | SETTINGS | EMPLOYEE | USER | SIGNATURE | NOTIFICATION | EMAIL)
- entity_id (uuid)
- action (string)
- before (jsonb nullable)
- after (jsonb nullable)
- created_at (timestamptz)
Minimum audited actions:
- CREATE_CASE, UPDATE_CASE, REQUEST_JUSTIFICATION, SUBMIT_JUSTIFICATION
- UPLOAD_DOC
- CEO_APPROVE, CEO_REJECT
- SIGNATURE_UPLOAD, PDF_SIGNED
- SETTINGS_UPDATE
3.11 AdminSettings (keyed settings)
- id (uuid)
- key (string unique)
- value_json (jsonb)
- updated_by_user_id (fk users.id)
- updated_at
Must include keys (v0):
- app_version: { "value": "0.1.0" }
- company_name
- upload_max_mb
- email_from
- smtp (optional to store; can be env-only)
- manager_justification_default_due_days
4) Digital Signature (PDF signing) — v0 Implementation Plan
4.1 Signature Asset
Store CEO signature as PNG with transparency.
Recommended:
user_signaturetable:- id (uuid)
- user_id (fk users.id unique)
- file_name, mime_type, file_size, storage_key
- created_at, updated_at
4.2 Signing UX
- CEO opens renewal case > Offer Letter preview
- Click “Sign”
- PDF preview with draggable signature overlay
- CEO clicks “Apply Signature”
- Frontend sends
{ pageNumber, x, y, width, height }in PDF points
Coordinate conversion:
- Convert DOM px coords to PDF points
- PDF origin is bottom-left; DOM origin is top-left
4.3 Backend stamping
Use pdf-lib server-side:
- Load OFFER_LETTER
- Embed signature PNG
- Draw on given page coords
- Save new PDF => SIGNED_OFFER
Rule:
- Must APPROVE before SIGN
- approve => status=APPROVED
- sign => create SIGNED_OFFER + status=SIGNED
5) Core Workflows
5.1 HR creates renewal
- Create RenewalCase (DRAFT)
- Upload OFFER_LETTER + PERFORMANCE_REPORT (optional)
- Upsert PerformanceSnapshot
- Request Manager Justification => status=PENDING_MANAGER + notify manager (in-app + email)
5.2 Manager justification
- Manager opens “My Requests”
- Submits recommendation + justification
- Alternative: HR sends one-time guest link; external manager can submit without account
- HR marks READY_FOR_CEO => notify CEO (in-app + email)
5.3 CEO decision
- Approve => status=APPROVED + locked_at + insert EmployeeCompensation + notify HR/manager
- Reject => status=REJECTED + locked_at + notify HR/manager
5.4 CEO signs PDF (after approval)
- Stamp one or more signatures across pages => SIGNED_OFFER + status=SIGNED
- On successful sign, case is closed (no further edits)
- Notify HR users (in-app + email via outbox)
6) UI Pages
- /login
- /hr/renewals, /hr/renewals/new, /hr/renewals/[id]
- /manager/requests, /manager/requests/[id]
- /ceo/renewals, /ceo/renewals/[id], /ceo/signature
- /guest/justification/[token] (public one-time manager link)
- /admin/settings, /admin/audit, /admin/users
- /notifications
Footer on every page:
ERS v{app_version}
7) API Routes (high-level)
Auth:
- POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me
Employees:
- GET/POST /api/employees
- GET/PATCH /api/employees/:id
Renewals:
- GET/POST /api/renewals
- GET/PATCH /api/renewals/:id
- POST /api/renewals/:id/request-justification
- POST /api/renewals/:id/mark-ready
- POST /api/renewals/:id/approve
- POST /api/renewals/:id/reject
Documents:
- POST /api/renewals/:id/documents
- GET /api/documents/:docId/download
Performance snapshot:
- GET/POST /api/renewals/:id/performance-snapshot
Justification:
- GET /api/renewals/:id/justification
- POST/PATCH /api/renewals/:id/justification
- GET/POST /api/guest/justification/:token
Signing:
- POST/GET /api/ceo/signature
- POST /api/renewals/:id/sign-pdf
Notifications:
- GET /api/notifications
- POST /api/notifications/:id/read
Admin:
- GET/PATCH /api/admin/settings
- GET /api/admin/audit
- GET/POST /api/admin/users
- PATCH /api/admin/users/:id
Internal:
- POST /api/internal/email/dispatch (secret-protected)
8) Storage Plan (VPS filesystem)
Base: /var/app/ers_uploads/
- documents:
documents/{renewal_case_id}/{doc_type}/v{version}/{filename} - signatures:
signatures/{user_id}/{filename}
Store relative path in DB storage_key.
9) Definition of Done (v0)
- HR can create packet end-to-end
- Manager can submit justification
- CEO can view peer stats, approve/reject, and sign PDF
- In-app + email notifications work via outbox + dispatch endpoint
- Audit log populated
- Footer shows version everywhere