CU-14: Team / org scheduling: invite, roles, round-robin, collective, team page, audit log

Priority: P2 Accounts/sessions: P1 owner (ravikantguptaofficial@gmail.com, logged into Calendo via Google; Google Calendar connected; host notification inbox) + P3 teammate (everythingaichannelemail@gmail.com, must already have a Calendo account AND be logged into Calendo in a second browser profile/context) Parallel-safe: Yes (creates its own org + team event types scoped by RUNID; does not touch global host availability) Exclusive (rewrites global host availability?): No Estimated time: 40 minutes L3 reality checks: Yes — Gmail (confirmation to invitee plus-alias; host notification to P1) + Google Calendar event creation. IMPORTANT: see the L3 section — for a round-robin team event booked through the team page, the calendar event + host email land on the booking-page owner (P1), not necessarily the round-robin-assigned member. The assignment itself is an L2 (dashboard) check. Do not assert an email/GCal hit on the assigned member unless the booking was placed through that member's own booking slug.

Goal

This suite proves Calendo's team / organization scheduling end to end: an owner can create an org, add a teammate, assign and constrain roles, and publish team event types that schedule across multiple people. It verifies the two team scheduling modes — round-robin (booking rotates/assigns to the least-booked available member) and collective (a slot is only offered when all members are free) — plus the public team page that lists members and their bookable event types, and the per-user audit/activity log. This matters because team scheduling (round-robin sales calls, collective panel interviews) is the Phase 3 growth feature that lets Calendo serve teams, not just solo users; getting assignment, availability intersection/union, and permissions correct is what makes it trustworthy for real orgs.

Preconditions

Test data

RUNID convention: at execution time pick one fresh token, format YYYYMMDD-HHMM UTC, e.g. 20260601-1530. Use the SAME RUNID for every artifact in this run so reruns never collide and Gmail/Calendar searches can be scoped.

Create exactly these for this run (substitute the live RUNID):

Steps

Phase A — Create the org and confirm owner controls (P1)

  1. Action: In the P1 context, open the dashboard (https://calendo.dev/dashboard/), then navigate to the Teams page (click the sidebar link "Team" which points to /dashboard/team.html, or go directly to https://calendo.dev/dashboard/team.html). → Expect: The "Teams" heading is visible with a "+ New Team" button (#createOrgBtn). If no orgs exist yet, an empty-state "You haven't created or joined any teams yet." (#noOrgsMessage) shows. → [L1]
  1. Action: Click "+ New Team" (#createOrgBtn). In the Create Team modal (#createOrgModal), type the org name into "Team Name" (#newOrgName) = CU14 Team <RUNID>. Confirm the "URL Slug" field (#newOrgSlug) auto-fills to cu14-team-<RUNID>. Click "Create" (#submitCreateOrg). → Expect: A toast "Team created" appears (#toast), the modal closes, and a new org card (.org-card) with name CU14 Team <RUNID>, slug /cu14-team-<RUNID>, and role owner shows in the org list. → [L1] Capture screenshot: cu14-org-created
  1. Action: Click the new org card (.org-card[data-org-id]) to open its detail view (#orgDetailView). → Expect: The org name shows as the page title (#orgName), a stats row (#orgStats) shows Members = 1, Bookings (30d) = 0, Event Types = 0, and the Members table (#membersTableBody) lists P1 (ravikantguptaofficial) with a role badge owner (.role-badge.role-owner). The "+ Invite" (#inviteMemberBtn), "+ Create" team-event-type (#createTeamETBtn), and "Edit" (#editOrgBtn) buttons are all visible (owner has full control). → [L1] Capture screenshot: cu14-org-detail-owner

Phase B — Invite P3 and exercise roles

  1. Action: Click "+ Invite" (#inviteMemberBtn). In the Invite Member modal (#inviteMemberModal), type everythingaichannelemail@gmail.com into "Email Address" (#inviteEmail), leave Role (#inviteRole) on "Member". Click "Send Invite" (#submitInvite). → Expect: Because P3 is an existing Calendo user, the toast reads "Member added" (not "Invitation sent"). The members table now lists 2 rows: P1 (owner) and P3 (everythingaichannelemail) with role badge member (.role-badge.role-member). Stats row Members = 2. → [L1] [L2] Capture screenshot: cu14-member-added Order note: P3 must be added before any team event type is booked, otherwise round-robin/collective has only one member and assignment cannot rotate.
  1. Action: Reload the org detail (click "Back to Teams" #backToOrgsLink, then re-open the org card) to confirm membership persisted. → Expect: P3 still appears as a member after reload — confirms server-side persistence, not just optimistic UI. → [L2]
  1. Action: In P3's members-table row, click "Make Admin" (the role-toggle button rendered by changeRole(...) — only the owner sees it). → Expect: Toast "Role updated"; P3's badge changes to admin (.role-badge.role-admin). → [L1] [L2]
  1. Action: Switch to the P3 browser context. Open https://calendo.dev/dashboard/team.html and open the same org CU14 Team <RUNID>. → Expect: As an admin, P3 SEES the "+ Invite" (#inviteMemberBtn) and "+ Create" team-event-type (#createTeamETBtn) and "Edit" (#editOrgBtn) buttons (admin can invite/edit/create). However, P3 must NOT see any role-change controls in the members table for P1 (only the owner can change roles — handleUpdateMemberRole returns 403 for non-owners). → [L1] [L2] Capture screenshot: cu14-p3-admin-view
  1. Action: Back in the P1 context, demote P3: in P3's row click "Make Member" (changeRole). → Expect: Toast "Role updated"; P3's badge returns to member. → [L1]
  1. Action: Switch to the P3 context, reload the org detail. → Expect: As a plain member, P3 NO LONGER sees the "+ Invite", "+ Create" team-event-type, or "Edit" buttons (these are hidden for non-admin/owner via isAdminOrOwner in renderOrgDetail). This proves the role demotion actually constrains P3's capabilities. → [L1] [L2] Capture screenshot: cu14-p3-member-constrained Note: The frontend hides controls; the server also enforces 403 on the underlying endpoints (/api/organizations/:id/invite, .../members/:id). The agent verifies the UI constraint; the server-side 403 is noted under manual residue.

Phase C — Round-robin team event type (P1)

  1. Action: Back in P1 context, in the org detail click "+ Create" team event type (#createTeamETBtn). In the Create Team Event Type modal (#createTeamETModal): Name (#teamETName) = RR Sync <RUNID>; Duration (#teamETDuration) = 30; Scheduling Mode (#teamETMode) = "Round Robin"; Description (#teamETDescription) = CU14 round robin <RUNID>. Click "Create" (#submitCreateTeamET). → Expect: Toast "Team event type created"; the Team Event Types list (#teamEventTypes) now shows a .team-et-card with name RR Sync <RUNID>, meta 30 min · rr-sync-<RUNID>, and a green mode badge (.mode-badge) reading round-robin. Stats row Event Types increments to 1. → [L1] Capture screenshot: cu14-rr-event-created
  1. Action: Find the public booking link for this team event. Open the public team page (see Phase E for the exact URL) OR construct it from the dashboard: the team event type is owned by P1, so its booking link is https://calendo.dev/booking/?user=<P1_HOST_SLUG>&event=rr-sync-<RUNID>. To get <P1_HOST_SLUG>, read the dashboard booking link value (#bookingLinkValue) on the Overview tab — it is of the form https://calendo.dev/booking/?user=<P1_HOST_SLUG>. → Expect: You have a concrete booking URL containing event=rr-sync-<RUNID>. → [L2]
  1. Action: Open the round-robin booking URL in a fresh (incognito/guest) browser context with no Calendo login. → Expect: The booking page (#booking-view) loads showing the event RR Sync <RUNID>, 30 min, with a calendar (.calendar-day.available days present). The available slots are the union of P1's and P3's availability (round-robin offers a slot if ANY member is free). → [L1]
  1. Action: Click the first available day (.calendar-day.available), then the first time slot (.time-slot). Fill the booking form: Name (#input-name) = RR Invitee One <RUNID>, Email (#input-email) = ravikantguptaofficial+inv-rr1-<RUNID>@gmail.com. Click Confirm (#btn-confirm). → Expect: A confirmation screen with title (.confirmation-title) containing "Booking Confirmed". Note the booked date/time (record it as RR1_TIME). → [L1] Capture screenshot: cu14-rr-booking1-confirmed
  1. Action: In the same fresh context, re-open the round-robin booking URL and book a SECOND slot (a different time): first available day → first slot (.time-slot) → Name (#input-name) = RR Invitee Two <RUNID>, Email (#input-email) = ravikantguptaofficial+inv-rr2-<RUNID>@gmail.com → Confirm (#btn-confirm). → Expect: "Booking Confirmed". Record date/time as RR2_TIME. → [L1] Order note: Two bookings are needed to observe rotation. The first booking goes to the least-booked available member (tie broken by lowest user id); the second should shift toward the other member if both are free (pickRoundRobinMember picks fewest recent bookings).
  1. Action: In the P1 context, open the org detail again and read the Members table "Bookings (30d)" column (booking_count_30d per member). → Expect: The two new round-robin bookings are attributed to members via assigned_user_id: the combined Bookings(30d) across P1 + P3 increased by 2, and ideally the count is distributed across both members (e.g., P1 = 1, P3 = 1) rather than both landing on one member — demonstrating rotation. If both landed on the same member, note it; rotation depends on each member being free + least-booked at the chosen slots. → [L2] Capture screenshot: cu14-rr-assignment-stats
  1. Action: In the P1 context, go to the dashboard Bookings tab (sidebar data-tab="bookings", https://calendo.dev/dashboard/ then Bookings). Locate the two RR Sync <RUNID> bookings (search/scan for invitee RR Invitee One <RUNID> and RR Invitee Two <RUNID>). → Expect: Both bookings appear with their invitee names/emails and the event RR Sync <RUNID>. This confirms team bookings persist and are visible to the owner. → [L2] Capture screenshot: cu14-rr-bookings-dashboard

Phase D — Collective team event type (P1)

  1. Action: In P1 context org detail, click "+ Create" team event type (#createTeamETBtn). Name (#teamETName) = Collective Panel <RUNID>; Duration (#teamETDuration) = 30; Mode (#teamETMode) = "Collective"; Description = CU14 collective <RUNID>. Click "Create" (#submitCreateTeamET). → Expect: Toast "Team event type created"; a .team-et-card Collective Panel <RUNID> with mode badge collective. → [L1] Capture screenshot: cu14-collective-event-created
  1. Action: Open the collective booking URL in a fresh context: https://calendo.dev/booking/?user=<P1_HOST_SLUG>&event=collective-panel-<RUNID>. → Expect: The booking page loads. The offered slots are the intersection of P1's and P3's availability — i.e., only times when BOTH are free are bookable. Compared to the round-robin event (union), the collective event should offer fewer or equal available slots. If P1 and P3 have non-overlapping schedules, expect NO available days/slots (an empty calendar / "no times available" state) — that is correct collective behavior, not a bug. → [L1] [L2] Capture screenshot: cu14-collective-slots
  1. Action: If at least one collective slot is available, book it: first available day → first .time-slot → Name (#input-name) = Coll Invitee <RUNID>, Email (#input-email) = ravikantguptaofficial+inv-coll-<RUNID>@gmail.com → Confirm (#btn-confirm). Record the time as COLL_TIME. → Expect: "Booking Confirmed". If NO slots were available because schedules do not overlap, skip the booking and record "collective intersection empty (no overlapping availability)" as the observed (and acceptable) result. → [L1] Capture screenshot: cu14-collective-booking-confirmed (only if booked)

Phase E — Public team page

  1. Action: In the P1 context, go to the dashboard Settings tab (sidebar data-tab="settings"), find the "Team Page" section (#teamPageSection) which lists each org's public URL (#teamPageLinks). The URL is https://calendo.dev/team/cu14-team-<RUNID>. Click "Copy Link" or "Open". (Alternatively just navigate directly to that URL.) → Expect: The Team Page section is visible and shows CU14 Team <RUNID> with the URL https://calendo.dev/team/cu14-team-<RUNID>. → [L1]
  1. Action: Open https://calendo.dev/team/cu14-team-<RUNID> in a fresh (no-login) context. → Expect: The pretty URL 301-redirects to https://calendo.dev/team/?org=cu14-team-<RUNID> and renders the public team page: a header (#teamHeader) with org name CU14 Team <RUNID> and subtitle "Choose a team member to schedule a meeting"; a members list (#membersList) with .member-card entries. Under the relevant member (P1, who owns the team event types) the team event types RR Sync <RUNID> and Collective Panel <RUNID> appear as .event-link rows (each showing the name and "30 min"), each linking to /booking/<member_slug>?event=<event_slug>. → [L1] [L2] Capture screenshot: cu14-team-page-public
  1. Action: Click one of the event links (e.g., RR Sync <RUNID>) on the team page. → Expect: It navigates to that member's public booking page for the event (the same booking flow as Phase C). This confirms the team page's booking links are wired correctly. Do NOT complete another booking here unless needed; just confirm the booking view loads (#booking-view). → [L1]

Phase F — Audit / activity log

  1. Action: In the P1 context, go to Settings tab → "Activity Log" section (#auditLogContainer, table #auditLogTable, body #auditLogBody). → Expect: A table of recent actions with Timestamp / Action / Details columns loads (not "Failed to load activity log"). → [L1]
  1. Action: In the "All actions" filter dropdown (#auditActionFilter) select "Event type created" (value event_type.created) and click "Refresh" (#refreshAuditLogBtn). → Expect: The table shows Event type created rows, and entries for the two team event types created this run (details cell shows the slug, e.g. rr-sync-<RUNID> / collective-panel-<RUNID>) are present (they were logged via logAuditEvent on /api/event-types creation). → [L1] [L2] Capture screenshot: cu14-audit-event-created
  1. Action: In the filter, select "Booking created" (value booking.created) and click Refresh. → Expect: Rows for the round-robin (and collective, if booked) bookings appear; details cells include the invitee email (e.g., ...+inv-rr1-<RUNID>@gmail.com) and/or event name. → [L1] [L2] Capture screenshot: cu14-audit-booking-created Known limitation to record (not a failure): Org-level actions (org created, member invited, role changed) are NOT written to the per-user audit log by the current org handlers, so do not expect "organization created" / "member invited" rows. Only event_type/booking/login/settings-type actions appear. Record this observation.

L3 reality checks

Perform these AFTER the bookings are placed. Use the live RUNID in every query.

A. Gmail — invitee + host confirmation emails (P1 inbox).

B. Google Calendar — real event creation (P1 calendar).

IMPORTANT accuracy caveat for "email/GCal to the ASSIGNED member": In current production, the booking side-effects (Google Calendar event creation + host notification email) run against the booking-page owner of the slug the invitee used — here that is P1, because the team event types are owned by P1 and surfaced under P1's booking slug. The round-robin assignment (assigned_user_id, which may be P3) is recorded in the database and reflected in the dashboard org stats (Phase C step 15) but does NOT redirect the calendar event/email to the assigned member. Therefore:

Cleanup

Do all cleanup as P1 (owner) unless noted. The goal is to leave both Google accounts and Calendo clean for the next run.

  1. Cancel the round-robin bookings. From the confirmation emails (Gmail searches inv-rr1-<RUNID>, inv-rr2-<RUNID>) open each booking's cancel link (/booking/cancel.html?token=...) and cancel, OR cancel from the P1 dashboard Bookings tab (data-tab="bookings") using the per-booking Cancel action. → Confirm each shows cancelled.
  2. Cancel the collective booking (if placed): Gmail search inv-coll-<RUNID> → open cancel link → cancel.
  3. Verify in Google Calendar that the cancelled bookings' events were removed from P1's calendar at RR1_TIME, RR2_TIME, and COLL_TIME (cancellation should delete/decline the GCal event). If any orphan event remains, delete it manually in calendar.google.com.
  4. Delete the team event types. In the org detail (team.html), there is currently no in-UI delete for team event types; delete them from the dashboard Event Types tab (data-tab="event-types") by opening each (RR Sync <RUNID>, Collective Panel <RUNID>) and using its delete action, OR leave them only if no delete control exists — in that case set them inactive so they drop off the public team page, and record the residue.
  5. Remove P3 from the org. In org detail members table, click "Remove" on P3's row (removeMember(...)), confirm the browser prompt. → P3 should disappear from the members list.
  6. Delete the org. If team.html exposes no org-delete control (it does not in the current UI), the org cannot be deleted via the browser — record org cu14-team-<RUNID> left in place (no UI delete) under manual residue so the human can remove it via API/D1 if desired. Ensure at minimum that all member rows except the owner are removed and event types are inactive so the public team page is empty.
  7. Confirm Gmail/Calendar are clean: re-run the Gmail searches inv-rr1-<RUNID>, inv-rr2-<RUNID>, inv-coll-<RUNID> and confirm only the cancellation emails (not active bookings) remain; confirm no live events at RR1_TIME/RR2_TIME/COLL_TIME in P1's calendar.

Pass/Fail criteria

The run PASSES only if ALL of the following are true:

The run FAILS if: any precondition session was missing and had to be cold-logged-in; round-robin offered no union slots while members had availability; collective offered MORE slots than round-robin (intersection broken); the team page 404s or omits the team event types; the audit log fails to load or lacks the event_type/booking entries; or no confirmation email / GCal event appeared for a confirmed booking.

Evidence to capture

Manual residue / cannot-verify