No description
  • TypeScript 59%
  • Vue 36.9%
  • CSS 2.8%
  • JavaScript 0.8%
  • HTML 0.3%
  • Other 0.2%
Find a file
Leandro Afonso 7280e8a43c
All checks were successful
CI / client (push) Successful in 19s
CI / server (push) Successful in 32s
Docker image / build (push) Successful in 24s
fix: SSO login users not having holidays automatically calculated
2026-09-14 10:32:25 +01:00
.forgejo/workflows ci: push image with a package-scoped PAT (REGISTRY_TOKEN) 2026-06-01 22:17:51 +01:00
.superdesign UI redesign - mainly colors 2026-08-04 14:34:26 +01:00
client fix: revert login fields while leaving admin route- change on admin panel 2026-08-05 12:25:15 +01:00
docker feat: harden leave workflows and version database migrations 2026-07-20 16:17:15 +01:00
server fix: SSO login users not having holidays automatically calculated 2026-09-14 10:32:25 +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 mobile view 2026-07-29 12:23:38 +01:00
compose.yml email setup links + metadata only audit/logs + bootstrap create only 2026-07-20 14:51:33 +01:00
Dockerfile fix: vulns 2026-08-05 11:46:10 +01:00
future-impl.md fix: SSO login users not having holidays automatically calculated 2026-09-14 10:32:25 +01:00
LICENSE slight ui redesign + logo change + small license change 2026-06-16 21:31:19 +01:00
README.md feat: ignore supervisor email + fix calendar views + remove new request button 2026-08-04 16:12:16 +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. The Requests nav item carries a count of what is waiting on you.
  • 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. Supervisor emails are opt-out per department, for teams that use the supervisor list purely as group membership: clearing Email supervisors about new requests stops their mail while leaving their approval rights and their in-app pending count intact. The department manager is emailed either way.
  • 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 (Mon–Fri 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.

Import from TimeOff.Management

An existing TimeOff.Management SQLite database can be imported into a clean HoliCal PostgreSQL database. Stop the application while importing and back up both databases first. The host running the command needs the sqlite3 command line tool; set SQLITE3_BIN if it is installed under a different name.

For a new installation, the simplest route is Administration → Company settings → Import legacy data. Choose the SQLite file, review the dry-run counts, and confirm the import. This browser flow is available only while the signed-in bootstrap administrator is HoliCal's sole user and no operational data has been added. Their email must identify a legacy administrator; HoliCal preserves that account's current password or OIDC login and signs out all sessions when the import completes. Uploaded previews expire after 15 minutes.

cd server

# Validate and show the import counts without touching PostgreSQL.
npm run import:timeoff -- --source ../db.development.sqlite

# Import into DATABASE_URL after reviewing the dry-run report.
npm run import:timeoff -- --source ../db.development.sqlite --apply

The target may contain HoliCal's baseline company, schedule, and leave types, but no operational data. The importer refuses a non-empty target and writes everything in one transaction. It preserves users, departments, supervisors, leave types, holidays, leave history, allowance adjustments, comments, audit metadata, email-audit metadata, schedules, and usable calendar feeds.

TimeOff.Management passwords cannot be migrated safely: they are legacy MD5 hashes tied to the old application's secret, while HoliCal uses bcrypt. Users must reset their password or sign in through OIDC. To give one existing legacy administrator a new password during import, set both variables:

IMPORT_ADMIN_EMAIL=admin@example.com \
IMPORT_ADMIN_PASSWORD='a unique passphrase of at least 15 characters' \
npm run import:timeoff -- --source ../db.development.sqlite --apply

Duplicate case-insensitive email addresses are retained as separate users; all but the newest record receive a +legacy-ID address and are identified by user ID in the dry-run report. Duplicate holiday dates are collapsed. Orphaned allowance rows and ownerless feeds are skipped, legacy company feeds become team feeds, email bodies are deliberately not retained, and password-related audit rows are omitted. A user is imported as inactive when the legacy account is not activated or its employment end date has passed. Historical leave on non-working days is retained with zero working days and does not create an allowance allocation.


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.

To see a real notification without waiting for 09:00, send the daily absence digest on demand:

cd server
npm run digest                 # today, in the company timezone
npm run digest -- 2026-08-03   # a specific date

It sends the same message the scheduler sends, but writes no claim to daily_digest_runs, so it can be re-run for the same date. It reports why nothing was sent when the digest is off, has no recipients, lands on a weekend, or nobody is away. Without SMTP_URL the message is only recorded in the audit log - point it at a local catcher such as Mailpit to actually receive it. The other notifications need no command: book leave for someone whose requests are not auto-approved, then approve or reject it.


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-05 → 2026-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.