Zoom — video meetings for HR training & interviews, embedded in the admin

The whole Zoom module reduces to one idea:

zerp owns a thin proxy over the Zoom REST API plus a local mirror collection. Creating a meeting calls Zoom's POST /users/me/meetings, then persists a ZoomMeeting row (tenant-scoped by companyId/branchId). Joining is embedded in the admin via the Zoom Meeting Web SDK, gated by a short-lived signature (JWT) the backend mints from a separate Meeting-SDK app. Lifecycle status (scheduled → in_progress → completed) and recording URLs are kept in sync by Zoom webhooks. Zoom uses two distinct Zoom apps: a Server-to-Server OAuth app (REST) and a Meeting SDK app (embed).

Source: BE src/modules/zoom · Admin src/modules/hr/zoom (page route src/pages/hr/zoom.tsx, HR sidebar key zoom-meetings)

1. Purpose & scope

The zoom module lets HR staff schedule, list, edit, join, end, and delete Zoom meetings from inside the zerp admin — used for training sessions and interviews (it lives under the HR menu as "Meetings"). Responsibilities:

  • Create a meeting on Zoom (REST) and mirror it locally.
  • Embed the join in the browser via the Zoom Meeting Web SDK, with a backend-minted signature.
  • Keep status & recordings in sync via inbound Zoom webhooks.
  • Update / end / delete — propagating each change to Zoom before persisting locally.

It does NOT:

  • Store any link to a specific HR training record or recruitment interview — ZoomMeeting carries a free-form metadata JSON blob and a topic string, but no trainingId / candidateId FK. The HR-training relationship is organizational (it sits in the HR menu), not a schema relationship. See hr/training, recruitment/recruitment.
  • Manage Zoom users/licences, or host meetings as anyone but the configured S2S app's me user (/users/me/meetings).
  • Implement its own auth — it relies on platform/auth + platform/permissions-access for GraphQL, and HMAC signature verification for webhooks.

2. Data model

zoom_meetings — local mirror of each Zoom meeting

zoom/zoom.schema.ts, class ZoomMeeting extends BaseSchema, @ApSchema({ timestamps: true }), soft-delete via mongoose-delete plugin (deletedAt: true).

Field Type Required Notes
zoomMeetingId number Unique index. The id returned by Zoom (zoomMeeting.id). Webhooks & SDK use this number.
topic string Meeting title.
startTime Date Scheduled start (stored as Date; GraphQL exposes it as ISO String).
duration number Minutes; default 60.
timezone string Default "UTC"; on create set from `process.env.TZ
hostId string zerp userId of the creator (contextSvc.userId). Drives host-vs-participant role + manage permission.
hostEmail string Resolved from the creator's user record (user.email), default "".
joinUrl string Zoom join_url. Fallback "open in browser" link in the admin.
meetingPassword string Zoom password (nullable).
enableRecording boolean Default false. Maps to Zoom `auto_recording: "cloud"
recordingUrl string Filled later by the recording.completed webhook.
participantCount number Default 0 (not actively maintained by current handlers).
status string (enum) "scheduled" | "in_progress" | "completed", default "scheduled".
endedAt Date Set when ended (manual endMeeting or meeting.ended webhook).
participants Array<{ userId; joinTime?; leaveTime? }> Default [] (schema-defined; not populated by current handlers).
metadata Record<string, unknown> Free-form JSON, default null. GraphQL exposes/accepts it as a String.
companyId, branchId, createdAt, updatedAt, deletedAt From BaseSchema + timestamps + soft-delete. companyId is the tenant fence.

Status is a plain string enum (not a TS enum): @Prop({ enum: ["scheduled", "in_progress", "completed"], default: "scheduled" }).

The GraphQL ZoomMeeting DTO type (zoom/zoom.dto.ts) is a separate, hand-written @ObjectType that mirrors the schema but types dates as ISO String and metadata / zoomMeetingId per the SDL below. The persisted schema and the API DTO are two different classes with the same name in different files.

Tenant scoping: every read goes through ZoomRepository.buildQuery, which injects match.companyId = ObjectId(this.companyId) when a company context exists (zoom/zoom.repository.ts). getMeeting additionally double-checks meeting.companyId.toString() === contextSvc.companyId and throws ForbiddenException("Meeting not found") on mismatch.

3. API surface

3.1 GraphQL (zoom/zoom.resolver.ts)

All ops carry @ApGqlAuthorize({ permission: { subject: ApModules.ZOOM, action: <RoleAction> } }); ApModules.ZOOM = "zoom-meetings" (permission/permission.enum.ts). Mutations also carry @AuditMeta({ module: "zoom", collection: "zoom_meetings", ... }).

Operation Type Input Returns Permission (action)
zoomMeetingPage Query QueryZoomMeetingInput ZoomMeetingPageResult READ
zoomMeeting Query meetingId: String! ZoomMeeting READ
getZoomMeetingSignature Query meetingId: String! GetMeetingSignatureResult READ
createZoomMeeting Mutation CreateZoomMeetingInput ZoomMeeting CREATE (audit: CREATE)
updateZoomMeeting Mutation UpdateZoomMeetingInput ZoomMeeting UPDATE (audit: UPDATE)
endZoomMeeting Mutation meetingId: String! Boolean UPDATE (audit: STATUS_CHANGE)
deleteZoomMeeting Mutation meetingId: String! Boolean DELETE (audit: DELETE)
# src/schema.gql (generated)
input CreateZoomMeetingInput {
  topic: String!
  startTime: String!        # ISO string; service does new Date(startTime)
  duration: Float           # minutes, default 60 in service
  enableRecording: Boolean  # default false → auto_recording cloud|none
  waitingRoom: Boolean      # default true (waiting_room = waitingRoom !== false)
  metadata: String
}

input UpdateZoomMeetingInput {
  _id: String!
  topic: String
  startTime: String
  duration: Float
  metadata: String          # note: no enableRecording/waitingRoom on update
}

input QueryZoomMeetingInput {
  status: String            # filter by lifecycle status
  fromDate: Float           # ms → startTime >= new Date(fromDate)
  toDate: Float             # ms → startTime <= new Date(toDate)
  keyword: String           # regex on topic (case-insensitive)
  page: Int! = 1
  pageSize: Int! = 20
}

type GetMeetingSignatureResult {
  signature: String!        # Meeting-SDK JWT (HS256), 2h validity
  sdkKey: String!           # Meeting-SDK key the client initializes with
  zoomMeetingId: Float!
  role: String!             # "host" | "participant" (derived server-side)
  topic: String!
  joinUrl: String!
  password: String
}

type ZoomMeetingPageResult { data: [ZoomMeeting!]!  total: Float!  page: Float!  pageSize: Float! }

zoomMeetingPage paginates via ZoomRepository.zoomPage (aggregate + handlePageFacet / handlePageResult, skip = (page-1)*pageSize).

3.2 REST — Zoom webhook receiver (zoom/zoom-webhook.controller.ts)

Method Route Body / headers Response
POST /webhooks/zoom/events Zoom event JSON; headers x-zm-signature, x-zm-request-timestamp CRC handshake echo, or { success: true }

Public route (no @ApiAuthorize) — authenticity is enforced by HMAC signature + replay window, not by zerp auth. See §4.3.

4. Business rules & calculations

4.1 Create (ZoomService.createMeeting)

const timezone = process.env.TZ || "UTC";
const hostEmail = await this.resolveHostEmail();          // user.email of contextSvc.userId, else ""

const zoomMeeting = await this.callZoomApi("POST", "/users/me/meetings", {
  topic: input.topic,
  type: 2,                                                 // scheduled meeting
  start_time: new Date(input.startTime).toISOString(),
  duration: input.duration || 60,
  timezone,
  settings: {
    host_video: true,
    participant_video: true,
    join_before_host: false,
    auto_recording: input.enableRecording ? "cloud" : "none",
    waiting_room: input.waitingRoom !== false,             // default ON unless explicitly false
    approval_type: 2,
  },
});

return this.repo.create({
  companyId: ObjectId(contextSvc.companyId),
  branchId: contextSvc.branchId ? ObjectId(contextSvc.branchId) : null,
  zoomMeetingId: zoomMeeting.id,
  topic, startTime: new Date(input.startTime), duration: duration||60, timezone,
  hostId: contextSvc.userId, hostEmail,
  joinUrl: zoomMeeting.join_url, meetingPassword: zoomMeeting.password,
  enableRecording: input.enableRecording || false, recordingUrl: null,
  participantCount: 0, status: "scheduled", metadata: input.metadata ?? null,
});

Fixed Zoom settings: type: 2 (scheduled), host_video/participant_video on, join_before_host off, approval_type: 2, waiting room on by default.

4.2 Manage-permission rule (assertCanManage)

update, end, delete require the caller to be the host or a privileged company user:

private assertCanManage(meeting: { hostId: string }): void {
  const isHost = meeting.hostId === this.contextSvc.userId;
  const isPrivileged = PRIVILEGED_KINDS.includes(this.contextSvc.user?.kind);
  if (!isHost && !isPrivileged) throw new ForbiddenException("Only the host or an administrator can manage this meeting");
}

PRIVILEGED_KINDS = ["SuperAdmin", "Admin", "Company", "StoreAdmin"] (src/context.ts).

4.3 Update / end / delete — propagate to Zoom first, then persist

  • updateMeetinggetMeeting + assertCanManage; builds a zoomPatch from topic/startTime/ duration and calls PATCH /meetings/{id} (only if there's something to patch); then repo.update with the changed fields (metadata updated locally only).
  • endMeetingPUT /meetings/{id}/status { action: "end" }, then repo.update(status: "completed", endedAt: now).
  • deleteMeetingDELETE /meetings/{id} (a Zoom 404 is tolerated = already gone), then repo.delete (soft delete).

4.4 Meeting signature for embedding (getMeetingSignature)

const isHost = meeting.hostId === contextSvc.userId;
const role = isHost ? "host" : "participant";              // role is NEVER a client argument
const signature = configSvc.generateMeetingSignature(meeting.zoomMeetingId, role);
return { signature, sdkKey, zoomMeetingId, role, topic, joinUrl, password };

generateMeetingSignature (zoom/zoom.config.service.ts) signs an HS256 JWT with the Meeting SDK secret (ZOOM_SDK_SECRET, not the OAuth secret): payload { appKey, sdkKey, mn: meetingNumber, role: host?1:0, iat: now-30s, exp/tokenExp: iat+2h }. Throws a descriptive error if ZOOM_SDK_KEY / ZOOM_SDK_SECRET are unset.

4.5 Webhook handling (zoom-webhook.controller.ts)

  1. Replay guard — reject if |now - x-zm-request-timestamp| > 300s (REPLAY_TOLERANCE_SECONDS) → BadRequestException("Invalid webhook timestamp").
  2. Signature verifyverifyWebhookSignature(rawBody, signature, timestamp): HMAC-SHA256 over v0:{timestamp}:{rawBody} with ZOOM_WEBHOOK_SECRET, compared constant-time to x-zm-signature (v0={hash}). Uses req.rawBody — re-serializing the parsed body would change bytes and break verification. Invalid → BadRequestException("Invalid webhook signature").
  3. CRC URL-validation (event === "endpoint.url_validation") — echo { plainToken, encryptedToken: HMAC-SHA256(plainToken, webhookSecret) }.
  4. Lifecycle events (matched by Number(payload.object.id)updateByZoomMeetingId):
    • meeting.startedstatus: "in_progress".
    • meeting.endedstatus: "completed", endedAt: now.
    • recording.completedfetchRecordingUrl(meetingId) (GET /meetings/{id}/recordings, takes recording_files[0].download_url) → store recordingUrl if present.
  5. Otherwise { success: true }.

4.6 State machine

            createZoomMeeting                meeting.started (webhook)        meeting.ended (webhook)
   (none) ───────────────────▶  scheduled ───────────────────────▶ in_progress ──────────────▶ completed
                                    │                                                    ▲
                                    └──────────── endZoomMeeting (manual) ───────────────┘  (+ endedAt)

   recording.completed (webhook) ─▶ sets recordingUrl (orthogonal to status)
   deleteZoomMeeting ─▶ soft-delete (Zoom DELETE first; 404 tolerated)

4.7 Side effects

  • Outbound Zoom REST calls on create/update/end/delete/recording-fetch (see §8).
  • Audit-trail entries on every mutation via @AuditMeta (see platform/audit-trail).
  • No GL / stock / notification side effects — Zoom is isolated from the accounting and inventory engines.

4.8 Transactionality

None. Operations are not wrapped in a Mongo session; each is an external HTTP call + a single Mongo write. A Zoom call can succeed while the subsequent local write fails (or vice-versa) — there is no compensating rollback. See §9 Gotchas.

5. Permissions

  • GraphQL: @ApGqlAuthorize({ permission: { subject: ApModules.ZOOM /* "zoom-meetings" */, action } }) on every resolver — READ for the three queries, CREATE/UPDATE/UPDATE/DELETE for create/update/end/delete. See platform/permissions-access.
  • Service-level manage gate: assertCanManage (host or PRIVILEGED_KINDS) on update/end/delete — an additional row-level check on top of the module permission.
  • Admin page guard: src/pages/hr/zoom.tsx getServerSideProps runs ApGuardBuilder.isAuth() + haveModuleAccess('/hr/zoom', '/select-module').
  • Webhook: no permission — authenticity via HMAC signature + replay window only.

6. Flows

6.1 Schedule a meeting

Admin "Schedule Meeting" (ZoomScheduleForm, Formik/Yup)
  → context.scheduleMeeting(input)            [hr/zoom/context.tsx]
  → CREATE_ZOOM_MEETING mutation              [hr/zoom/gql/query.ts]
  → ZoomResolver.createZoomMeeting (perm: zoom-meetings/CREATE, audit CREATE)
  → ZoomService.createMeeting
       → callZoomApi POST /users/me/meetings  (S2S OAuth bearer token)
       → repo.create(ZoomMeeting, status="scheduled", companyId/branchId from context)
  → meeting prepended to list, toast "Meeting scheduled successfully"

6.2 Join a meeting (embedded SDK)

Admin clicks "Join" → ZoomMeetingJoin modal     [hr/zoom/components/ZoomMeetingJoin.tsx]
  → dynamic import('@zoom/meetingsdk'); ZoomMtg.preLoadWasm(); prepareWebSDK()
  → context.getMeetingSignature(meeting._id)
  → GET_ZOOM_MEETING_SIGNATURE query → ZoomResolver.getZoomMeetingSignature (perm: READ)
  → ZoomService.getMeetingSignature: role = host if meeting.hostId==userId else participant
       → ZoomConfigService.generateMeetingSignature (HS256 JWT, Meeting-SDK secret, 2h)
  → returns { signature, sdkKey, zoomMeetingId, role, topic, joinUrl, password }
  → ZoomMtg.init(...).join({ signature, sdkKey, meetingNumber: zoomMeetingId,
       userName, userEmail (from next-auth session), passWord })
  → on failure: show Alert + fallback link/button to meeting.joinUrl

6.3 Lifecycle sync (no admin action)

Zoom → POST /webhooks/zoom/events  (x-zm-signature, x-zm-request-timestamp)
  → replay window check (±300s) → HMAC verify over raw body
  → endpoint.url_validation → echo CRC token
  → meeting.started   → updateByZoomMeetingId(status="in_progress")
  → meeting.ended     → updateByZoomMeetingId(status="completed", endedAt=now)
  → recording.completed → fetchRecordingUrl → updateByZoomMeetingId(recordingUrl)

6.4 Unhappy paths

  • Zoom REST failurecallZoomApi wraps it as a clean BadGatewayException (raw Axios errors are circular and break GraphQL serialization). A stale/401 token triggers invalidateToken() + one retry. A missing-scope error yields a guidance message (add meeting:write:meeting / meeting:write:meeting:admin, restart). A Zoom 404 on delete is tolerated (proceed to local delete).
  • Not host / not privileged on update/end/delete → ForbiddenException.
  • Cross-tenant _idgetMeeting throws ForbiddenException("Meeting not found").
  • SDK load/join failure → admin shows an error Alert with the direct joinUrl fallback.
  • Bad webhook signature / stale timestamp → 400, no state change.

7. Admin UI

  • Route: src/pages/hr/zoom.tsxHRLayout selectedKeys={['zoom-meetings']} wraps ZoomContextProvider + ZoomPage. Guarded by ApGuardBuilder (isAuth + haveModuleAccess('/hr/zoom')).
  • Module: src/modules/hr/zoom/context.tsx, page.tsx, gql/query.ts, model.ts, components/{ZoomMeetingList, ZoomScheduleForm, ZoomMeetingJoin}.tsx.
  • page.tsxApPageHeader "Meetings" with a "Schedule Meeting" button; the list in an ApContainer; three ApModals: Schedule (500px), Edit (500px), Join (1000px). On schedule/edit success it closes the modal and reloadMeetings().
  • context.tsx methods (useZoomState()): scheduleMeeting, updateMeeting, getMeetingSignature, listMeetings(query), reloadMeetings, endMeeting, deleteMeeting — each wraps the corresponding mutation/lazy-query from useZoomQuery(), manages loading/error, updates local meetings/totalMeetings, and toasts via toastSvc. (Standard zync-nextjs layering: components consume useZoomState() only; the context is the sole useZoomQuery() consumer.)
  • ZoomScheduleForm — Formik + Yup: topic required (min 3), startTime required, duration 15–480 min, enableRecording boolean (create-only — hidden on edit). ApDateInput stores a ms timestamp; the form converts to ISO before sending. Inputs: ApTextInput, ApDateInput (showTime, past dates disabled), ApCheckbox.
  • ZoomMeetingJoin — dynamic-imports @zoom/meetingsdk, preloads WASM, fetches the signature, then ZoomMtg.init/join. Renders into the SDK's #zmmtg-root element (hidden on unmount). Pulls userName/userEmail from the next-auth session. On error, shows the joinUrl fallback.
  • ZoomMeetingList — table with Join/Edit actions (onJoinMeeting, onEditMeeting).

8. Dependencies & integrations

  • External: Zoom Cloud — two separate Zoom apps:
    1. Server-to-Server OAuth app (REST) — ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET, optional ZOOM_OAUTH_SCOPES. Token from POST https://zoom.us/oauth/token (grant_type=account_credentials, Basic clientId:clientSecret), cached in-memory, refreshed 60s early (ZoomConfigService.getAccessToken). Needs meeting scope meeting:write:meeting (or :admin).
    2. Meeting SDK app (embed) — ZOOM_SDK_KEY, ZOOM_SDK_SECRET used to sign the join JWT.
    3. Webhook secretZOOM_WEBHOOK_SECRET ("Secret Token") for signature verification + CRC. Missing env vars produce a startup logger.warn listing which features are disabled (ZoomConfigService.warnMissingConfig); isConfigured() checks the REST + webhook vars.
  • @nestjs/axios HttpService — all outbound Zoom REST calls (zoomApiBase = https://api.zoom.us/v2).
  • @zoom/meetingsdk (admin, client-side) — the embedded Web SDK.
  • UserService — resolve hostEmail from the creator's user record.
  • ApContextService (src/context.ts) — companyId, branchId, userId, user.kind, PRIVILEGED_KINDS.
  • Audit-trail@AuditMeta on all mutations → platform/audit-trail.
  • Module wiring: ZoomModule imports ConfigModule, AuthModule, UserModule, MongooseModule.forFeature([ZoomMeeting]), HttpModule; provides ZoomService, ZoomResolver, ZoomRepository, ZoomConfigService, ApContextService; controller ZoomWebhookController; exports ZoomService, ZoomConfigService.
  • No cron/jobs.

9. Gotchas & project-specific rules

  • Two Zoom apps, two secret types. REST uses the S2S OAuth client secret; the embed signature uses the Meeting SDK secret. Mixing them up is the most common misconfig — the error messages call this out explicitly.
  • hostId is a zerp userId, not a Zoom user id. All meetings are created on the S2S app's me account (/users/me/meetings); hostId only records which zerp user scheduled it (for role + manage checks).
  • Role is derived server-side, never trusted from the client. getMeetingSignature sets host/participant purely from meeting.hostId === contextSvc.userId.
  • Webhook verification needs the raw body. The controller relies on req.rawBody; if the global body parser doesn't preserve it, verification silently falls back to JSON.stringify(body) and may fail. The HMAC is over v0:{timestamp}:{rawBody}.
  • No transaction / no compensation. A Zoom call and the local write are not atomic. E.g. Zoom creates the meeting but the Mongo write fails → an orphan Zoom meeting with no local row. Update/end patch Zoom first; if the local write then fails, Zoom and zerp drift.
  • Schema vs DTO duplication. ZoomMeeting exists as both a persisted Mongoose schema (zoom.schema.ts, dates as Date, metadata object) and a GraphQL @ObjectType (zoom.dto.ts, dates as ISO String, metadata/zoomMeetingId typed for SDL). Keep them in sync when adding fields.
  • participants / participantCount are vestigial. Defined on the schema but not maintained by any current handler (no participant_joined/left webhook handling). Don't rely on them.
  • update can't change recording or waiting-roomUpdateZoomMeetingInput only has topic/startTime/duration/metadata; enableRecording/waitingRoom are create-only.
  • waiting_room defaults ON. waiting_room: input.waitingRoom !== false — omitting the field enables the waiting room; you must pass false explicitly to disable it.
  • No FK to HR training / recruitment. The "training/interview" association is by convention (HR menu + topic/metadata), not a schema relationship — see hr/training.