Skip to content

feat: Zoom Meeting support alongside Webinars - #17

Merged
harshtandiya merged 20 commits into
mainfrom
feat/zoom-meetings
Jul 27, 2026
Merged

feat: Zoom Meeting support alongside Webinars#17
harshtandiya merged 20 commits into
mainfrom
feat/zoom-meetings

Conversation

@harshtandiya

@harshtandiya harshtandiya commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What

Adds first-class Zoom Meeting support next to the existing Webinar support, reusing shared API helpers instead of duplicating the Webinar controller — then generalises the shared doctypes so they describe what they actually hold.

Changes

  • api.py — resource-parameterized HTTP helpers (resource="meetings"|"webinars"): create / update / delete / add-registrant / paginated registrants & participants.
  • Zoom Meeting doctype + controller — create (type 2, approval_type:0 so registration works), update, delete, add_registrant, and sync_attendance (via past_meetings/{uuid}). Named by zoom_meeting_id, so the docname is the Zoom ID.
  • Session renameZoom Webinar RegistrationZoom Session Registration, Zoom Webinar Attendance RecordZoom Session Attendance Record, Zoom Webinar Additional ParamZoom Session Additional Param. They hold meeting rows as well as webinar rows now, so Webinar no longer belonged in the name.
  • Dynamic session reference — the registration and attendance doctypes dropped their separate webinar and meeting links for reference_doctype + reference_name. Picking the session collapses from a branch on which link is set to get_cached_doc(self.reference_doctype, self.reference_name), and reqd on both fields replaces the hand-written "exactly one of" guard.
  • Removed the unused Consolidated Webinar Attendance report (scaffolding that still returned Column 1 / Row 1).
  • Shared timezone list — the 135 zones Zoom accepts were duplicated byte for byte in both session forms (138 of the meeting script's 163 lines). Now public/js/zoom_timezones.js, loaded once via app_include_js.

Migrations

Patch Stage Does
rename_webinar_doctypes_to_session pre_model_sync Renames the three doctypes. Must precede the model sync or the new names exist as empty tables and the rename hits a clash.
backfill_session_reference post_model_sync Fills reference_doctype / reference_name from the legacy webinar / meeting columns. Fills empty references only — the legacy columns are not kept in step afterwards, so overwriting a set reference would undo a later rename.
rename_meetings_to_zoom_id post_model_sync Renames meetings created before ID-based naming, which kept a hash docname. rename_doc repoints Buzz Event.zoom_meeting and the dynamic reference_name.

Legacy webinar / meeting columns are left behind for bench trim-tables.

Fixes found while reviewing this branch

Three defects the tests were not catching, each now covered:

Fix Was
Only PATCH Zoom when details changed The guard compared a datetime.date to str(date), never equal, so every save hit the API. Present in the webinar controller too, which the meeting one was copied from.
Scope webinar attendance to its own registrations The lookup filtered on email alone. Harmless while one table held only webinars; wrong once it held meetings.
Keep access tokens out of request logs create_request_log stores request_headers verbatim, so every logged call persisted its bearer token. Nothing in frappe or the payments app passes that argument — dropping it matches the convention.

Gotchas handled

  • Participant reports use the meeting UUID (captured at create), not the numeric id — URL-encoded; double-encode path noted inline for // UUIDs.
  • Registration requires settings.approval_type:0 on a scheduled (type 2) meeting — set in the create body and pinned by assertions.
  • Zoom returns int id; cast to string on store (Data field + URL paths).
  • link_filters on reference_doctype narrows the desk picker to the two session doctypes, but applies client-side only — validate_session_reference enforces it on the server.
  • Connections on Zoom Webinar now match reference_name. DocType Link has no dynamic filter field, so the panel no longer restricts by session type; both doctypes name from the same Zoom ID space, so a collision needs two sessions sharing an ID. Zoom Meeting gains the Connections it never had.

Required Zoom app scopes (Server-to-Server OAuth)

The app authenticates with grant_type=account_credentials, so these are account-level (:admin) granular scopes. Add them to the Zoom Marketplace app before merging — every call below 4711s without its scope. Webinar scopes already on the app are unchanged; these are purely additive.

Scope Endpoint Needed for Required?
meeting:write:meeting:admin POST /users/me/meetings Creating the meeting on Zoom Meeting insert Yes
meeting:update:meeting:admin PATCH /meetings/{meetingId} Pushing title/schedule edits back to Zoom Yes
meeting:delete:meeting:admin DELETE /meetings/{meetingId} Deleting the meeting on on_trash Yes
meeting:write:registrant:admin POST /meetings/{meetingId}/registrants Registering an attendee when a Zoom Session Registration is submitted Yes
meeting:read:list_past_participants:admin GET /past_meetings/{meetingUUID}/participants sync_attendance — needs a Pro plan or higher Yes
meeting:read:list_registrants:admin GET /meetings/{meetingId}/registrants get_zoom_registrants helper — no caller yet Only for meeting-side registrant sync
report:read:list_meeting_participants:admin GET /report/meetings/{meetingId}/participants Not called — attendance uses past_meetings, which is gated by meeting:read:list_past_participants:admin instead No
meeting:read:meeting:admin GET /meetings/{meetingId} Not called — nothing fetches a single meeting from Zoom No

Scope names are as of the Zoom v2 granular scopes. Zoom renames these periodically — worth confirming each against the Marketplace UI when you add them.

Tests

All mocked — no real Zoom calls, and no live token fetch: Zoom Webinar calls requests and the token endpoint directly rather than going through api.py, so both are patched. Fixtures in tests/zoom_fixtures.py mirror live Zoom v2 JSON. 36 passing, ruff and pre-commit clean.

Session and registrant IDs are drawn fresh per call rather than fixed: sessions are named by Zoom ID and registrant_id is unique, and IntegrationTestCase rolls back per class rather than per test, so rows outlive a run and fixed IDs collided with their own leftovers on a rerun.

Three of these tests passed on their first run and had to be rewritten before they meant anything:

  • The unchanged-save test saved the doc returned by insert(), which still holds date as the string it was given, so the broken comparison matched by accident. It now reloads from the database, which is what the desk does.
  • The cross-session test relied on frappe.db.get_value ordering, which is undefined — it would have passed or failed at random. Rewritten so the correct answer is None.
  • The pagination test used two pages, and an implementation that appends the token still produces exactly one token on the second request. It takes three pages to see them stack, confirmed by writing the broken version and watching the third URL carry two.

Breaking changes

Renamed DocTypes

Before After
Zoom Webinar Registration Zoom Session Registration
Zoom Webinar Attendance Record Zoom Session Attendance Record
Zoom Webinar Additional Param Zoom Session Additional Param

Renamed fields

On both Zoom Session Registration and Zoom Session Attendance Record, the two links collapse into one dynamic pair:

Before After
webinar (Link → Zoom Webinar) reference_doctype = "Zoom Webinar", reference_name = the old value
meeting (Link → Zoom Meeting) reference_doctype = "Zoom Meeting", reference_name = the old value

Also changed

  • Zoom Meeting docnames. Meetings are named by zoom_meeting_id now. Existing hash-named meetings are renamed by patch, so any docname you stored elsewhere is stale.
  • Removed: the unused Consolidated Webinar Attendance report.

What the patches fix for you

frappe.rename_doc repoints Link field options, Custom Field options, Property Setters, Dynamic Links, Report.ref_doctype, Client Script.dt and Notification.document_type. The backfill fills reference_doctype / reference_name from the old columns. The old columns stay until you run bench trim-tables, so nothing is lost if you need to check.

What you must fix by hand

Nothing rewrites code or expression bodies. Grep your custom app, Server Scripts, Client Scripts, Notification conditions, Print Formats and Jinja templates for the old names:

grep -rn "Zoom Webinar Registration\|Zoom Webinar Attendance Record\|Zoom Webinar Additional Param" .
grep -rnE "\b(webinar|meeting)\b.*Zoom Session" .

Then apply:

If your code does Change it to
frappe.get_all("Zoom Webinar Registration", ...) frappe.get_all("Zoom Session Registration", ...)
filters={"webinar": w} filters={"reference_doctype": "Zoom Webinar", "reference_name": w}
filters={"meeting": m} filters={"reference_doctype": "Zoom Meeting", "reference_name": m}
doc.webinar / doc.meeting doc.reference_name (check doc.reference_doctype for which)
from ...doctype.zoom_webinar_registration.zoom_webinar_registration import ZoomWebinarRegistration from ...doctype.zoom_session_registration.zoom_session_registration import ZoomSessionRegistration
/api/resource/Zoom%20Webinar%20Registration /api/resource/Zoom%20Session%20Registration

Controller classes follow the same rename: ZoomWebinarAttendanceRecordZoomSessionAttendanceRecord, ZoomWebinarAdditionalParamZoomSessionAdditionalParam.

A row can only ever have one session, so webinar or meeting becomes just reference_name.

Pairs with bwhtech/buzz#288 — ship together, this app migrates first.

🤖 Generated with Claude Code

harshtandiya and others added 3 commits July 24, 2026 17:03
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

harshtandiya has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

harshtandiya has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

harshtandiya has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

harshtandiya has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

harshtandiya has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

- Added 'send_zoom_registration_email' and 'column_break_narx' fields to the Zoom Meeting JSON configuration.
- Updated field definitions for better clarity and structure.
- Modified permissions to include 'Zoom Manager' role.
- Adjusted 'duration' field to allow None values in the Python class.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

harshtandiya has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

Zoom Webinar Registration and Zoom Webinar Attendance Record now hold rows
for Zoom Meetings as well as Zoom Webinars, so their names no longer match
what they store. Rename both to the "session" vocabulary already used by the
resource-parameterized helpers in api.py:

  Zoom Webinar Registration      -> Zoom Session Registration
  Zoom Webinar Attendance Record -> Zoom Session Attendance Record

The pre_model_sync patch does the move with frappe.rename_doc, which renames
the tables and repoints Link options, property setters, report ref_doctype,
attachments and dynamic links. It has to run before the model sync, otherwise
migrate creates the new names as empty tables first and the rename collides.

Both controllers also gain a validate() guard requiring exactly one of
webinar/meeting. Dropping reqd on `webinar` for the meeting support left it
possible to insert a record linked to neither, and the attendance controller
had no validation at all.

BREAKING CHANGE: /api/resource/Zoom%20Webinar%20Registration and
/api/resource/Zoom%20Webinar%20Attendance%20Record are gone; use the Zoom
Session paths. Python imports of the old module paths break. Server Scripts,
Client Scripts and Notification conditions containing the old doctype names
are not rewritten by the patch and must be updated by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

harshtandiya has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

harshtandiya and others added 5 commits July 27, 2026 15:59
Zoom Meeting fell back to hash naming, so the desk URL and every link
carried an opaque ID with no relation to the meeting on Zoom. Name it after
zoom_meeting_id instead: before_insert stores the ID from the create call,
and Frappe runs before_insert ahead of set_new_name, so the value is there
when the name is assigned.

The names are only unique because the Zoom IDs are, which broke the tests:
IntegrationTestCase rolls back per class, not per test, so repeated inserts
of the same mocked create response collided on the primary key. Tests now
take their meeting ID from create_meeting_response and pass a distinct one
each.

Also cover additional_params reaching Zoom, which had no test, before the
child doctype behind it gets renamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zoom Webinar Additional Param was the last doctype left on the webinar
vocabulary. It is the additional_params child table of Zoom Session
Registration, which now holds meeting rows too, so the name no longer
matches what it stores. Add it to the existing pre_model_sync rename patch.

frappe.rename_doc handles the istable case: update_options_for_fieldtype
repoints the parent's Table field, and the child rows' parent/parenttype
still name Zoom Session Registration, which did not change.

Also drop the Consolidated Webinar Attendance report. It was scaffolding
that never got written -- get_columns returns "Column 1"/"Column 2" and
get_data returns two literal rows -- with no workspace link and no caller.
Its ref_doctype was updated by the earlier rename while its own name was
not, so deleting it removes the inconsistency rather than renaming dead
code. Migrate's orphan-report sweep removes the stale Report doc, so the
patch needs no extra step.

BREAKING CHANGE: /api/resource/Zoom%20Webinar%20Additional%20Param is gone;
use the Zoom Session path. Python imports of the old module path break.
The Consolidated Webinar Attendance report is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Naming Zoom Meeting by zoom_meeting_id only applied to new docs, so meetings
created earlier kept their hash name while zoom_meeting_id held the real Zoom
ID. Anything building a Zoom URL from the docname was wrong for those.

rename_doc repoints Buzz Event.zoom_meeting and the dynamic reference_name on
the session registration and attendance records.

Zoom Meeting also gains the Connections it never had, matching Zoom Webinar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zoom Session Registration and Zoom Session Attendance Record each carried a
`webinar` Link and a `meeting` Link, exactly one of which was meant to be set.
Nothing in the schema enforced that, every read had to branch on which one was
populated, and adding a third session type would have meant a third column.

Both now carry reference_doctype + reference_name instead. Selecting the
registration's session collapses from

    frappe.get_cached_doc(
        "Zoom Meeting" if self.meeting else "Zoom Webinar", self.meeting or self.webinar
    )

to a plain get_cached_doc(self.reference_doctype, self.reference_name), and
`reqd` on both fields replaces the hand-written "exactly one of" guard.
reference_doctype carries link_filters so the desk only offers the two session
doctypes, but those apply client-side, so validate_session_reference keeps the
check on the server where it counts.

The backfill fills empty references only. The legacy columns are left for
`bench trim-tables` and are not kept in step afterwards, so overwriting a
populated reference would undo a later rename.

Connections on Zoom Webinar now match on reference_name. DocType Link has no
dynamic filter field, so the panel no longer restricts by session type; both
doctypes are named from the same Zoom ID space, so a collision needs two
sessions sharing an ID.

Test IDs are drawn fresh per call rather than fixed: sessions are named by
Zoom ID and registrant_id is unique, and rows outlive a run, so fixed IDs
collided with their own leftovers on a rerun.

BREAKING CHANGE: the `webinar` and `meeting` fields are gone from Zoom Session
Registration and Zoom Session Attendance Record; read reference_doctype and
reference_name. API clients filtering on the old fieldnames must be updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

Both failures only showed up in CI, because the dev bench has buzz installed
and real Zoom credentials configured.

Zoom Webinar posts through `requests` and fetches its own token rather than
going via the api.py helpers, so patching `requests` alone left the token
call live: it hit zoom.us on every run and threw "Zoom Settings Incomplete"
wherever credentials were absent. mock_zoom_post patches both.

The meeting rename test also queried Buzz Event, which this app does not
depend on and CI does not install. The remaining assertion, that every
meeting is named after its zoom_meeting_id, covers the patch on its own.

Verified by making authenticate() raise and confirming the suite still
passes, so nothing reaches live auth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

harshtandiya and others added 5 commits July 27, 2026 18:19
The change guard compared a datetime.date to str(date), which is never
equal, so it never fired and every save sent a PATCH to Zoom. Editing a Buzz
Event cascades into a meeting save, so ordinary edits were hitting the API.

Both controllers carried it -- the meeting one was copied from the webinar
one. getdate() on both sides fixes each.

The tests missed it because they saved the doc returned by insert(), which
still holds `date` as the string it was given, so the comparison matched by
accident. The new tests reload from the database first, which is what the
desk and the Buzz Event hook do.

Also drops a backfill assertion that compared a literal to itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The registration lookup filtered on email alone, so it returned any
registration for that address. Once one table started holding meetings as
well as webinars, a meeting registration could be attached to a webinar
attendance record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create_request_log stores request_headers verbatim on the Integration
Request, so every logged call persisted its Authorization bearer token.
Integration Request is readable by any System Manager.

Frappe has no redaction helper and nothing in frappe or the payments app
passes request_headers at all -- Stripe, Razorpay, PayPal, Braintree,
GoCardless, Paytm, Mpesa and Paymob all omit it. Our headers only ever hold
the token and a constant content-type, so dropping the argument loses no
debugging value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 135-entry list Zoom accepts was duplicated byte for byte in both session
form scripts, 138 of the meeting script's 163 lines. It now lives in
public/js and loads once via app_include_js.

A test asserts neither form inlines it again, since nothing else would catch
the two copies drifting apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_paginate took a url_builder callback and a page_size nobody overrode, so
callers wrapped a one-line f-string in a lambda. They now pass the URL.

Pagination had no multi-page test at all -- both existing ones used
single-page fixtures, leaving the loop unexercised. The new test covers
three pages, which is the point: with two pages an appending implementation
still produces one token and looks correct. Verified by writing the
appending version and watching the third request carry two tokens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

pre-commit was never installed in this clone, so prettier and eslint had not
run on any commit here. The shared list is the first JS this app has added
in a while, which is what surfaced it.

zoom_integration is a namespace frappe.provide creates at runtime, so eslint
needs it declaring alongside frappe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@harshtandiya
harshtandiya merged commit c39d9b0 into main Jul 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant