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 aZoomMeetingrow (tenant-scoped bycompanyId/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
trainingrecord orrecruitmentinterview —ZoomMeetingcarries a free-formmetadataJSON blob and atopicstring, but notrainingId/candidateIdFK. 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
meuser (/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
ZoomMeetingDTO type (zoom/zoom.dto.ts) is a separate, hand-written@ObjectTypethat mirrors the schema but types dates as ISOStringandmetadata/zoomMeetingIdper 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
updateMeeting—getMeeting+assertCanManage; builds azoomPatchfromtopic/startTime/durationand callsPATCH /meetings/{id}(only if there's something to patch); thenrepo.updatewith the changed fields (metadataupdated locally only).endMeeting—PUT /meetings/{id}/status { action: "end" }, thenrepo.update(status: "completed", endedAt: now).deleteMeeting—DELETE /meetings/{id}(a Zoom 404 is tolerated = already gone), thenrepo.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)
- Replay guard — reject if
|now - x-zm-request-timestamp| > 300s(REPLAY_TOLERANCE_SECONDS) →BadRequestException("Invalid webhook timestamp"). - Signature verify —
verifyWebhookSignature(rawBody, signature, timestamp): HMAC-SHA256 overv0:{timestamp}:{rawBody}withZOOM_WEBHOOK_SECRET, compared constant-time tox-zm-signature(v0={hash}). Usesreq.rawBody— re-serializing the parsed body would change bytes and break verification. Invalid →BadRequestException("Invalid webhook signature"). - CRC URL-validation (
event === "endpoint.url_validation") — echo{ plainToken, encryptedToken: HMAC-SHA256(plainToken, webhookSecret) }. - Lifecycle events (matched by
Number(payload.object.id)→updateByZoomMeetingId):meeting.started→status: "in_progress".meeting.ended→status: "completed", endedAt: now.recording.completed→fetchRecordingUrl(meetingId)(GET /meetings/{id}/recordings, takesrecording_files[0].download_url) → storerecordingUrlif present.
- 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 —READfor the three queries,CREATE/UPDATE/UPDATE/DELETEfor create/update/end/delete. See platform/permissions-access. - Service-level manage gate:
assertCanManage(host orPRIVILEGED_KINDS) on update/end/delete — an additional row-level check on top of the module permission. - Admin page guard:
src/pages/hr/zoom.tsxgetServerSidePropsrunsApGuardBuilder.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 failure →
callZoomApiwraps it as a cleanBadGatewayException(raw Axios errors are circular and break GraphQL serialization). A stale/401 token triggersinvalidateToken()+ one retry. A missing-scope error yields a guidance message (addmeeting: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
_id→getMeetingthrowsForbiddenException("Meeting not found"). - SDK load/join failure → admin shows an error Alert with the direct
joinUrlfallback. - Bad webhook signature / stale timestamp → 400, no state change.
7. Admin UI
- Route:
src/pages/hr/zoom.tsx→HRLayout selectedKeys={['zoom-meetings']}wrapsZoomContextProvider+ZoomPage. Guarded byApGuardBuilder(isAuth+haveModuleAccess('/hr/zoom')). - Module:
src/modules/hr/zoom/—context.tsx,page.tsx,gql/query.ts,model.ts,components/{ZoomMeetingList, ZoomScheduleForm, ZoomMeetingJoin}.tsx. page.tsx—ApPageHeader "Meetings"with a "Schedule Meeting" button; the list in anApContainer; threeApModals: Schedule (500px), Edit (500px), Join (1000px). On schedule/edit success it closes the modal andreloadMeetings().context.tsxmethods (useZoomState()):scheduleMeeting,updateMeeting,getMeetingSignature,listMeetings(query),reloadMeetings,endMeeting,deleteMeeting— each wraps the corresponding mutation/lazy-query fromuseZoomQuery(), managesloading/error, updates localmeetings/totalMeetings, and toasts viatoastSvc. (Standard zync-nextjs layering: components consumeuseZoomState()only; the context is the soleuseZoomQuery()consumer.)ZoomScheduleForm— Formik + Yup:topicrequired (min 3),startTimerequired,duration15–480 min,enableRecordingboolean (create-only — hidden on edit).ApDateInputstores 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, thenZoomMtg.init/join. Renders into the SDK's#zmmtg-rootelement (hidden on unmount). PullsuserName/userEmailfrom thenext-authsession. On error, shows thejoinUrlfallback.ZoomMeetingList— table with Join/Edit actions (onJoinMeeting,onEditMeeting).
8. Dependencies & integrations
- External: Zoom Cloud — two separate Zoom apps:
- Server-to-Server OAuth app (REST) —
ZOOM_ACCOUNT_ID,ZOOM_CLIENT_ID,ZOOM_CLIENT_SECRET, optionalZOOM_OAUTH_SCOPES. Token fromPOST https://zoom.us/oauth/token(grant_type=account_credentials, BasicclientId:clientSecret), cached in-memory, refreshed 60s early (ZoomConfigService.getAccessToken). Needs meeting scopemeeting:write:meeting(or:admin). - Meeting SDK app (embed) —
ZOOM_SDK_KEY,ZOOM_SDK_SECRETused to sign the join JWT. - Webhook secret —
ZOOM_WEBHOOK_SECRET("Secret Token") for signature verification + CRC. Missing env vars produce a startuplogger.warnlisting which features are disabled (ZoomConfigService.warnMissingConfig);isConfigured()checks the REST + webhook vars.
- Server-to-Server OAuth app (REST) —
@nestjs/axiosHttpService— all outbound Zoom REST calls (zoomApiBase = https://api.zoom.us/v2).@zoom/meetingsdk(admin, client-side) — the embedded Web SDK.UserService— resolvehostEmailfrom the creator's user record.ApContextService(src/context.ts) —companyId,branchId,userId,user.kind,PRIVILEGED_KINDS.- Audit-trail —
@AuditMetaon all mutations → platform/audit-trail. - Module wiring:
ZoomModuleimportsConfigModule,AuthModule,UserModule,MongooseModule.forFeature([ZoomMeeting]),HttpModule; providesZoomService,ZoomResolver,ZoomRepository,ZoomConfigService,ApContextService; controllerZoomWebhookController; exportsZoomService,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.
hostIdis a zerpuserId, not a Zoom user id. All meetings are created on the S2S app'smeaccount (/users/me/meetings);hostIdonly records which zerp user scheduled it (for role + manage checks).- Role is derived server-side, never trusted from the client.
getMeetingSignaturesetshost/participantpurely frommeeting.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 toJSON.stringify(body)and may fail. The HMAC is overv0:{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.
ZoomMeetingexists as both a persisted Mongoose schema (zoom.schema.ts, dates asDate,metadataobject) and a GraphQL@ObjectType(zoom.dto.ts, dates as ISOString,metadata/zoomMeetingIdtyped for SDL). Keep them in sync when adding fields. participants/participantCountare vestigial. Defined on the schema but not maintained by any current handler (noparticipant_joined/leftwebhook handling). Don't rely on them.updatecan't change recording or waiting-room —UpdateZoomMeetingInputonly hastopic/startTime/duration/metadata;enableRecording/waitingRoomare create-only.waiting_roomdefaults ON.waiting_room: input.waitingRoom !== false— omitting the field enables the waiting room; you must passfalseexplicitly 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.