Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

19 KiB

PRD — Teams Meeting Link on Appointment Approval

  • Slug: teams-meeting-on-appointment-approval
  • Date: 2026-04-23
  • Status: Draft

Summary

Generate a Microsoft Teams online meeting link automatically when an MSP staff member approves an appointment request, controlled per-appointment by a toggle on the approval form. The Teams join URL is embedded in the client-facing approval email, the calendar ICS attachment, the MSP schedule-entry view, and the client-portal appointment detail page. Meetings follow the appointment's lifecycle: they are patched when the schedule is changed and deleted when the appointment is cancelled.

Problem

MSP operators running virtual consultations currently have to create a Teams meeting manually after approving an appointment request, then paste the join link into a follow-up email to the client. This is slow, error-prone, and leaves the ICS calendar attachment without a join URL. With the Teams integration shipping for tenant notifications, we have a viable path to create meetings on the tenant's behalf and close the loop.

Goals

  • Let MSP approvers create a Teams meeting at approval time with a single toggle (no second screen, no copy-paste).
  • Ship the join URL to the client via the existing approval email and ICS attachment.
  • Keep the meeting in sync when the appointment is rescheduled or cancelled so the link never points at a stale time.
  • Expose tenant-level configuration (default meeting organizer) in an existing settings surface so admins can turn the feature on without a developer.
  • EE-only: CE builds must continue to work unchanged.

Non-goals

  • Supporting providers other than Microsoft Teams (Zoom, Google Meet). The schema is generic to allow this later, but only the Teams provider is implemented.
  • Per-user delegated OAuth. All meetings are created as a designated tenant organizer via app-only auth.
  • A general "integrations" admin hub. The configuration lives inside Availability Settings → Teams Meetings tab because it is tied to the appointment-approval workflow, not a standalone concern.
  • Retries / background queues / circuit breakers for Graph calls. On failure we log, show a warning toast, and let the approver retry manually if desired.
  • Recording/transcript policies, lobby/PSTN options, federated participants. Meetings are created with Graph defaults.
  • Translating templates beyond English for the new strings. Pseudo-locale regeneration + translation pass can follow the usual i18n pipeline.

Users and Primary Flows

MSP approver (primary)

  1. Opens a pending appointment request in the MSP panel or via the calendar Edit Entry modal.
  2. Sees the "Generate Teams meeting link" toggle (only if their tenant has Teams configured AND has an organizer set). Toggle defaults ON.
  3. Clicks Approve. Schedule entry is created and — if toggle on — a Teams meeting is created on behalf of the tenant's designated organizer.
  4. Sees the join URL on the approved appointment's detail view, and a green success toast. If the Graph call failed, sees a yellow warning toast explaining the approval succeeded but the meeting was not created.

MSP admin (setup)

  1. Navigates to Scheduling → Availability Settings. The "Teams Meetings" tab is visible only when teams_integrations.install_status = 'active'.
  2. Enters the default meeting organizer UPN (e.g., scheduling@acme.com). Saves.
  3. A validation server action optionally verifies the UPN resolves to a Microsoft user (best-effort; failure is a warning, not a blocker).

MSP approver (reschedule)

  1. Opens an approved appointment request and updates final_date / final_time.
  2. If the appointment has an online_meeting_id, the server patches the meeting via Graph to the new times.
  3. If the PATCH fails, the reschedule still succeeds; a warning toast informs the approver.

MSP approver (cancel / decline)

  1. Clicks Cancel or Delete on an approved appointment that has an associated Teams meeting.
  2. A confirmation dialog warns: "This will also delete the Microsoft Teams meeting."
  3. On confirm, the Graph DELETE is attempted. Success or failure, the appointment state change proceeds; Graph errors become warnings.

Client (end user)

  1. Receives the approval email with a "Join Teams Meeting" button.
  2. Opens the ICS attachment in their calendar; the LOCATION field shows "Microsoft Teams Meeting" and the URL/description contain the join link.
  3. In the client portal, the appointment detail page shows a prominent "Join Teams Meeting" button.

UX / UI Notes

Approval form — toggle

  • Label: "Generate Microsoft Teams meeting link"
  • Placement: between "Assign Technician" and "Internal Notes" in the approval form within AppointmentRequestsPanel.tsx and in the pending-request approval UI in EntryPopup.tsx.
  • Visibility: only if getTeamsMeetingCapability(tenantId) returns { available: true }.
  • Default: checked.

Availability Settings — new tab

  • New TabsTrigger value="teams-meetings" appended to the existing list in AvailabilitySettings.tsx.
  • Tab visibility gated by a readiness check (install_status = 'active'); the whole <TabsTrigger> is conditionally rendered so non-configured tenants never see it.
  • Body contains: a single Input for "Default meeting organizer (UPN or Microsoft user ID)" with help text, a Save button, and a small "Verify" link that calls Graph /users/{upn} to confirm the user exists.
  • A warning banner at the top of the tab lists prerequisites the admin must complete in Azure: application permission OnlineMeetings.ReadWrite.All + an Application Access Policy granting the app permission to create meetings for this user.

MSP schedule-entry view (approved)

  • Add a "Join Teams Meeting" button inside the existing success banner in EntryPopup.tsx. Clicking opens the URL in a new tab.

Client-portal appointment details

  • Add a prominent primary button "Join Teams Meeting" when online_meeting_url is present. Placed near the date/time block in AppointmentRequestDetailsPage.tsx.

Email templates

  • appointment-request-approved-client: add a conditional Handlebars block that renders a "Join Teams Meeting" button when {{onlineMeetingUrl}} is present.
  • appointment-assigned: same conditional block for the assigned technician.

ICS file

  • When online_meeting_url is present, set LOCATION: Microsoft Teams Meeting and include the URL in both the URL property (which icsGenerator already supports) and append a line to DESCRIPTION: "Join Teams Meeting: {{url}}".

Failure UX

  • approveAppointmentRequest returns { success: true, data: ..., teamsMeetingWarning?: string }. When the warning is present, the UI shows a yellow variant="warning" toast and continues showing the approved state.
  • Reschedule failures → { ..., teamsMeetingWarning?: string } in the update action's return, surfaced as a toast.
  • Delete failures → warning toast: "Appointment deleted, but the Microsoft Teams meeting could not be removed. Please remove it manually in Teams."

Requirements

Functional Requirements

FR-1 Schema: appointment_requests

  • Add nullable columns: online_meeting_provider (text), online_meeting_url (text), online_meeting_id (text).
  • Values populated only when a meeting is successfully created.

FR-2 Schema: teams_integrations

  • Add nullable column default_meeting_organizer_upn (text).
  • Readiness check for meeting creation = install_status = 'active' AND selected_profile_id IS NOT NULL AND default_meeting_organizer_upn IS NOT NULL.

FR-3 Helper: createTeamsMeeting()

  • Location: ee/packages/microsoft-teams/src/lib/meetings/createTeamsMeeting.ts.
  • Input: { tenantId, subject, startDateTime, endDateTime }.
  • Behaviour: resolve profile via resolveTeamsMicrosoftProviderConfigImpl, fetch app token via fetchMicrosoftGraphAppToken, read organizer UPN from teams_integrations, POST to https://graph.microsoft.com/v1.0/users/{upn}/onlineMeetings.
  • Output: { joinWebUrl, meetingId } | null. Logs on failure and returns null.

FR-4 Helper: updateTeamsMeeting()

  • Same location. Input: { tenantId, meetingId, startDateTime, endDateTime }.
  • PATCHes /users/{upn}/onlineMeetings/{id} with new times.

FR-5 Helper: deleteTeamsMeeting()

  • Same location. Input: { tenantId, meetingId }. Fire-and-forget DELETE. Surfaces errors only via log.

FR-6 Readiness server action: getTeamsMeetingCapability(tenantId)

  • Location: ee/packages/microsoft-teams/src/lib/actions/meetings/meetingCapabilityActions.ts.
  • Output: { available: boolean, reason?: 'ee_disabled' | 'not_configured' | 'no_organizer' }.
  • Called from the approval form to decide toggle visibility.

FR-7 CE/EE split

  • packages/scheduling/ (CE-safe) calls the Teams helpers only via a dynamic EE import guarded by an edition check. CE builds must not require the ee/packages/microsoft-teams package.
  • Pattern: a thin resolveTeamsMeetingService() in scheduling that returns no-op handlers when EE is not available.

FR-8 Approval schema update

  • approveAppointmentRequestSchema adds generate_teams_meeting: boolean (default false).

FR-9 approveAppointmentRequest action

  • After schedule_entry insert, if input.generate_teams_meeting === true, call createTeamsMeeting().
  • On success: UPDATE appointment_requests SET online_meeting_provider='teams', online_meeting_url=..., online_meeting_id=... WHERE appointment_request_id = ?.
  • Pass onlineMeetingUrl into the approved email template payload.
  • Pass url into the ICS generator input.
  • On failure: populate teamsMeetingWarning in response; do not fail the approval.

FR-10 updateAppointmentRequestDateTime action

  • If the request has online_meeting_id and provider is teams, call updateTeamsMeeting() with the new start/end.
  • On failure: surface teamsMeetingWarning.

FR-11 Cancel / delete flow

  • cancelAppointmentRequest (client portal) and MSP-side deletion: when online_meeting_id present and provider is teams, call deleteTeamsMeeting().
  • UI: cancel/delete confirmation dialogs show the warning text about removing the Teams meeting when the appointment has one.
  • After-the-fact warning toast on Graph failure.

FR-12 UI: Approval form toggle

  • AppointmentRequestsPanel.tsx and the pending branch of EntryPopup.tsx render the toggle only when getTeamsMeetingCapability says available. Default checked.

FR-13 UI: Join button on MSP entry view

  • EntryPopup.tsx approved banner includes a "Join Teams Meeting" button when appointmentRequestData.online_meeting_url is present.

FR-14 UI: Join button on client portal

  • packages/client-portal/src/components/appointments/AppointmentRequestDetailsPage.tsx renders a primary button when URL is present.

FR-15 Admin UI: Teams Meetings tab in Availability Settings

  • Conditional <TabsTrigger> + <TabsContent> in AvailabilitySettings.tsx.
  • Form: single UPN input + Save + optional Verify button.
  • Backing server action: setDefaultMeetingOrganizer({ tenant, upn }) writes to teams_integrations.default_meeting_organizer_upn (requires tenant_integrations:update or equivalent permission).
  • Optional verifyMeetingOrganizer({ tenant, upn }) that calls Graph /users/{upn} and returns { valid: boolean, displayName?: string, reason?: string }.

FR-16 Email templates

  • Update the two template migrations (or add a new migration) to add a conditional "Join Teams Meeting" section rendered via {{#if onlineMeetingUrl}}...{{/if}}.
  • Templates updated: appointment-request-approved-client, appointment-assigned.

FR-17 ICS generator integration

  • Pass url: online_meeting_url and location: 'Microsoft Teams Meeting' into ICSEventData when a meeting was created.

FR-18 i18n

  • New keys under entryPopup, requests.approval, availabilitySettings.tabs, availabilitySettings.teamsMeetings.*, and client-portal appointment translations. Add to English; regenerate pseudo locales.

Non-functional Requirements

  • EE gating: All Graph-calling code lives under ee/. CE build continues to succeed.
  • Fail soft: Any Graph call that fails must not fail the parent user action (approve, reschedule, cancel). Errors are logged with a structured message including tenant, appointment_request_id, operation (create | update | delete), and the Graph error code/body.
  • Audit trail: Meeting creation/update/delete operations go through existing publishEvent logging — one new event: APPOINTMENT_ONLINE_MEETING_ATTACHED (optional MVP, only if cheap to add). If not emitted, the regular SCHEDULE_ENTRY_UPDATED event still fires.

Data / API / Integrations

Migrations

  1. server/migrations/<timestamp>_add_online_meeting_columns_to_appointment_requests.cjs

    • Adds three nullable text columns to appointment_requests.
    • Safe under Citus (adds to distributed table, no distribution key change).
  2. ee/server/migrations/<timestamp>_add_default_meeting_organizer_to_teams_integrations.cjs

    • Adds nullable default_meeting_organizer_upn text column.

Microsoft Graph endpoints

  • Create: POST https://graph.microsoft.com/v1.0/users/{upn}/onlineMeetings
    • Body: { subject, startDateTime, endDateTime } (ISO 8601, UTC).
    • Requires app permission OnlineMeetings.ReadWrite.All + Application Access Policy authorising the app for the organizer user.
  • Update: PATCH /users/{upn}/onlineMeetings/{id} with { startDateTime, endDateTime }.
  • Delete: DELETE /users/{upn}/onlineMeetings/{id}.
  • Verify user (optional): GET /users/{upn} returns { displayName, userPrincipalName, id }.

Reused infrastructure

  • resolveTeamsMicrosoftProviderConfigImpl at ee/packages/microsoft-teams/src/lib/auth/teamsMicrosoftProviderResolution.ts.
  • fetchMicrosoftGraphAppToken at ee/packages/microsoft-teams/src/lib/notifications/teamsNotificationDelivery.ts (or extract to a shared auth module if preferred).
  • getTeamsIntegrationExecutionStateImpl at ee/packages/microsoft-teams/src/lib/actions/integrations/teamsActions.ts.
  • generateICSBuffer / ICSEventData at packages/scheduling/src/utils/icsGenerator.ts (already supports url and location).

Security / Permissions

  • Tenant isolation: Meeting creation always scoped to the tenant's own Microsoft profile; no cross-tenant Graph calls possible.
  • Organizer UPN update permission: reuse tenant_integrations:update (or the existing permission covering teams_integrations).
  • Secret handling: Graph tokens are short-lived and fetched per-call — not persisted. Existing fetchMicrosoftGraphAppToken path is already battle-tested.
  • Meeting URL exposure: join URLs are visible to anyone who receives the email or can view the appointment in the client portal. This matches the current Teams meeting access model — Graph-issued links require the user to be allowed by the tenant's meeting policies.

Observability

  • Structured log lines for every Graph call at INFO on success and WARN on failure, including tenant, appointment_request_id, operation, and Graph response status/code.
  • No new metrics or dashboards in this plan. (Out of scope per the "no gold-plating" principle.)

Rollout / Migration

Phase 1 — Ship dark (no behaviour change)

  • Apply both migrations. No code path reads the new columns yet, so CE + EE tenants are unaffected.

Phase 2 — EE code + admin UI

  • Ship the helpers, server actions, availability-settings tab, approval-form toggle, email template update, and client-portal button.
  • The toggle is only visible if the tenant has set default_meeting_organizer_upn — so the feature is inert until an admin opts in.

Phase 3 — Documentation

  • Add an Azure admin runbook: how to grant OnlineMeetings.ReadWrite.All application permission and how to create the Application Access Policy authorizing the organizer user. Include the PowerShell snippets.
  • Location: docs/integrations/teams-meetings-setup.md.

Feature flag

  • Reuse existing PostHog flag teams-integration-ui for the UI surfaces. No new flag unless we want to gate meetings separately; the existing gate is enough.

Backout

  • If issues arise, setting default_meeting_organizer_upn = NULL instantly hides the toggle and disables new meeting creation, without requiring a deploy. Existing appointments with stored URLs continue to work.

Open Questions

  1. Should verifyMeetingOrganizer also check that an Application Access Policy exists, or just that the user exists? Graph doesn't expose policy membership directly; a working test-call is the only true check. Tentative answer: the Verify button performs a dry-run POST /users/{upn}/onlineMeetings with a throwaway meeting and deletes it if successful. Heavier but truthful. If rejected, show the Azure admin runbook link.
  2. When reschedule fires, should we also update subject or only times? Tentative answer: only times. If the approver wants to change the subject they can do it in Teams.
  3. Do we need to update the EE edition check module, or does ee/packages/microsoft-teams have an existing ambient export that CE can safely call (returning no-op)? Verify before implementation.

Acceptance Criteria (Definition of Done)

  • Both migrations applied; columns present in dev + staging DBs.
  • createTeamsMeeting, updateTeamsMeeting, deleteTeamsMeeting helpers return correctly shaped results against a fixture tenant (manual QA).
  • getTeamsMeetingCapability returns { available: false, reason: 'not_configured' } for a tenant with no teams_integrations row; returns { available: false, reason: 'no_organizer' } when UPN is null; returns { available: true } when all prerequisites are met.
  • Approval form toggle is hidden when capability is unavailable and visible (default ON) when available.
  • Approving a request with the toggle on creates a Teams meeting, stores the three columns, injects the join URL into the approval email, and adds it to the ICS file.
  • Approval still succeeds if Graph returns a 403/404/5xx; warning toast is shown; appointment is approved without meeting columns populated.
  • Rescheduling an appointment with online_meeting_id patches the Graph meeting; times on the join link match the new time.
  • Cancelling/declining an appointment with online_meeting_id deletes the Graph meeting; confirmation dialog warns about the deletion.
  • Client-portal detail page shows the "Join Teams Meeting" button for appointments with a URL.
  • Availability Settings → Teams Meetings tab appears only when install_status = 'active'; organizer UPN can be saved and verified.
  • CE builds pass (npm run build without ee).
  • EE builds pass (npm run build:ee).
  • Translation validator (node scripts/validate-translations.cjs) reports 0 errors after pseudo-locale regeneration.
  • Azure admin runbook exists at docs/integrations/teams-meetings-setup.md.