Skip to main content

Work Calendar, Appointments & Workload

Three modules share one idea: when does each person work, and how much of that time is booked?

ModulePathRole
WorkCalendarModule (@Global())apps/api/src/modules/work-calendar/Business hours, per-user hours, public holidays, leave types and requests, working-time maths
AppointmentsModuleapps/api/src/modules/appointments/Appointment types, appointments (on top of work requests and tasks), booking links, invites, reminders
WorkloadModuleapps/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.

MigrationAdds
130_business_hours_time_offmodule_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_appointmentswork_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_linksappointment_booking_links. appointments.booking_link_id, client_requested_start, client_request_note. Templates appointment_client_received, appointment_client_change_requested.

Tables​

Working time (130)​

TableKey columns
module_settings general/business_hours{ days: [{ dayOfWeek 0–6, isWorkingDay, start 'HH:MM', end, breakMinutes }] }. Timezone is company_settings.timezone.
users.uses_custom_hoursfalse = business hours; true = user_working_hours rows apply
user_working_hoursuser_id, day_of_week, is_working_day, start_time, end_time, break_minutes; UNIQUE (user_id, day_of_week)
public_holidaysname, holiday_date, is_half_day, is_recurring (same month/day every year); unique date among non-deleted
time_off_typesname, slug (unique), color, is_paid, requires_approval, is_system, is_active, sort_order
time_off_requestsuser_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)​

TableKey columns
appointment_typeswork_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 }]
appointmentsappointment_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_attendeesis_client, contact_id, user_id, name, email (unique per appointment, case-insensitive), phone, response_status
appointment_remindersaudience, 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_linkstoken (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 from BusinessHoursService.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, returns null if no working time). For each day the UTC window [start, end] is used with a ratio available ÷ 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. Without userId it 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:

  1. Validate dates/portions (multi-day: start may be second_half, end may be first_half; max 366 days), reject overlaps with pending/approved requests (409), price it (400 if 0 minutes).
  2. Auto-approve when recorded for someone else (caller must lead them or be admin) or the type has requires_approval = false.
  3. Otherwise ApprovalService.createRequest(schema, 'time_off', id, 'time_off_request', actor, { typeSlug, totalHours, isPaid }). A matching rule stores approval_request_id; decisions go through the engine and come back via a registered decision handler (applyEngineDecision).
  4. No rule → fallbackApprovers: manager → team leads of the user's teams → department head → active admins (first non-empty tier), notified with time_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.dueAfter uses workingTime.addWorkingHours when config.useBusinessHours !== false (default true), falling back to the SLA's own calculateDueDate when it is off or returns null. 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:

EventAppointmentWork requestTask
book (create)requestedcreated (type's request type, silent, coordinatorOnly), estimated_minutes = duration + buffers + prep, due_date = proposed—
returnToRequester (note, suggestedStart?)reschedulingreturned—
resubmitrequestedrequested (SLA restarts)—
assign (host, confirmedStart, estimatedMinutes 5–1440 required)confirmedassigned to hostcreated: 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 clearedreassigned if host changedmoved / reassigned
complete (notes required, actualMinutes required)completedtime entry logged, completedactual_minutes, completed
complete(noShow) (after start)no_showcompletedcompleted
cancelcancelledcancelledsoft-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):

  1. Existing google_event_id → update/cancel it on organiser_user_id's calendar (fall back to .ics email if the update fails).
  2. New invite → pickOrganiser: appointments/settings.organiserUserId if that user has an active Google connection, else the host if connected. CalendarSyncService.createMeetingEvent(..., withMeet: mode === 'video'); stores google_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).
  3. No Google → AppointmentMessagingService.emailClients sends the appointment_client_invite / _cancelled template with an invite.ics / cancel.ics attachment.

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.

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):

  1. Clip to [today, today + booking_window_days] (tenant timezone).
  2. Hosts = host_user_ids, else active members of the request type's owning team.
  3. Per host per day: the working-calendar window, skipped on holidays or any approved leave; intersected with user_availability for 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 at max_per_host_per_day confirmed appointments.
  4. Busy intervals per host: confirmed appointments ± buffers; open tasks of category meeting/call or with appointment_id (due_date + estimate / default duration / 30 min); project_meetings attendees; confirmed form_bookings; calendar_events with source = 'google'.
  5. Pending: unassigned requests of the same type each hold one host for their proposed time.
  6. Candidate starts step from each window start by max(15, duration) minutes; a start is offered when start >= now + min_notice_hours and (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 type default_duration_minutes, else 0; kind appointment when appointment_id is set, meeting for category meeting/call, else task. Completed tasks are included.
  • Project meetings — each internal attendee, duration_minutes, kind meeting.
  • Project tasks — open (not completed, status not is_done, project status not is_closed) with estimated_hours > logged_hours and a start or due date: remaining hours spread from max(start, today) to max(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), null when nothing is available. Status bands: < 60 underused, 60–84 healthy, 85–100 high, > 100 overloaded, null none.

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​

MethodPathPermission
GET/work-calendar/business-hoursany 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/previewself, 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? } · canceldecide / cancel rule
GET/work-calendar/my-peoplepeople the caller leads (admins: everyone)
GET/work-calendar/users/:userId/hoursself, 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​

MethodPathPermission
GET/appointments/types · /types/:idappointments.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/:linkIdappointments.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.

MethodPath
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​

MethodPathPermission
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​

CronScheduleLockJob
AppointmentRemindersCronevery minuteappointment-reminders.cron, 5 minappointment-reminders per tenant; skips tenants without appointment_reminders
WorkRequestSlaCronevery 5 minuteswork-request-sla.cron, 15 minwork-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​

  • appointments module: view / create / edit / delete / export. Granted view/create/edit to every existing role by 131.
  • Business hours, holidays, leave types, appointment types and the organiser need settings.edit to change.
  • Time off, working hours and calendars use the leader relation (manager / team lead / department head), not an RBAC module.