No description
  • TypeScript 53.6%
  • Vue 44%
  • CSS 1.4%
  • JavaScript 0.4%
  • HTML 0.4%
  • Other 0.2%
Find a file
Leandro Afonso d86f98de79
All checks were successful
CI / client (push) Successful in 16s
CI / server (push) Successful in 28s
Docker image / build (push) Successful in 37s
feat: harden leave workflows and version database migrations
2026-07-20 16:17:15 +01:00
.forgejo/workflows ci: push image with a package-scoped PAT (REGISTRY_TOKEN) 2026-06-01 22:17:51 +01:00
client feat: harden leave workflows and version database migrations 2026-07-20 16:17:15 +01:00
docker feat: harden leave workflows and version database migrations 2026-07-20 16:17:15 +01:00
server feat: harden leave workflows and version database migrations 2026-07-20 16:17:15 +01:00
.dockerignore inital ui/api commit 2026-06-01 21:12:20 +01:00
.env.prod.example feat: harden leave workflows and version database migrations 2026-07-20 16:17:15 +01:00
.gitattributes feat: harden leave workflows and version database migrations 2026-07-20 16:17:15 +01:00
.gitignore fix: enforce account deactivation 2026-07-12 18:31:53 +01:00
compose.yml email setup links + metadata only audit/logs + bootstrap create only 2026-07-20 14:51:33 +01:00
Dockerfile feat: harden leave workflows and version database migrations 2026-07-20 16:17:15 +01:00
LICENSE slight ui redesign + logo change + small license change 2026-06-16 21:31:19 +01:00
README.md feat: harden leave workflows and version database migrations 2026-07-20 16:17:15 +01:00

HoliCal

HoliCal is an absence-management application for a single company. It is built as a Vue 3 single-page app talking to an Express/Postgres JSON API, with session authentication, OIDC single sign-on (compatible with Authentik), a leave engine that enforces the company's booking rules, and a light/dark theme.

This is a ground-up v2. The original server-rendered Handlebars/Sequelize app has been removed; the codebase is now just server/ (API) and client/ (SPA).

Features

  • Auth: local password (bcrypt), OIDC/Authentik SSO, and password reset, with shared login/reset throttling, strong passphrases, and session rotation.
  • Leave: booking with working-day costing, half-day parts (morning or afternoon), public-holiday and weekend rules, day-part-aware overlap protection, and per-type frequency limits.
  • Half-day pairing: two complementary halves can share a day - e.g. a morning Annual Leave and an afternoon Free half-day. Overlap detection works per half-day, so only the same half collides.
  • Approval workflow: auto-approve (per user or per leave type) or route to the department manager and supervisors. Approve, reject, cancel, revoke, and comment.
  • Dashboard statistics: a per-user balance breakdown - nominal allowance, carried-over, individual adjustment, part-year employment proration, and used - alongside per-type usage against each type's annual cap, plus supervisor/department details.
  • Allowance model: per-user/department/company allowance, manual adjustments, year-to-year carry-over, and part-year proration from each user's start/end date. Cross-year leave is split and charged to each calendar year's allowance.
  • Departments & team scoping: CRUD, manager, secondary supervisors, per-department allowance, public-holiday policy, and a minimum-present coverage rule. A read-only manager Overview rolls up each managed team.
  • Users: admin CRUD, roles, activation, allowance overrides, and onboarding emails.
  • Work schedules: a company default plus per-user schedules (whole, morning, afternoon, or off per weekday) that feed the day-count engine.
  • Bank holidays: CRUD plus country presets (GB, US, PT).
  • Leave types: CRUD with colour, allowance and auto-approve flags, and monthly/yearly limits.
  • Calendars: a personal month calendar and a team wallchart (respecting the team-view-hidden and share-all-absences settings). Both render half-days split in place - morning on the left, afternoon on the right.
  • iCal feeds: private personal and team subscription links.
  • Reports: allowance-by-time and leaves, exportable as JSON or CSV.
  • Audit: an entity change log and an outgoing-email log. Leave status transitions are audited, and removed bookings retain their history.
  • Email: notifications for requests, decisions, onboarding, and resets, plus a daily absence digest sent at 09:00 in the company timezone. Sends go over SMTP; delivery metadata is recorded without retaining message content.
  • Integration API: bearer-token JSON endpoints under /integration/v1.

The integration suite in server/test runs 86 tests across 13 files. It seeds its own demo fixtures (demo@holical.dev admin, alice@holical.dev employee); the production seed ships no demo users.


Architecture

client/              Vue 3 + Vite + TypeScript SPA (light/dark theme)
server/              Express + TypeScript JSON API, Postgres (pg), express-session
compose.yml          One-command stack: builds the app image + Postgres 16
Dockerfile           Multi-stage build (client + server → slim runtime)
docker/entrypoint.sh Applies migrations/seed and creates the initial admin when absent
  • Auth: local email/password (bcrypt) or OIDC SSO. The session is stored in Postgres via connect-pg-simple, in a cookie named holical.sid (httpOnly, sameSite). Authentication rotates the session id; password resets invalidate existing sessions. Production refuses to start with the fallback or a short SESSION_SECRET.
  • /api/me returns 401 when unauthenticated and the user when signed in.
  • The SPA is served at /. Any non-/api GET falls back to index.html so client-side routes (/calendar, deep links, hard refresh) resolve.
  • The leave engine (server/src/leave/engine.ts) counts working days (MonFri minus public holidays, adjusted by the user's schedule) and enforces the booking rules below; the balance logic lives in server/src/leave/service.ts.
  • A small in-process scheduler (server/src/scheduler.ts) fires the daily absence digest at 09:00 company time. A database claim ensures only one app replica sends the digest for a company date.

Database migrations

Database changes live as immutable, ordered files in server/src/db/migrations. schema_migrations records each successful version and checksum. Migrations run inside individual transactions under a Postgres advisory lock, so concurrent app replicas cannot apply the same version twice.

Migration 001 is the frozen, idempotent adoption baseline for installations created by the former schema.sql mechanism. It runs once on both legacy and fresh databases; subsequent migrations are incremental and are never replayed. To add a change, create the next zero-padded SQL file and register it in server/src/db/migrate.ts. Never edit a version that may already have run.


Quick start

Local development runs the API and SPA with npm run dev, backed by a Postgres container.

# 1. Postgres (just the db service from compose.yml).
#    The password must match server/.env.example's DATABASE_URL (holical).
POSTGRES_PASSWORD=holical docker compose up -d db

# 2. API
cd server
cp .env.example .env           # tweak as needed
npm install
# Optionally set ADMIN_EMAIL + ADMIN_PASSWORD in .env to bootstrap an admin.
npm run seed                   # applies pending migrations, then the baseline seed data
npm run dev                    # http://localhost:3000

# 3. SPA (separate terminal)
cd client
npm install
npm run dev                    # http://localhost:5173 (proxies /api -> :3000)

A fresh seed creates the company, the leave types, and the default work schedule, but no users. Bootstrap the first admin by setting ADMIN_EMAIL and ADMIN_PASSWORD before seeding, or configure OIDC and sign in via SSO. The bootstrap is create-only: it never changes an existing account's password, name, or admin role.

npm run seed applies pending migrations first, then seeds baseline data. npm run migrate only applies pending database migrations.


Deployment (Docker)

compose.yml builds the app image and runs it alongside Postgres, serving the API and the built SPA on a single origin:

cp .env.prod.example .env       # set SESSION_SECRET, POSTGRES_PASSWORD, ADMIN_*, …
docker compose up -d --build
# HoliCal on http://localhost:3000  (override with APP_PORT)

The container's entrypoint applies pending migrations and the idempotent seed on start, then creates the ADMIN_EMAIL/ADMIN_PASSWORD admin only when that email does not already exist. Remove ADMIN_PASSWORD from the deployment environment after the first successful startup. Put the app behind a TLS-terminating reverse proxy and set BASE_URL=https://… (which enables Secure cookies); for plain HTTP set SECURE_COOKIES=false.


Leave rules

Behaviour Rule
Working-day cost A 14-calendar-day request 2026-01-052026-01-18 costs exactly 10 working days (weekends excluded). Allowance 22 becomes 12.
Half-day parts A booking may take only the morning or only the afternoon, costing ½ day.
Half-day pairing Two leaves may share one day if they take opposite halves (morning + afternoon); overlap is rejected only when the same half is already taken.
Weekend start A booking may not start on a Saturday or Sunday; it is rejected.
Public holiday A booking may not start on a public holiday; it is rejected and named (e.g. "…public holiday: May Day."). Holidays inside a range simply don't count.
Overlap A request that overlaps an existing booking (same half-day) is rejected.
Free half-day Costs ½ day, at most one per calendar month. Doesn't reduce the annual allowance.
Birthday Costs 1 day, at most one per year. Doesn't reduce the annual allowance.
Employment proration A part-year employee's nominal allowance is scaled by the fraction of the year they're employed (from their start/end date), rounded to the nearest half-day.
Balance remaining = allowance Σ working_days of allowance-deducting leaves in the year, where allowance = prorated nominal + adjustment + carry-over.

Leave types, holidays, and the demo fixtures are defined in server/src/db/seed.ts; the rule engine lives in server/src/leave/engine.ts and the balance maths in server/src/leave/service.ts.


OIDC / Authentik SSO

OIDC is enabled automatically when OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET are set. The login screen then shows Sign in with SSO.

The implementation (server/src/auth/oidc.ts) uses openid-client with OIDC discovery, PKCE, and nonce/state, so it works with any compliant provider.

Configure Authentik

  1. In Authentik, create an OAuth2/OpenID Provider:

    • Client type: Confidential
    • Redirect URI: http://localhost:3000/api/auth/oidc/callback
    • Scopes: openid, profile, email
  2. Create an Application bound to that provider and note its slug.

  3. Set the server env:

    OIDC_ISSUER=https://authentik.example.com/application/o/<app-slug>/
    OIDC_CLIENT_ID=<client id>
    OIDC_CLIENT_SECRET=<client secret>
    OIDC_REDIRECT_URI=http://localhost:3000/api/auth/oidc/callback
    OIDC_SCOPES=openid profile email
    OIDC_AUTO_PROVISION=true   # create local users on first SSO login
    

On callback the user is matched by oidc_sub, then by email, then auto-provisioned (if enabled) with the default 22-day allowance.

Flow: GET /api/auth/oidc/login → provider → GET /api/auth/oidc/callback → session established → redirect to /.


Theme & branding

HoliCal ships a light and a dark theme on a neutral slate surface, with a calm, enterprise-leaning visual language (crisp panels, hairline borders, restrained type). The choice is applied before paint (an inline script in index.html) to avoid flashes, respects prefers-color-scheme, and is persisted to localStorage via the header toggle. See client/src/composables/useTheme.ts and client/src/styles/main.css.

The brand and accent colours are runtime-configurable per deployment, so the same generic codebase can wear any company's identity. An admin sets a single hex for each in Admin → Settings; client/src/lib/palette.ts derives a full 50→950 ramp from each anchor and writes them to :root as CSS custom properties, recolouring the entire UI with no rebuild. The shipped defaults are a brand-neutral professional blue (#2552a0) and a calm cyan (#0e7490) - both chosen to meet WCAG AA contrast - and mirror the company table defaults so the first paint is already correct. GET /api/branding exposes the company name and colours (non-sensitive) for the login screen and pre-auth paint.


API reference

The complete, browsable reference is Swagger UI at /api/docs (e.g. http://localhost:3000/api/docs once the server is running). It is generated from a hand-written OpenAPI 3.0 spec at server/openapi.yaml, also served raw at /api/openapi.yaml. The docs page is open to everyone: it describes the API but exposes no data. Every endpoint, every status code, and every request/response shape is documented there, grouped by area (auth, leave, approvals, calendar, reference data, the admin areas, and the integration API).

The table below is just a representative slice. All app routes are mounted under /api; the integration API is separate, under /integration/v1, and the iCal feeds under /feed.

Method Path Auth Notes
GET /api/health liveness probe
POST /api/auth/login { email, password } sets the session cookie
POST /api/auth/logout clears the session
GET /api/me current user; 401 when unauthenticated
GET /api/auth/config { oidcEnabled }
GET /api/auth/oidc/login begin SSO
GET /api/auth/oidc/callback SSO callback
GET /api/leave-types
GET /api/holidays
GET /api/leaves current user's bookings
GET /api/balance?year=YYYY balance breakdown, per-type usage, supervisors, department
POST /api/leaves { leaveTypeId, start, end, dayPartStart?, dayPartEnd?, note? }, 422 on rule violation
DELETE /api/leaves/:id soft-remove a booking; retained as cancelled audit history
GET /api/team?start=…&end=… team wallchart data
GET /feed/... token personal/team iCal subscriptions

Tests

The rules and flows are covered by an integration suite (Vitest + Supertest against a real Postgres holical_test database) - 86 tests across 13 files in server/test.

cd server
# Create the test database once (against your dev Postgres):
docker compose exec db psql -U holical -c "CREATE DATABASE holical_test"
npm test

vitest.config.ts points at holical_test (override with TEST_DATABASE_URL) and globalSetup drops, migrates, and seeds it before the run. Coverage spans: auth (rate limits, password policy, session rotation/invalidation, /api/me), the working-day/balance rules, cross-year charging, weekend/holiday/overlap rejection, half-day pairing, the free half-day and birthday limits, employment proration, departments and concurrent team coverage, reports, feeds, single-delivery email/digest claims, audited approval/removal workflows, and SPA serving with the client-route fallback. Migration coverage includes legacy schema adoption, sensitive-data cleanup, data backfill, and no-op reruns.