Work Calendar, Appointments & Workload
Three modules share one idea: when does each person work, and how much of that time is booked?
| Module | Path | Role |
|---|---|---|
WorkCalendarModule (@Global()) | apps/api/src/modules/work-calendar/ | Business hours, per-user hours, public holidays, leave types and requests, working-time maths |
AppointmentsModule | apps/api/src/modules/appointments/ | Appointment types, appointments (on top of work requests and tasks), booking links, invites, reminders |
WorkloadModule | apps/api/src/modules/workload/ | Occupancy per person per day; free hours for the booking form |
Consumers of WorkingTimeService: lead SLA (leads/sla.service.ts), work request response SLA, appointments (candidates, warnings, slots), workload and time-off pricing.
apps/api/src/modules/
├── work-calendar/
│ ├── work-calendar.module.ts ← @Global, exports all four services
│ ├── work-calendar.controller.ts ← /work-calendar
│ ├── business-hours.service.ts ← tenant week + per-user hours (60 s cache)
│ ├── holidays.service.ts ← public_holidays, recurring expansion
│ ├── time-off.service.ts ← leave types + requests + approval
│ ├── working-time.service.ts ← calendars, addWorkingHours, workingMinutesBetween
│ ├── work-calendar.types.ts
│ └── tz.util.ts ← date keys, zonedToUtc, safeTimezone
├── appointments/
│ ├── appointments.module.ts
│ ├── appointments.controller.ts ← /appointments (JWT)
│ ├── appointments-public.controller.ts ← /appointments/public (no auth, rate limited)
│ ├── appointment-types.service.ts ← types + organiser setting
│ ├── appointments.service.ts ← lifecycle (appointment ⇄ work request ⇄ task)
│ ├── appointment-booking.service.ts ← booking links + public book/manage
│ ├── appointment-slots.service.ts ← free-slot engine
│ ├── appointment-messaging.service.ts ← client .ics emails, reminders
│ └── appointment-reminders.cron.ts
├── workload/
│ ├── workload.controller.ts ← /workload
│ └── workload.service.ts
└── work-requests/
└── work-request-sla.cron.ts ← response SLA alerts
apps/web/src/
├── api/work-calendar.api.ts ← workCalendarApi
├── api/appointments.api.ts ← appointmentsApi, publicAppointmentsApi
├── api/workload.api.ts ← workloadApi
├── features/admin/BusinessHoursPage.tsx ← /admin/business-hours
├── features/admin/AppointmentSettingsPage.tsx ← /admin/appointment-settings
├── features/time-off/ ← /time-off
├── features/appointments/ ← /appointments, /appointments/:id,
│ /book-appointment/:tenantSlug/:token (public),
│ /appointment/:tenantSlug/:token (public manage)
├── features/workload/ ← /team-calendar, /workload
├── features/users/WorkingHoursCard.tsx ← profile → Availability tab
├── components/shared/WeekHoursEditor.tsx
├── components/shared/appointments/ ← EntityAppointmentsPanel, NewAppointmentModal,
│ BookingLinksModal, SlotPicker, …
└── utils/occupancy.ts ← status/kind metadata, week helpers
Migrations
All three are in apps/api/src/scripts/run-tenant-migrations.ts. See Database Migrations.
| Migration | Adds |
|---|---|
130_business_hours_time_off | module_settings('general','business_hours') seeded from the enabled lead SLA config, else Mon–Fri 09:00–17:00, breakMinutes: 0. users.uses_custom_hours + user_working_hours, seeded for users whose old weekly_capacity_hours ≠ 40 (≤ 60). public_holidays. time_off_types (seeded Annual / Sick / Casual / Unpaid). time_off_requests. approval_rules CHECKs widened with entity time_off and trigger time_off_request. task_types.category (task·meeting·call) + counts_toward_occupancy; built-in Meeting/Demo/Onboarding → meeting, Call → call; new system type Internal meeting. Templates time_off_requested, time_off_decided. |
131_appointments | work_request_types.is_schedulable, coordinator_assign_only, response_sla_hours. work_requests response-SLA and return columns, status CHECK with under_review / returned, appointment_id. appointment_types, appointments (+ appointment_number_seq, APT-000001), appointment_attendees, appointment_reminders. tasks.appointment_id. RBAC: every role gets appointments view/create/edit (delete false) when it has no appointments key. Templates for the internal appointment events, the SLA events and the client invite / reminder / cancelled emails. |
132_appointment_booking_links | appointment_booking_links. appointments.booking_link_id, client_requested_start, client_request_note. Templates appointment_client_received, appointment_client_change_requested. |
Tables
Working time (130)
| Table | Key columns |
|---|---|
module_settings general/business_hours | { days: [{ dayOfWeek 0–6, isWorkingDay, start 'HH:MM', end, breakMinutes }] }. Timezone is company_settings.timezone. |
users.uses_custom_hours | false = business hours; true = user_working_hours rows apply |
user_working_hours | user_id, day_of_week, is_working_day, start_time, end_time, break_minutes; UNIQUE (user_id, day_of_week) |
public_holidays | name, holiday_date, is_half_day, is_recurring (same month/day every year); unique date among non-deleted |
time_off_types | name, slug (unique), color, is_paid, requires_approval, is_system, is_active, sort_order |
time_off_requests | user_id, type_id, start_date, end_date, start_portion / end_portion (full·first_half·second_half), total_hours (snapshot at request time), reason, status (pending·approved·rejected·cancelled), approval_request_id, decided_by/at, decision_comment |
user_availability (booking availability, "My Availability") is unchanged and separate from working hours.
Appointments (131, 132)
| Table | Key columns |
|---|---|
appointment_types | work_request_type_id (who coordinates), task_type_id (meeting task), duration_minutes, buffer_before/after_minutes, prep_minutes, meeting_modes TEXT[], default_location, required_skill_tags TEXT[], host_user_ids UUID[] (empty = owning team), min_notice_hours (24), booking_window_days (60), max_per_host_per_day, reminders JSONB [{ audience: client|internal, offsetMinutes, channels }] |
appointments | appointment_number, type_id, title, agenda, entity_type/entity_id (primary record), account_id, project_id, status (requested·rescheduling·confirmed·completed·cancelled·no_show), priority (normal·high·urgent), source (internal·client_booking), meeting_mode, location, meet_link, timezone, duration_minutes, buffers, proposed_start, alternative_slots JSONB, suggested_start + return_note, confirmed_start/end, preferred_host_id, host_user_id, requested_by, work_request_id, task_id, organiser_user_id, google_event_id, outcome_notes, reschedule_count, cancel_*, cancel_token, reschedule_token (the client's manage token), booking_link_id, client_requested_start, client_request_note |
appointment_attendees | is_client, contact_id, user_id, name, email (unique per appointment, case-insensitive), phone, response_status |
appointment_reminders | audience, channel (email·whatsapp·sms·notification), attendee_id / user_id, send_at, status (pending·sent·failed·skipped), error. Partial index on send_at WHERE status = 'pending' |
appointment_booking_links | token (unique), appointment_type_id, entity_type/entity_id, account_id, project_id, title, is_active, expires_at, booking_count, last_booked_at |
Working-Time Maths
WorkingTimeService builds a WorkingDay per user per local date:
scheduledMinutes = end − start − breakMinutes (0 on non-working days)
holidayMinutes = scheduled (½ for half-day holidays)
timeOffMinutes = per approved request: full → scheduled − holiday,
first_half / second_half → min(scheduled / 2, scheduled − holiday)
availableMinutes = max(0, scheduled − holiday − timeOff)
getCalendars(schema, userIds, from, to)— max 400 days. User hours come fromBusinessHoursService.getUsersHours(custom rows or the tenant week, all read in the tenant timezone). Only approved leave is subtracted.addWorkingHours(schema, start, hours, userId?)— walks forward in 62-day chunks (max 400 days, returnsnullif no working time). For each day the UTC window[start, end]is used with a ratioavailable ÷ window span, so breaks, half-day holidays and half-day leave are spread evenly across the window rather than placed at a time of day. WithoutuserIdit uses tenant business hours and holidays (no leave) — this is what lead SLA and response SLA use.workingMinutesBetween(start, end, userId?)— the inverse, same ratio model.timeOffMinutes(...)— prices a leave request: the user's hours in range minus holidays, with half-day portions at the ends; other leave is ignored.
BusinessHoursService.getBusinessHours is cached per tenant for 60 seconds, so a timezone change in General Settings can take up to a minute to apply.
Time Off
TimeOffService.create:
- Validate dates/portions (multi-day: start may be
second_half, end may befirst_half; max 366 days), reject overlaps with pending/approved requests (409), price it (400if 0 minutes). - Auto-approve when recorded for someone else (caller must lead them or be admin) or the type has
requires_approval = false. - Otherwise
ApprovalService.createRequest(schema, 'time_off', id, 'time_off_request', actor, { typeSlug, totalHours, isPaid }). A matching rule storesapproval_request_id; decisions go through the engine and come back via a registered decision handler (applyEngineDecision). - No rule →
fallbackApprovers: manager → team leads of the user's teams → department head → active admins (first non-empty tier), notified withtime_off_requested.
"Leads" (isLeaderOf / ledUsersSql) = manager, team lead of one of the user's teams, or head of their department. Permissions per request: view owner / leader / current engine approver; decide pending and not owner — engine approver when rule-driven, else leader or fallback approver; cancel owner (pending, or approved and not started) or leader (pending/approved).
Lead SLA and Response SLA
SlaService.dueAfterusesworkingTime.addWorkingHourswhenconfig.useBusinessHours !== false(defaulttrue), falling back to the SLA's owncalculateDueDatewhen it is off or returnsnull. Used for first-contact and escalation due dates.- Work request response SLA: see Work Requests Module — Response SLA.
Appointment Lifecycle
AppointmentsService keeps three records in step:
| Event | Appointment | Work request | Task |
|---|---|---|---|
book (create) | requested | created (type's request type, silent, coordinatorOnly), estimated_minutes = duration + buffers + prep, due_date = proposed | — |
returnToRequester (note, suggestedStart?) | rescheduling | returned | — |
resubmit | requested | requested (SLA restarts) | — |
assign (host, confirmedStart, estimatedMinutes 5–1440 required) | confirmed | assigned to host | created: type's task_type_id (default Meeting), related_entity = work_requests, appointment_id set |
reschedule (time and/or host) | confirmed, reschedule_count + 1, client request cleared | reassigned if host changed | moved / reassigned |
complete (notes required, actualMinutes required) | completed | time entry logged, completed | actual_minutes, completed |
complete(noShow) (after start) | no_show | completed | completed |
cancel | cancelled | cancelled | soft-deleted |
Every step writes an audit row; book / confirm / reschedule / cancel / complete / no-show also write an activity on the primary record and on its account (appointment_* activity types). The work request controller blocks direct actions on appointment-linked requests (assertNotAppointment).
Coordinator = roleLevel >= 100 or WorkRequestsService.canManage on the request (admins, all record access, owning team lead, owning department head). findOne().can exposes edit, assign, returnToRequester, resubmit, reschedule, cancel, complete (host or coordinator), noShow (after start), followUp.
candidates(id, at) — the type's hosts (else the owning team's members) with, for the local day of at: availableMinutes (working calendar), bookedMinutes (open task estimates due that day, counts_toward_occupancy), freeMinutes, dayOccupancy, onLeave, confirmed-appointment clashes, skillMatch against users.skill_tags, isPreferred. Sorted preferred → fewest clashes → most skills → most free.
Warnings returned after assign/reschedule: full-day holiday, on leave, non-working day, clashes, video meeting without a Meet link.
Invites
sendInvites(kind):
- Existing
google_event_id→ update/cancel it onorganiser_user_id's calendar (fall back to.icsemail if the update fails). - New invite →
pickOrganiser:appointments/settings.organiserUserIdif that user has an active Google connection, else the host if connected.CalendarSyncService.createMeetingEvent(..., withMeet: mode === 'video'); storesgoogle_event_id,organiser_user_id,meet_link. Attendees: client emails + requester + host (the host is skipped when their own Google is connected, since the task syncs). - No Google →
AppointmentMessagingService.emailClientssends theappointment_client_invite/_cancelledtemplate with aninvite.ics/cancel.icsattachment.
Client emails get a manage link (APP_URL/appointment/<tenantSlug>/<reschedule_token>) appended when the template doesn't include {{manageUrl}}.
Reminders
writeReminders (on confirm and reschedule) clears pending rows (skipped) and inserts one row per rule × client attendee × channel (email needs an email, WhatsApp/SMS a phone), or per rule × (host, requester) for internal rules (channel = 'notification'). Past send times are skipped.
AppointmentRemindersCron — every minute, @CronLock('appointment-reminders.cron', 5 min), fanned out per tenant through TenantJobsService (appointment-reminders job). sendDue claims up to 200 due rows with UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING, drops reminders for appointments no longer confirmed or already started, sends client reminders through EmailChannel / SmsWhatsAppChannel (appointment_client_reminder) and internal ones as the appointment_reminder notification (channels from each user's preferences). Failures are marked failed with the error.
Booking Links and the Slot Engine
AppointmentBookingService — links are created by anyone with appointments.create and access to the record; the type must be active and the record type must be in its request type's allowed_entities. DELETE sets is_active = false and soft-deletes.
Public booking runs as the link's creator (linkActor; 410 if they are inactive) with record access skipped, and creates the appointment with source = 'client_booking', the client as the only attendee. The slot is re-checked (isFree) just before saving. The client gets appointment_client_received with the manage URL.
AppointmentSlotsService.slots(type, from, to):
- Clip to
[today, today + booking_window_days](tenant timezone). - Hosts =
host_user_ids, else active members of the request type's owning team. - Per host per day: the working-calendar window, skipped on holidays or any approved leave; intersected with
user_availabilityfor that weekday when the host has any availability rows (no row that day = not bookable); skipped when the window is shorter than the meeting or the host is atmax_per_host_per_dayconfirmed appointments. - Busy intervals per host: confirmed appointments ± buffers; open tasks of category
meeting/callor withappointment_id(due_date+ estimate / default duration / 30 min);project_meetingsattendees; confirmedform_bookings;calendar_eventswithsource = 'google'. - Pending: unassigned requests of the same type each hold one host for their proposed time.
- Candidate starts step from each window start by
max(15, duration)minutes; a start is offered whenstart >= now + min_notice_hoursand(free hosts for start − buffers … end + buffers) − overlapping pending > 0.
Client changes (clientRequestNewTime): a requested / rescheduling appointment simply moves (proposed_start, back to requested; a returned work request is resubmitted); a confirmed one keeps its time and stores client_requested_start / client_request_note for the coordinator. Both notify appointment_client_change_requested. clientCancel cancels as the requester. Changes are allowed while the appointment is open and hasn't started.
Occupancy Algorithm
WorkloadService.compute(userIds, from, to) (max 62 days for /workload/team, 14 for /workload/availability):
- Available =
WorkingDay.availableMinutes. - CRM tasks with a due date in range and
counts_toward_occupancy(null type = counts): minutes =estimated_minutes, else typedefault_duration_minutes, else 0; kindappointmentwhenappointment_idis set,meetingfor categorymeeting/call, elsetask. Completed tasks are included. - Project meetings — each internal attendee,
duration_minutes, kindmeeting. - Project tasks — open (not completed, status not
is_done, project status notis_closed) withestimated_hours > logged_hoursand a start or due date: remaining hours spread frommax(start, today)tomax(due, effStart)over the assignee's days with available time, in proportion to each day's available minutes (last day takes the rounding remainder; no working day in the span → all on the last day). Spans look ahead at most 180 days. occupancy = round(booked ÷ available × 100),nullwhen nothing is available. Status bands:< 60underused,60–84healthy,85–100high,> 100overloaded,nullnone.
team() also returns totals, byProject (project work only, excluding meetings/appointments) and up to six suggestions: move whole hours from people over 85 % to people under 60 % (down to 85 % / up to 85 % of available), assign_next to the person with ≥ 8 free hours, hold for people at 80–85 %.
Visibility (visiblePeople): admins / settings.edit see everyone; others see themselves, direct reports, members of teams they lead or belong to, and people in departments they head. Max 200 people; a filter that returns nobody for a non-admin is a 403.
Endpoints
All authenticated endpoints use JwtAuthGuard + PermissionGuard.
/work-calendar
| Method | Path | Permission |
|---|---|---|
| GET | /work-calendar/business-hours | any user |
| PUT | /work-calendar/business-hours — { days: [7 entries] } | settings.edit |
| GET | /work-calendar/holidays?year= (recurring included) | any user |
| POST / PUT / DELETE | /work-calendar/holidays[/:id] | settings.edit |
| GET | /work-calendar/time-off/types?includeInactive= | any user |
| POST / PUT / DELETE | /work-calendar/time-off/types[/:id] (built-in types can't be deleted) | settings.edit |
| GET | /work-calendar/time-off/requests?scope=mine|team|all&status=&userId=&from=&to=&page=&limit= | scoped in service |
| POST | /work-calendar/time-off/requests/preview | self, or a user you lead |
| POST | /work-calendar/time-off/requests — { typeId, startDate, endDate, startPortion, endPortion, reason, userId? } | self, or record for a user you lead |
| GET | /work-calendar/time-off/requests/:id (with can) | view rule |
| POST | /work-calendar/time-off/requests/:id/approve · reject — { comment? } · cancel | decide / cancel rule |
| GET | /work-calendar/my-people | people the caller leads (admins: everyone) |
| GET | /work-calendar/users/:userId/hours | self, leader or settings editor |
| PUT | /work-calendar/users/:userId/hours — { useCustomHours, days? } | leader or settings editor (not self) |
| GET | /work-calendar/calendar?from=&to=&userIds= (≤ 100 users) | others filtered to people you lead unless settings editor |
| GET | /work-calendar/calendar/business?from=&to= | any user |
/appointments
| Method | Path | Permission |
|---|---|---|
| GET | /appointments/types · /types/:id | appointments.view |
| POST / PUT / DELETE | /appointments/types[/:id] (delete refused with open appointments) | settings.edit |
| GET / PUT | /appointments/settings — { organiserUserId } | view / settings.edit |
| GET | /appointments/booking-links?entityType=&entityId= | appointments.view |
| POST | /appointments/booking-links — { appointmentTypeId, entityType, entityId, title?, expiresAt? } | appointments.create |
| DELETE | /appointments/booking-links/:linkId | appointments.create |
| GET | /appointments/stats — today, thisWeek, awaitingConfirmation, noShowRate (90 days) | view |
| GET | /appointments/by-entity/:entityType/:entityId (accounts/projects include their records) | view + record access |
| GET | /appointments?tab=upcoming|requests|past|cancelled|all&mine=&hostId=&projectId=&accountId=&from=&to=&search=&page=&limit= | view (record access filter) |
| POST | /appointments — { typeId, entityType, entityId, title?, agenda?, proposedStart, alternativeSlots? (≤ 5), meetingMode?, location?, durationMinutes?, priority?, preferredHostId?, attendees? (≤ 20), projectId?, requestData? } | create |
| GET | /appointments/:id (with can, attendees) | view |
| GET | /appointments/:id/candidates?at= | view (coordinator only) |
| PUT | /appointments/:id (while requested / rescheduling) | edit |
| POST | /appointments/:id/return — { note, suggestedStart? } · resubmit — { proposedStart, alternativeSlots?, note? } · assign — { hostUserId, confirmedStart, estimatedMinutes, notes?, meetingMode?, location? } · reschedule — { confirmedStart?, hostUserId?, estimatedMinutes? } · cancel — { reason? } · complete — { outcomeNotes, actualMinutes } · no-show — { outcomeNotes? } | edit |
/appointments/public (no auth)
Manage routes are declared before the booking routes so manage is never read as a tenant slug. Writes are rate limited (RateLimit): booking 10 per 10 minutes per IP + tenant + token; manage writes 10 per 10 minutes per IP + token.
| Method | Path |
|---|---|
| GET | /appointments/public/manage/:tenantSlug/:token — summary, canChange, isConfirmed |
| GET | /appointments/public/manage/:tenantSlug/:token/dates?month=YYYY-MM · /slots?date=YYYY-MM-DD |
| POST | /appointments/public/manage/:tenantSlug/:token/request-time — { start, note? } |
| POST | /appointments/public/manage/:tenantSlug/:token/cancel — { reason? } |
| GET | /appointments/public/:tenantSlug/:token — link info (410 when inactive/expired) |
| GET | /appointments/public/:tenantSlug/:token/dates?month= · /slots?date= (UTC ISO starts) |
| POST | /appointments/public/:tenantSlug/:token/book — { name, email, phone?, start, meetingMode?, agenda?, timezone? } |
/workload
| Method | Path | Permission |
|---|---|---|
| GET | /workload/team?from=&to=&teamId=&departmentId=&userIds= (≤ 62 days) | any user (visibility filtered) |
| GET | /workload/availability?appointmentTypeId=&from=&to= (≤ 14 days; free minutes per host per day, no work details) | appointments.create |
/work-requests (additions)
POST /work-requests/:id/review · /return · /resubmit — see Work Requests Module.
Crons
| Cron | Schedule | Lock | Job |
|---|---|---|---|
AppointmentRemindersCron | every minute | appointment-reminders.cron, 5 min | appointment-reminders per tenant; skips tenants without appointment_reminders |
WorkRequestSlaCron | every 5 minutes | work-request-sla.cron, 15 min | work-request-sla per tenant; skips tenants without work_requests.response_due_at |
Both use TenantJobsService.fanOut, so they run on the worker role rather than inside the scheduler.
Notifications
New event types in NOTIFICATION_EVENT_TYPES (defaults in brackets): time_off_requested, time_off_decided, appointment_requested, appointment_returned, appointment_confirmed, appointment_rescheduled, appointment_cancelled, appointment_client_change_requested (in-app + email); appointment_reminder, work_request_sla_warning (in-app only); work_request_sla_breached (in-app + email). Client-only templates (not preferences): appointment_client_invite, appointment_client_reminder, appointment_client_cancelled, appointment_client_received.
RBAC
appointmentsmodule: view / create / edit / delete / export. Granted view/create/edit to every existing role by131.- Business hours, holidays, leave types, appointment types and the organiser need
settings.editto change. - Time off, working hours and calendars use the leader relation (manager / team lead / department head), not an RBAC module.