Sign inSign up
timesheet logo

garfieldwtf/timesheet

By garfieldwtf

•Updated 1 day ago

Image
0

45

garfieldwtf/timesheet repository overview

⁠Monthly Timesheet Web Application

Production-ready timesheet system for a Malaysian company (Kuala Lumpur, Asia/Kuala_Lumpur): daily attendance, leave (MC/EL/AL), overtime & night-maintenance tracking, automatic Comp-Off entitlements, and a multi-manager monthly approval workflow.

Ships as a single Docker image — the container serves the React SPA, the REST API, and auto-runs database migrations on startup. With docker compose up you get a fully working stack (app + PostgreSQL) in about a minute.

docker compose up -d          # app on http://localhost:8080, Postgres on :5433

Log in with [email protected] / Password123! (see Seeded accounts⁠).


⁠Table of contents


⁠Architecture

┌─────────────────────────────────────────────────────────────────────┐
│ Docker image: garfieldwtf/timesheet                                 │
│                                                                     │
│  ┌─────────────────────┐         ┌────────────────────────────────┐ │
│  │  React + MUI SPA    │  /api   │  Express + TypeScript (Node 22)│ │
│  │  (Vite build,       │ ──────► │  (port 4000)                   │ │
│  │   served statically)│         │                                │ │
│  │                     │         │  Routes: /auth /me /manager    │ │
│  │  - Login (JWT)      │         │           /admin  (+ /api/*)   │ │
│  │  - Timesheet grid   │         │  Services:                     │ │
│  │  - Manager dashboard│         │   - compoff.ts  (OT→comp-off)  │ │
│  │  - Edit history     │         │   - workingday.ts (next work   │ │
│  └─────────────────────┘         │     day)                       │ │
│                                  │   - timesheet.ts (grid, submit)│ │
│  /uploads (volume)               │   - holidays.ts (Google Cal)   │ │
│   └─ MC attachments              │   - email.ts (SMTP + log)      │ │
│                                  │   - audit.ts (edit history)    │ │
│                                  │  Scheduler (node-cron, KL tz): │ │
│                                  │   holiday sync / expiry /      │ │
│                                  │   reminders                    │ │
│                                  └───────────────┬────────────────┘ │
└──────────────────────────────────────────────────┼──────────────────┘
                                                   │ pg (node-postgres)
                                    ┌──────────────▼───────────────┐
                                    │  PostgreSQL 16               │
                                    │  (compose: port 5433)        │
                                    └──────────────────────────────┘

Stack: Node.js 22 + Express + TypeScript · PostgreSQL (node-pg-migrate migrations) · React 18 + MUI + Vite · JWT auth with refresh tokens · bcrypt · Zod validation · node-cron · Nodemailer (SMTP) · Docker (multi-stage build, Alpine).

⁠Repository layout

backend/
  src/
    config.ts             # typed env settings
    index.ts              # Express app entry; serves SPA in production
    scheduler.ts          # cron jobs (holiday sync, expiry, reminders)
    db/pool.ts            # pg pool + transaction helper
    middleware/auth.ts    # JWT verify, RBAC, manager-of-user checks
    migrations/           # node-pg-migrate migrations (plain JS)
    routes/               # auth.ts, me.ts, manager.ts, admin.ts
    services/             # compoff.ts, workingday.ts, timesheet.ts,
                          # holidays.ts, email.ts, audit.ts
    tests/                # Jest unit tests (47 tests)
    seed.ts               # seed: admin, 2 managers, 3 employees, 2 groups, holidays
frontend/
  src/
    api/index.ts          # axios client + token refresh interceptor
    pages/                # Login, TimesheetPage, ManagerDashboard, EditHistoryPage
    types/index.ts        # shared DTO types
openapi.yaml              # API documentation
Dockerfile                # multi-stage build → single production image
docker-compose.yml        # app + PostgreSQL 16 stack
docker-entrypoint.sh      # wait-for-db → migrations → optional seed → start
.env.example              # all configurable settings

⁠Quick start with Docker

Prerequisites: Docker (with the Compose plugin) — Docker Desktop, Rancher Desktop, or a plain Docker Engine + compose plugin on Linux.

  1. Start the stack (from the repository root):

    docker compose up -d --build
    

    The first build takes a few minutes (npm ci + TypeScript/Vite builds). Subsequent builds are much faster thanks to layer caching.

  2. Open the app: http://localhost:8080⁠

  3. Log in with a seeded account (password Password123!) — e.g. [email protected]. The demo seed is enabled by default via SEED_ON_START=true (idempotent upserts — safe on every restart).

⁠What the container does on startup

The image entrypoint (docker-entrypoint.sh⁠) runs as PID 1 via tini (proper signal handling, fast graceful shutdown) and:

  1. Waits for Postgres — retries every second for up to 60 seconds using the DATABASE_URL (works with any Postgres the container can reach, not just compose).
  2. Runs migrations — node-pg-migrate up applies every pending migration in backend/src/migrations (7 migrations: schema, tasks, holidays, amendments, etc.). Migrations are idempotent; already-applied ones are skipped.
  3. Seeds (optional) — if SEED_ON_START=true, runs the compiled seed script (demo users, groups, 2026 Malaysian public holidays). All inserts are ON CONFLICT upserts, so restarting never duplicates data. Disable it (SEED_ON_START=false) for real deployments after you have created your own admin account.
  4. Starts the server — node backend/dist/index.js (Express on port 4000):
    • /api/* → REST API (mirrors the dev-time Vite proxy rewrite)
    • /uploads/* → uploaded MC attachments
    • /health and /api/health → health probe
    • everything else → the built React SPA, with an index.html fallback for client-side routes (/login, /timesheet, …)
    • starts the cron scheduler (holiday sync, comp-off expiry, submission reminders — all in Asia/Kuala_Lumpur time)
⁠Seeded accounts

Password for all: Password123!

EmailRoleGroups
[email protected]⁠admin—
[email protected]⁠managerEngineering Team, IT Department
[email protected]⁠managerIT Department
[email protected]⁠employeeEngineering Team, IT Department
[email protected]⁠employeeEngineering Team
[email protected]⁠employeeEngineering Team

⚠️ Change these passwords immediately if you expose the app beyond localhost. Log in as each user (or use the admin user management endpoints) and rotate them. All seeded passwords are bcrypt hashes of Password123!.

⁠Stopping / resetting
docker compose down             # stop app + db (data preserved in volumes)
docker compose down -v          # stop and DELETE all data (volumes included)
docker compose logs -f app      # tail application logs
docker compose logs -f db       # tail Postgres logs
docker compose restart app      # restart just the app (re-runs migrations, harmless)

⁠Configuration reference

Everything is configured via environment variables (see .env.example⁠ for the full annotated list). In Docker, docker compose reads a .env file placed next to docker-compose.yml and substitutes values; every variable also has a working default so docker compose up works out of the box.

⁠Database
VariableDefault (compose)Description
DATABASE_URLpostgres://timesheet:timesheet@db:5432/timesheetPostgres connection string. Set automatically by compose; override to use your own database.
POSTGRES_USERtimesheetPostgres superuser name (compose db service).
POSTGRES_PASSWORDtimesheetPostgres password (compose db service).
POSTGRES_DBtimesheetDatabase name (compose db service).
POSTGRES_PORT5433Host port mapping for the db service. Set to an empty value to unpublish.
⁠Auth / security
VariableDefaultDescription
JWT_ACCESS_SECRETchange-me-accessHMAC secret for access tokens. Must be changed in production.
JWT_REFRESH_SECRETchange-me-refreshHMAC secret for refresh tokens. Must be changed in production.
JWT_ACCESS_TTL15mAccess token lifetime.
JWT_REFRESH_TTL7dRefresh token lifetime.
BCRYPT_ROUNDS10bcrypt cost factor for password hashing.
PORT4000Express listen port (inside the container).

💡 Generate strong secrets with openssl rand -hex 32.

⁠Server / network
VariableDefaultDescription
CORS_ORIGINhttp://localhost:8080Allowed browser origin for CORS. Since the SPA is served from the same origin in the Docker image, this rarely needs changing unless you host the frontend separately.
APP_PORT8080Host port the app is published on (compose only).
TZAsia/Kuala_LumpurContainer timezone (cron jobs always run in KL time regardless).
⁠Email (SMTP)
VariableDefaultDescription
SMTP_HOST(empty)SMTP server hostname. When empty/localhost, sending fails gracefully and is recorded in email_log.
SMTP_PORT1025SMTP port (587 for STARTTLS, 465 with SMTP_SECURE=true).
SMTP_SECUREfalseUse TLS on connect (port 465).
SMTP_USER / SMTP_PASS(empty)SMTP credentials.
MAIL_FROMTimesheet App <[email protected]>From address.
⁠Holidays (Google Calendar API)
VariableDefaultDescription
GOOGLE_CALENDAR_API_KEY(empty)API key for the Google Calendar API v3. Without it, the monthly sync is a no-op and admins can maintain holidays manually.
GOOGLE_CALENDAR_IDen.malaysia#[email protected]Malaysian public holidays calendar.
HOLIDAY_SYNC_CRON0 6 1 * *Sync schedule (monthly, 06:00 on day 1, KL time).
⁠Comp-Off engine
VariableDefaultDescription
COMP_OFF_HALF_DAY_MINUTES0OT minutes above this earns a half day (0 = disabled).
COMP_OFF_FULL_DAY_MINUTES240OT minutes ≥ this earns a full day (4h).
COMP_OFF_CAP_MINUTES480Cap: 1 full day per OT entry (excess earns nothing).
COMP_OFF_EXPIRY_DAYS90Comp-offs expire after this many days (daily cron).
COMP_OFF_ELIGIBLE_WINDOWSworkday_after_hours,weekend,public_holidayWhen OT qualifies for comp-off.
⁠Submission
VariableDefaultDescription
ALLOW_SUBMIT_WITH_PENDINGfalseAllow submitting the monthly timesheet while items are pending approval.
SUBMIT_FROM_LAST_DAY_OF_MONTHtrueSubmission window opens on the last day of the month.
⁠Uploads
VariableDefaultDescription
UPLOAD_DIR/app/uploads (image) / ./uploads (dev)Where MC attachments are stored. Compose mounts a named volume here for persistence.
MAX_UPLOAD_MB5Max upload size in MB (PDF/PNG/JPG only).
⁠Docker-specific
VariableDefaultDescription
SEED_ON_STARTtrue (compose)Run the idempotent demo seed at container start. Set to false in production.
NODE_ENVproduction (image)Set by the image; do not override.

⁠Running without Docker

Prerequisites: Node.js ≥ 20 and PostgreSQL ≥ 14.

  1. Install dependencies (repo root):

    npm install
    
  2. Configure environment:

    cp .env.example .env
    # edit DATABASE_URL, JWT secrets, SMTP, Google Calendar API key
    
  3. Create the database:

    createdb timesheet   # or: psql -c "CREATE DATABASE timesheet;"
    
  4. Run migrations:

    npm run db:migrate
    
  5. Seed (admin, 2 managers, 3 employees, 2 groups, 2026 Malaysian holidays):

    npm run db:seed
    
  6. Run:

    npm run dev:backend    # http://localhost:4000
    npm run dev:frontend   # http://localhost:5173 (proxies /api → :4000)
    

    In dev the SPA runs on the Vite dev server (port 5173) which proxies /api and /uploads to the backend. In production (Docker image) the built SPA is served by Express itself — no Vite, no separate frontend container.


⁠Docker image details

⁠Build stages

The Dockerfile⁠ is a multi-stage build producing a small Alpine-based runtime:

StageBasePurpose
depsnode:22-alpinenpm ci with the workspace manifests only (optimal layer caching).
buildnode:22-alpinetsc for the backend, vite build for the SPA.
prod-depsnode:22-alpinenpm ci --omit=dev — production node_modules for the backend only.
runtimenode:22-alpineFinal image: compiled backend, migrations, built SPA, prod deps, tini, entrypoint.

Notes:

  • The final image contains no devDependencies, no TypeScript sources, no test files.
  • tini is PID 1 → docker stop cleanly interrupts Express and the cron scheduler.
  • Working directory is /app; the app listens on 4000 (publish it however you like).
  • .dockerignore keeps node_modules, local data/, .tools/, and .env out of the build context (secrets never enter the image).
⁠Using your own Postgres

The image is database-agnostic. Point DATABASE_URL at any reachable Postgres ≥ 14:

docker run -d --name timesheet \
  -p 8080:4000 \
  -e DATABASE_URL="postgres://user:pass@your-postgres-host:5432/timesheet" \
  -e JWT_ACCESS_SECRET="$(openssl rand -hex 32)" \
  -e JWT_REFRESH_SECRET="$(openssl rand -hex 32)" \
  -e SEED_ON_START=false \
  -v timesheet-uploads:/app/uploads \
  garfieldwtf/timesheet:latest

The entrypoint waits for that database, migrates it, and starts the server.

⁠Publishing to a registry
docker build -t garfieldwtf/timesheet:latest .
docker push garfieldwtf/timesheet:latest

# versioned tags are recommended:
docker build -t garfieldwtf/timesheet:1.0.0 -t garfieldwtf/timesheet:latest .
docker push garfieldwtf/timesheet:1.0.0
⁠Running the image directly (no compose)
docker network create timesheet-net
docker run -d --name timesheet-db --network timesheet-net \
  -e POSTGRES_USER=timesheet -e POSTGRES_PASSWORD=timesheet -e POSTGRES_DB=timesheet \
  -v timesheet-pgdata:/var/lib/postgresql/data postgres:16-alpine
docker run -d --name timesheet-app --network timesheet-net \
  -p 8080:4000 \
  -e DATABASE_URL="postgres://timesheet:timesheet@timesheet-db:5432/timesheet" \
  -e JWT_ACCESS_SECRET="change-me" -e JWT_REFRESH_SECRET="change-me" \
  -v timesheet-uploads:/app/uploads \
  garfieldwtf/timesheet:latest

⁠Health checks & backups

  • Liveness: GET /health (or /api/health) → {"ok":true} — no auth required.

  • Database backups (compose stack):

    # backup
    docker compose exec db pg_dump -U timesheet timesheet > backup_$(date +%F).sql
    # restore
    cat backup_2026-01-15.sql | docker compose exec -T db psql -U timesheet timesheet
    
  • Uploaded attachments live in the uploads volume (/app/uploads in the app container). Back it up along with the database:

    docker run --rm -v timesheet2_uploads:/data -v ${PWD}:/backup alpine \
      tar czf /backup/uploads_$(date +%F).tar.gz -C /data .
    

⁠Comp-Off rules (critical business logic)

Only time outside 09:00–18:00 counts as overtime (configurable per user via standard_start/standard_end). Cross-midnight windows are supported (e.g. 22:00→02:00 = 4h).

OT minutes (outside standard hours)Comp-Off earned
0NONE
> 0 and < 240 (4h)HALF_DAY
≥ 240 and ≤ 480 (8h)FULL_DAY
> 480FULL_DAY (capped; excess recorded but earns nothing)
  • The comp-off is auto-assigned to the next working day (skipping weekends and public holidays; rolls into the next month/year when earned on the last day of the month — the record is created for the following month's timesheet).
  • Employees can request a different date (POST /me/comp-off/:id/request-date); requires manager approval; rejection falls back to the default assignment.
  • Comp-offs expire after COMP_OFF_EXPIRY_DAYS (default 90); a daily cron marks them expired.
  • Eligibility windows are configurable via COMP_OFF_ELIGIBLE_WINDOWS (workday_after_hours, weekend, public_holiday).
  • Stacking rule: each overtime entry earns at most one comp-off (half or full). Two half-day comp-offs are two separate records; they do not merge into one full day automatically. A day can carry only one COMP_OFF code; if two comp-offs resolve to the same date, one must be moved via a date-change request.

⁠Approval workflow

draft → (submit, from last day of month) → submitted → (approve|reject) → approved (read-only, terminal) | rejected (editable, resubmit allowed).

  • Managers cannot approve their own timesheet (server-enforced).
  • All managers of the employee's group(s) are notified; first to act wins; the others receive a resolution notice. Approving an already-approved timesheet is idempotent ({ ok: true, alreadyApproved: true }).
  • Rejection requires a reason; the reason is visible in-app and emailed.
  • If a manager is deactivated mid-approval, an hourly cron alerts admins to reassign the pending items.

⁠Verification

  • npm test — 47 unit tests covering: comp-off calculation (all hour boundaries, cross-midnight, eligibility windows), next-working-day resolver (weekends, PHs, month/year rollover), approval state machine, edit-history diffing.
  • End-to-end smoke-tested against a live PostgreSQL: login → grid generation → OT logging with comp-off auto-calculation → leave request + approval → comp-off date change request + approval → submission gating → multi-manager approval, self-approval block, post-approval read-only enforcement, idempotent approve, and blocked post-approval rejection.
  • The Docker production layout was validated end-to-end: compiled backend + built SPA served by Express — /health, /api/health, /api/auth/login, SPA root and client-side routes all verified against a live PostgreSQL.
  • npm run build (frontend) type-checks under strict TS and builds.

⁠Notes

  • All date logic is UTC+8 (KL, no DST); attendance dates are DATE columns.
  • Emails are logged in email_log (sent/failed) — without SMTP the send fails gracefully and is recorded.
  • Holiday sync runs monthly via Google Calendar API (GOOGLE_CALENDAR_API_KEY); admin overrides are never deleted by sync.
  • File uploads (MC attachments) are restricted to PDF/PNG/JPG and size-capped.
  • API reference: openapi.yaml⁠

Tag summary

Content type

Image

Digest

sha256:942cad7cd…

Size

75.8 MB

Last updated

1 day ago

docker pull garfieldwtf/timesheet