Author: Patrick Miguel M. Babala
Reviewer: Christian Denzon, Rico Angelo Alipit, and Clyde Timothy
Creation Date: September 29, 2026
Status: Published
References: Group-Based Enrollment (feature audit item 11.5, Module 11 — Organization), PR #761, 8caf9499, a122b054, dae5a6a0, 4a8a5f88, 49f279dd, 28c00451, PR #805, PR #833, PR #836, PR #869, Issue #860, docs/FEATURE_AUDIT_MAY2026.md, docs/wireframes/index.html, 20260812_create_group_enrollments.sql, 20260806_create_learner_groups.sql, lib/services/groupEnrollmentService.ts, app/api/agency/groups/**, components/creator/enrollment/**
Introduction & Goals
Problem Summary
Before this feature, enrollment was strictly per learner. An instructor creating a quest for a class, team, or department had to add learners one at a time or via CSV, and had no way to keep enrollment in sync as people joined the cohort later.
The platform already modelled cohorts as learner groups (learner_groups + group_memberships) and had a metadata-only assignment table, learner_group_quests, that recorded "this quest is scoped to this group" without ever creating learner enrollments. Feature 11.5 closes that gap: it introduces group_enrollments, a table whose row means the group's members were actually enrolled in the quest, and wires it to bulk learner enrollment, welcome email, in-app notification, and ongoing auto-sync.
Goals & Non-Goals
Goals:
Enroll every current member of one or more learner groups into a quest in a single manager action.
Keep cohorts auto-synced: members added to a group later are enrolled into the group's active quests automatically.
Preserve the existing enrollment capacity contract (
quests.enrollment_limit/enrollment_count): never oversubscribe, never leave a half-enrolled cohort silently.Support soft unenroll (archive) that preserves learner progress and history.
Keep all cohort data agency-scoped, with subgroup visibility enforced for non-managers.
Make coordination between
group_enrollmentsand per-learnerquest_enrollmentsconsistent under failure (compensating rollback).
Non-Goals:
Not a per-learner enrollment API — that remains
app/api/creator/enrollment/*.Not a replacement for
learner_group_quests(metadata-only scoping). The two coexist and are deliberately merged by readers.Not a scheduled/async job queue. Auto-sync is fire-and-forget on the request path; there is no retry worker.
Not progress migration. Unenroll archives the cohort link only; it never deletes
quest_enrollmentsor resets progress.Not a hard-delete feature. There is no user-facing permanent delete of a
group_enrollmentsrow.Not an email/notification feature for unenroll. Only enrollment sends email/notification.
Glossary
Term | Definition |
Group / Cohort | An agency-scoped set of learners ( |
Subgroup | A |
Group enrollment | A |
Metadata-only assignment | A |
Auto-sync / auto-enroll |
|
Reactivation | Re-enrolling a previously archived group: the existing row flips back to |
Reject-not-truncate | Capacity policy: if a cohort needs more slots than remain, the whole enrollment is rejected rather than partially enrolled. |
Compensating rollback | Manual undo of writes already committed when a later step in the same request fails (Supabase JS has no cross-statement transaction here). |
Manager | Agency role |
High-Level Architecture
System Diagram
+-----------------------------------------------------------------------------------+| Quest Editor UI || Enrollment page (app/quest-editor/[questID]/(sections)/enrollment/page.tsx) || |-- AssignToGroupsModal --> ConfirmEnrollmentModal || |-- GroupEnrollmentTable |+-----------------------------------------+-----------------------------------------+ | v+-----------------------------------------------------------------------------------+| Next.js Route Handlers (App Router) || POST/DELETE /api/agency/groups/[id]/quests/[questId]/enroll || GET /api/agency/groups/quest-enrollments || POST /api/agency/groups/[id]/members and .../members/bulk (auto-enroll) |+-----------------------------------------+-----------------------------------------+ | v+-----------------------------------------------------------------------------------+| Auth & Access Gates || authenticateAgencyManager (write: OWNER/ADMIN/CREATOR) || authenticateAgencyMember (read) || resolveSubGroupRoleForAgency + canEditSubGroupRole || resolveListableSubGroupIds (visibility allow-list) |+-----------------------------------------+-----------------------------------------+ | v+-----------------------------------------------------------------------------------+| Service Layer || lib/services/groupEnrollmentService.ts || lib/learnerGroups/groupQuestScope.ts || | || +--> EmailService + renderQuestEnrollmentEmail || +--> notifications (GROUP_ENROLLED) |+-----------------------------------------+-----------------------------------------+ | v+-----------------------------------------------------------------------------------+| Supabase Postgres || group_enrollments, quest_enrollments, learner_groups / group_memberships, || quests || Trigger sync_quest_enrollment_count_* keeps quests.enrollment_count in sync |+-----------------------------------------------------------------------------------+
Technologies Used
Concern | Technology |
Runtime | Next.js 16 App Router, React 19 (Route handlers under |
Language | TypeScript (strict, no new third-party libraries introduced) |
Database | Supabase / PostgreSQL ( |
Auth | Supabase Auth via |
Existing | |
Validation | Zod v4 (Input schemas in |
UI | Shadcn/UI ( |
Detailed Design & Implementation
Data Model / Schema
+-----------------------+ +-----------------------+ +-----------------------+| agencies | 1 * | learner_groups | * 1 | app_users ||-----------------------|----------|-----------------------|----------|-----------------------|| id | | id | | id |+-----------------------+ | agency_id (FK) | +-----------------------+ | status | ^ +-----------------------+ | | 1 | 1 | 1 | | | | | | * | * | * | +-------------------------+ | +---------+ | | | | | v v v |+-----------------------+ +-----------------------+ +-----------------------+| group_memberships | * 1 | group_enrollments | | learner_group_quests ||-----------------------|----------|-----------------------| | (metadata-only) || group_id (FK) | | id (PK) | +-----------------------+| user_id (FK) ---------+ | group_id (FK) |+-----------------------+ | quest_id (FK) ----+ | | adventure_id (FK) | | | status | | | enrolled_at | | | archived_at | | +-------------------+ | | | * v 1+-----------------------+ +-----------------------+| adventures | 1 * | quests ||-----------------------|----------|-----------------------|| id | | id (PK) |+-----------------------+ | enrollment_limit | | enrollment_count | +-----------------------+ | 1 | | * v +-----------------------+ | quest_enrollments | |-----------------------| | id (PK) | | quest_id (FK) | | learner_id (FK) ------+ | status | | progress | +-----------------------+
Table: public.group_enrollments (supabase/migrations/20260812_create_group_enrollments.sql)
Column | Type | Constraints / Default | Purpose |
|
| PK, | Row identity. |
|
|
| Owning cohort. |
|
|
| Target quest. |
|
| FK | Optional parent adventure (currently unpopulated). |
|
|
| Soft-delete flag. |
|
|
| First/again enrollment time; read by analytics. |
|
| nullable | When unenrolled. |
|
|
| Audit. |
|
|
| Maintained by trigger. |
Indexes, Triggers, and RLS Details:
Composite Key / Unique Partial Index:
idx_group_enrollments_active_uniqueon (group_id,quest_id)WHERE status = 'ACTIVE'. This serves as the concurrency guard against duplicate active enrollments while allowing archived rows to retain history for re-enrollment.Indexes:
idx_group_enrollments_quest_id,idx_group_enrollments_group_id, andidx_group_enrollments_status.Foreign Keys & Cascades:
group_idreferenceslearner_groups(id)ON DELETE CASCADE;quest_idreferencesquests(id)ON DELETE CASCADE; nullableadventure_idreferencesadventures(id)ON DELETE CASCADE.Triggers:
group_enrollments_updated_at_trigger(BEFORE UPDATE) executesupdate_group_enrollments_updated_at().RLS Helper Functions: Helper functions
is_agency_member,is_agency_manager, andis_agency_questare sourced from20260806_create_learner_groups.sql. The service layer bypasses RLS via theservice_roleclient, but RLS protects PostgREST directly.
Row-Level Security Policies:
Policy | Operation | Rule |
|
|
|
|
| group |
|
|
|
|
|
|
Grants: SELECT to authenticated; full DML to service_role.
API Specification
All responses use the standard envelope from ApiResponseHelper: { success, message, data, error }. Errors carry an ApiErrorCode.
Method & Path | Auth | Purpose | Body / Query |
|
| Enroll a group into a quest | Body: none |
|
| Soft-unenroll a group ( | Body: none |
|
| List a quest's group enrollments | Query: |
|
| Add members to group and trigger auto-sync | Body: |
|
| Bulk add members via CSV and trigger auto-sync | Body: |
Sample Response: POST /api/agency/groups/{id}/quests/{questId}/enroll (200 OK):
{ "success": true, "message": "Group enrolled successfully", "data": { "group_id": "...", "quest_id": "...", "newly_enrolled_count": 12, "total_group_members": 20, "reactivated": false }}
Failure modes: 401 unauthorized user, 403 no subgroup edit access, 404 group not found in agency, 400 for validation/capacity/duplicate.
Sample Response: GET /api/agency/groups/quest-enrollments?questId={uuid} (200 OK):
{ "success": true, "data": { "canManage": true, "enrollments": [ { "id": "...", "group_id": "...", "group_name": "Summer Cohort", "manager": { "name": "Dana", "email": "dana@x.com" }, "member_count": 20, "enrolled_at": "2026-08-11T...", "status": "ACTIVE", "archived_at": null, "completion_rate": 47.5 } ] }}
Contract notes:
Visibility allow-list: Agency
OWNER/ADMINsee every enrolled subgroup; other roles receive only subgroups they own or hold a view/edit grant on (resolveListableSubGroupIds). Filtering is passed into the service asallowedGroupIds.canManagemirrors agency manager roles so the UI can hide write actions that would otherwise403.Non-agency creators:
authenticateAgencyMemberreturns403; the client treats401/403as "feature unavailable" and hides the card (no dead UI, no error toast).Auto-sync trigger calls: Both member-add endpoints call
void autoEnrollNewGroupMembers({ groupId, learnerIds })after the member write succeeds. The call is unawaited (fire-and-forget).
Logic & Workflows
Step-by-Step Enrollment (enrollGroupInQuest, lib/services/groupEnrollmentService.ts:201):
Resolve and authorize the group via
getAgencyGroupRow(agencyId, groupId)(agency-scoped). If it returnsnull, return API404. If groupstatusis'ARCHIVED', throwBadRequestError.Resolve and authorize the quest.
getEnrollableQuestrequirespublishing_status = 'published'andcreator_idingetAgencyCreatorUserIds(agencyId)(owner + directly-assigned users +ACTIVEmembers). If cross-agency, throwBadRequestError.Read existing enrollment state before any write using
maybeSingleon (group_id,quest_id). Ifstatusis'ACTIVE', throwBadRequestError("Group is already enrolled in this quest"). If'ARCHIVED', mark for reactivation.Compute who needs enrolling. Load
group_memberships, load members' existingquest_enrollmentsfor this quest, and diff against the membership set to producelearnersToInsert. This makes enrollment idempotent per learner.Enforce capacity before writing (see Capacity Policy below).
Upsert
group_enrollments. Reactivate the archived row (status = 'ACTIVE',archived_at = null,enrolled_at = now()) or insert a new row. A23505unique-violation error is caught and remapped toBadRequestError.Insert
quest_enrollmentsforlearnersToInsert, batched atLIST_BATCH = 100rows, each withstatus: "ongoing"andprogress: { percentage: 0, ... }. On any batch error, execute compensating rollback.Execute side effects only if
learnersToInsert.length > 0. Send one welcome email per learner with an address (viarenderQuestEnrollmentEmail), then insertGROUP_ENROLLEDnotifications honoringnotification_prefs.quest_updates_in_app. Already-enrolled learners are never re-emailed or re-notified.
Capacity Policy (Reject-Not-Truncate):
Formula:
availableSlots = max(0, enrollment_limit - enrollment_count)(whenenrollment_limit > 0).If
availableSlots <= 0, return400 "Enrollment limit reached for this quest".If
learnersToInsert.length > availableSlots, return400 "Not enough enrollment slots: this group needs N slots, only M remain".Rationale: Marking a cohort
ACTIVEwhile silently enrolling only part of it would leave the remainder un-backfilled by auto-sync. Rejecting wholesale keepsgroup_enrollmentsandquest_enrollmentsin strict agreement. Running the check before writing ensures no stuckACTIVEgroup rows remain with zero enrollments.
Compensating Rollback (groupEnrollmentService.ts:328):
If a quest_enrollments insert fails mid-batch, prior state is restored before rethrowing:
Fresh insert branch:
DELETEthe newly createdgroup_enrollmentsrow, and delete everyquest_enrollmentsrow inserted by this execution (in batches).Reactivation branch:
UPDATEthe row back to'ARCHIVED'and restore botharchived_atand the originalenrolled_atto preserve first-enrollment history.
Unenroll Soft-Delete Workflow (unenrollGroupFromQuest):
Call getAgencyGroupRow and find the (group_id, quest_id) row. If null, return false (404). If already 'ARCHIVED', return true (idempotent). Otherwise, update to status = 'ARCHIVED', archived_at = now(). Individual learner quest_enrollments and progress records remain untouched.
Auto-Sync Mechanism (autoEnrollNewGroupMember / autoEnrollNewGroupMembers, groupEnrollmentService.ts:476 / :574):
Load every
group_enrollmentsrow for the group wherestatus = 'ACTIVE'to get quest IDs. If none, return.Load capacity (
enrollment_limit,enrollment_count) for target quests in batches.For each quest in order:
Check idempotency: if the learner already has a
quest_enrollmentsrow, skip (avoids spurious "limit reached" logs).Check capacity: if
count >= limit(wherelimit > 0), log"Auto-enroll skipped: quest ... has reached its enrollment limit"and continue.Insert the
quest_enrollmentsrow; log and continue on error.
The execution never throws: wrapped at top level, writing failures to
console.error. A member addition cannot fail due to auto-enroll errors.Bounded concurrency:
autoEnrollNewGroupMembersexecutes with at mostAUTO_ENROLL_CONCURRENCY = 5workers to protect database and email providers.
Listing & Enrichment (listQuestGroupEnrollments, groupEnrollmentService.ts:622):
Load agency group IDs; intersect with
allowedGroupIdsallow-list when present. If empty, return[].Fetch
group_enrollmentsfor the quest scoped to those groups (including archived rows).Enrich each record: group name and manager (
app_users),member_count(group_memberships), andcompletion_rate.Calculate
completion_rateas the mean ofquest_enrollments.progress.percentageacross group members with an enrollment (rounded to 2 decimal places; 0 if none), sorted descending byenrolled_at.Dual-source caveat: Cohort quests exist in both
learner_group_quests(metadata) andgroup_enrollments(active enrollment). Reporting components merge and deduplicate vialib/learnerGroups/groupQuestScope.ts(fetchGroupQuestsBySubgroup).
Frontend Flow:
EnrollmentPagemounts and rendersGroupEnrollmentTable. On load, the table invokes the list endpoint; a401or403hides the card.canManagegates the "Assign to Groups" button and row-level "Unenroll" actions."Assign to Groups" (
AssignToGroupsModal) queriesGET /api/agency/groups?all=1, filters toACTIVEgroups, disables already-enrolled groups, and estimates learner count viamember_count."Confirm" (
ConfirmEnrollmentModal) summarizes learners and groups, issuing onePOSTrequest per selected group (independent calls; partial success reported via toasts).Upon completion, the page increments
refreshKeyto refetch both the group table and learner table.
Infrastructure & Operations
Dependencies
Upstream
Supabase Postgres:
group_enrollments,quest_enrollments,learner_groups,group_memberships,quests,notifications,app_usersAuth Guards:
authenticateAgencyManagerandauthenticateAgencyMembervialib/auth/authenticate.tsPermission Resolvers:
lib/learnerGroups/subGroupPermissionAccess.ts
Downstream:
Email Service:
EmailServiceandrenderQuestEnrollmentEmailvialib/email/*Notification Pipeline:
notificationstable (data.type = 'GROUP_ENROLLED')Database Triggers:
private.sync_quest_enrollment_count_ins/del/upd(20260925migration)
Monitoring & Alerting
Current observability relies on structured console.error logs with the prefix [groupEnrollmentService].
Symptom | Likely cause | Fix |
| Welcome email failed for a single learner | Monitor count trend; alert if spikes occur indicating email provider issues. |
| Entire email block threw during rendering or branding | Set alert on immediate occurrence. |
|
| Set alert on immediate occurrence. |
| New member skipped because quest capacity is full | Monitor as product metric count per quest (non-critical error). |
| Per-quest insert error during auto-sync workflow | Set alert on count trend. |
| Uncaught top-level auto-sync execution failure | Set alert on immediate occurrence. |
Deployment Plan
Migration Order (Forward-Only):
20260806_create_learner_groups.sql: Tables and RLS helper functions (is_agency_member,is_agency_manager,is_agency_quest). Must precede20260812.20260812_create_group_enrollments.sql:group_enrollmentstable, indexes, RLS, and triggers.20260924_...and20260925_...: Hardening migrations implementing statement-levelenrollment_counttriggers.
Rollout Notes:
Ordering Constraint: Migration
20260925must be applied before deploying application code that dropssyncEnrollmentCount()RPC calls (a49329ed). Without RPC calls, the DB trigger is the sole updater forquests.enrollment_count; skipping this causes stale capacity counts.Feature Flags: No flag is required. Availability is implicit: non-agency creators receive
403and the UI hides the card.Idempotency: Migrations are additive and idempotent (drop-then-create on triggers and policies).
Backfill Strategy: Cohorts predating
20260812lackgroup_enrollmentsrows. Executescripts/backfill-learner-cohorts.tsor enroll cohorts manually through the UI.
Testing & Quality Assurance
Test Strategy
Unit / Service Tests (
tests/services/group-enrollment-service.test.ts): Capacity checks before writes; reject-not-truncate enforcement; compensating rollback logic (both fresh insert and reactivation branches, validatingenrolled_at/archived_atrestoration); auto-enroll capacity checks and idempotency ordering; no replay on email/notifications; exactly-once email delivery; single batched notifications; subgroup visibility allow-list.API Tests (
tests/api/bulk-enrollment.test.ts): CSV payload hardening (row/size limitations, prevention of password resets, bounded email concurrency).End-to-End Tests (
tests/e2e/creator/enrollment.spec.ts): ValidatesQA-071(manual single learner) andQA-072(CSV bulk). Group assignment UI flow is not yet covered.Authorization Tests (
tests/agency/groups/learner-groups.test.ts): Subgroup visibility resolution (resolveListableSubGroupIds) and enrollment allow-list boundaries.
Known Limitations
Missing E2E Coverage: Group assignment flow (
AssignToGroupsModal->ConfirmEnrollmentModal->GroupEnrollmentTable) is not covered by Playwright specs.Unused Column:
adventure_idexists in schema with cascading deletes but is not populated by any active code path.Fire-and-Forget Auto-Sync: Auto-sync has no retry worker. If skipped due to full capacity or failed DB writes, learners stay unenrolled until re-added or manually enrolled.
Application-Level Capacity Lock:
enrollment_limitenforcement is not a database invariant; concurrent cohort enrollments can race and oversubscribe.Eventual Consistency:
enrollment_countis maintained via triggers without transactional locks during application-level check-then-insert routines.UI Copy Discrepancy:
ConfirmEnrollmentModalstates auto-sync can be toggled off anytime, but no granular toggle exists outside of unenrollment (status = 'ARCHIVED').No Reconciliation on Capacity Release: Skipped learners are not re-evaluated if capacity opens up later on a quest.
In-Memory Enrichment:
listQuestGroupEnrollmentsloads group memberships and member progress into memory to compute aggregate statistics.
Maintenance & Support
Troubleshooting
Symptom | Likely cause | Fix |
| An | Unenroll first, or reuse existing active enrollment. |
|
| Raise quest limit or unenroll learners. Check trigger count integrity. |
| Cohort size exceeds remaining capacity under reject-not-truncate policy | Increase enrollment limit or reduce cohort size. Do not truncate. |
|
| Publish the quest before enrolling groups. |
| Quest creator is not within agency owner, member, or direct set | Confirm quest ownership; cross-agency assignments are prohibited. |
|
| Unarchive or restore the group before initiating enrollment. |
| Caller lacks owner role, agency | Grant edit permissions via subgroup sharing settings, or run as manager. |
Group Enrollment card missing in UI | User is a standalone creator without an agency (API returns | Expected behavior; card is scoped to agency members. |
New member added to group but not enrolled | Already enrolled, quest reached capacity, or auto-sync failure | Verify |
Email or in-app notification not received | Transient mail failure, null address, or | Inspect logs for |
Operational Checks
Stuck Group Enrollment: If a group is
ACTIVEwith zero member enrollments, verify logs for mid-batch failures. Archive the row and re-enroll.
Count Drift Verification: Run query to confirm triggers exist:
SELECT tgname FROM pg_trigger WHERE tgrelid = 'public.quest_enrollments'::regclass AND tgname LIKE 'sync_quest_enrollment_count%';If absent, apply migrations
20260924and20260925.
Dual-Source Reconciliation: If a cohort quest is absent from group reporting, verify whether it exists in
learner_group_questsorgroup_enrollments, ensuring readers usefetchGroupQuestsBySubgroup.
Changelog
August 11, 2026 — Initial group-based enrollment for learner cohorts (
group_enrollments, service, API, UI) onfeat/group-based-enrollment(a122b054, PR#761).August 2026 — Review blockers/minors: filter archived groups, enforce
enrollment_limit, fire-and-forget auto-enroll, roll back group row on mid-batch failure, tighten RLSINSERT(ACTIVE+ agency quest), scope listing to agency groups onfeat/group-based-enrollment(dae5a6a0).August 2026 — Prevent stuck enrollments: limit checks before any write, reject-not-truncate, roll back inserted
quest_enrollments, honor limit in auto-enroll, correct success/empty toasts onfeat/group-based-enrollment(4a8a5f88).August 2026 — Gate UI on manager role (
canManage), idempotency before capacity in auto-enroll, restoreenrolled_aton failed reactivation, map23505toBadRequestError, service tests onfeat/group-based-enrollment(49f279dd).August 2026 — Bound auto-enroll fan-out (concurrency 5), side-effect idempotency tests, document
403-> hidden-card contract onfeat/group-based-enrollment(28c00451).September 2026 — Removed API
syncEnrollmentCount()calls;enrollment_countnow maintained by DB triggers (RLS/hardening follow-ups) onmain(a49329ed, Issue#860).September 2026 — Default-cohort reactivation preserves original
enrolled_at; consolidation moves/retargetsgroup_enrollmentsonmain(PR#869,creatorCohortService.ts).
Document version:
1.0 - Draft, Initial Technical Guide, 07/29/2026
1.1 - Published, Group-Based Enrollment Technical Guide, 07/29/2026