Group-Based Enrollment

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_enrollments and per-learner quest_enrollments consistent 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_enrollments or resets progress.

  • Not a hard-delete feature. There is no user-facing permanent delete of a group_enrollments row.

  • Not an email/notification feature for unenroll. Only enrollment sends email/notification.

Glossary

Term

Definition

Group / Cohort

An agency-scoped set of learners (learner_groups) used to enroll and report by team/department.

Subgroup

A learner_groups row that hangs under a main group (main_learner_groups) and carries its own viewer/editor permissions.

Group enrollment

A group_enrollments row linking a group to a quest, meaning members were actually enrolled. Soft-deleted via status = 'ARCHIVED'.

Metadata-only assignment

A learner_group_quests row. Says "this quest belongs to this group" but does not enroll anyone.

Auto-sync / auto-enroll

autoEnrollNewGroupMember(s) — enrolling a newly added member into every quest for which the group has an ACTIVE enrollment.

Reactivation

Re-enrolling a previously archived group: the existing row flips back to ACTIVE (a new row is not inserted).

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 OWNER, ADMIN, or CREATOR (authenticateAgencyManager).


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 app/api/agency/groups/**)

Language

TypeScript (strict, no new third-party libraries introduced)

Database

Supabase / PostgreSQL (RLS enabled on group_enrollments; service-role client used server-side)

Auth

Supabase Auth via lib/auth/authenticate.ts (Manager vs member role gates plus subgroup permission resolution)

Email

Existing EmailService + renderQuestEnrollmentEmail (Reused, not new)

Validation

Zod v4 (Input schemas in lib/schemas/learner-group.schema.ts for member add/bulk)

UI

Shadcn/UI (Dialog, Table, Button, Badge, MultiSelect under components/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

id

UUID

PK, gen_random_uuid()

Row identity.

group_id

UUID

NOT NULL, FK learner_groups(id) ON DELETE CASCADE

Owning cohort.

quest_id

UUID

NOT NULL, FK quests(id) ON DELETE CASCADE

Target quest.

adventure_id

UUID

FK adventures(id) ON DELETE CASCADE, nullable

Optional parent adventure (currently unpopulated).

status

TEXT

NOT NULL default 'ACTIVE', CHECK in ('ACTIVE','ARCHIVED')

Soft-delete flag.

enrolled_at

TIMESTAMPTZ

NOT NULL default NOW()

First/again enrollment time; read by analytics.

archived_at

TIMESTAMPTZ

nullable

When unenrolled.

created_at

TIMESTAMPTZ

NOT NULL default NOW()

Audit.

updated_at

TIMESTAMPTZ

NOT NULL default NOW()

Maintained by trigger.

Indexes, Triggers, and RLS Details:

  • Composite Key / Unique Partial Index: idx_group_enrollments_active_unique on (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, and idx_group_enrollments_status.

  • Foreign Keys & Cascades: group_id references learner_groups(id) ON DELETE CASCADE; quest_id references quests(id) ON DELETE CASCADE; nullable adventure_id references adventures(id) ON DELETE CASCADE.

  • Triggers: group_enrollments_updated_at_trigger (BEFORE UPDATE) executes update_group_enrollments_updated_at().

  • RLS Helper Functions: Helper functions is_agency_member, is_agency_manager, and is_agency_quest are sourced from 20260806_create_learner_groups.sql. The service layer bypasses RLS via the service_role client, but RLS protects PostgREST directly.

Row-Level Security Policies:

Policy

Operation

Rule

group_enrollments_select_agency

SELECT

is_agency_member(lg.agency_id)

group_enrollments_insert_agency

INSERT

group status = 'ACTIVE' AND is_agency_manager(lg.agency_id) AND is_agency_quest(lg.agency_id, quest_id)

group_enrollments_update_agency

UPDATE

is_agency_manager(lg.agency_id)

group_enrollments_delete_agency

DELETE

is_agency_manager(lg.agency_id)

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

POST /api/agency/groups/{id}/quests/{questId}/enroll

manager + subgroup edit access (owner, agency owner/admin, or explicit 'edit' grant)

Enroll a group into a quest

Body: none

DELETE /api/agency/groups/{id}/quests/{questId}/enroll

manager + subgroup edit access

Soft-unenroll a group (status = 'ARCHIVED')

Body: none

GET /api/agency/groups/quest-enrollments

member (any agency member; non-agency receives 403)

List a quest's group enrollments

Query: questId={uuid}

POST /api/agency/groups/{id}/members

manager + subgroup edit access

Add members to group and trigger auto-sync

Body: { learnerIds: string[] }

POST /api/agency/groups/{id}/members/bulk

manager + subgroup edit access

Bulk add members via CSV and trigger auto-sync

Body: multipart/form-data file (CSV)

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/ADMIN see every enrolled subgroup; other roles receive only subgroups they own or hold a view/edit grant on (resolveListableSubGroupIds). Filtering is passed into the service as allowedGroupIds.

  • canManage mirrors agency manager roles so the UI can hide write actions that would otherwise 403.

  • Non-agency creators: authenticateAgencyMember returns 403; the client treats 401/403 as "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):

  1. Resolve and authorize the group via getAgencyGroupRow(agencyId, groupId) (agency-scoped). If it returns null, return API 404. If group status is 'ARCHIVED', throw BadRequestError.

  2. Resolve and authorize the quest. getEnrollableQuest requires publishing_status = 'published' and creator_id in getAgencyCreatorUserIds(agencyId) (owner + directly-assigned users + ACTIVE members). If cross-agency, throw BadRequestError.

  3. Read existing enrollment state before any write using maybeSingle on (group_id, quest_id). If status is 'ACTIVE', throw BadRequestError("Group is already enrolled in this quest"). If 'ARCHIVED', mark for reactivation.

  4. Compute who needs enrolling. Load group_memberships, load members' existing quest_enrollments for this quest, and diff against the membership set to produce learnersToInsert. This makes enrollment idempotent per learner.

  5. Enforce capacity before writing (see Capacity Policy below).

  6. Upsert group_enrollments. Reactivate the archived row (status = 'ACTIVE', archived_at = null, enrolled_at = now()) or insert a new row. A 23505 unique-violation error is caught and remapped to BadRequestError.

  7. Insert quest_enrollments for learnersToInsert, batched at LIST_BATCH = 100 rows, each with status: "ongoing" and progress: { percentage: 0, ... }. On any batch error, execute compensating rollback.

  8. Execute side effects only if learnersToInsert.length > 0. Send one welcome email per learner with an address (via renderQuestEnrollmentEmail), then insert GROUP_ENROLLED notifications honoring notification_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) (when enrollment_limit > 0).

  • If availableSlots <= 0, return 400 "Enrollment limit reached for this quest".

  • If learnersToInsert.length > availableSlots, return 400 "Not enough enrollment slots: this group needs N slots, only M remain".

  • Rationale: Marking a cohort ACTIVE while silently enrolling only part of it would leave the remainder un-backfilled by auto-sync. Rejecting wholesale keeps group_enrollments and quest_enrollments in strict agreement. Running the check before writing ensures no stuck ACTIVE group 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: DELETE the newly created group_enrollments row, and delete every quest_enrollments row inserted by this execution (in batches).

  • Reactivation branch: UPDATE the row back to 'ARCHIVED' and restore both archived_at and the original enrolled_at to 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):

  1. Load every group_enrollments row for the group where status = 'ACTIVE' to get quest IDs. If none, return.

  2. Load capacity (enrollment_limit, enrollment_count) for target quests in batches.

  3. For each quest in order:

    1. Check idempotency: if the learner already has a quest_enrollments row, skip (avoids spurious "limit reached" logs).

    2. Check capacity: if count >= limit (where limit > 0), log "Auto-enroll skipped: quest ... has reached its enrollment limit" and continue.

    3. Insert the quest_enrollments row; log and continue on error.

  4. The execution never throws: wrapped at top level, writing failures to console.error. A member addition cannot fail due to auto-enroll errors.

  5. Bounded concurrency: autoEnrollNewGroupMembers executes with at most AUTO_ENROLL_CONCURRENCY = 5 workers to protect database and email providers.

Listing & Enrichment (listQuestGroupEnrollments, groupEnrollmentService.ts:622):

  1. Load agency group IDs; intersect with allowedGroupIds allow-list when present. If empty, return [].

  2. Fetch group_enrollments for the quest scoped to those groups (including archived rows).

  3. Enrich each record: group name and manager (app_users), member_count (group_memberships), and completion_rate.

  4. Calculate completion_rate as the mean of quest_enrollments.progress.percentage across group members with an enrollment (rounded to 2 decimal places; 0 if none), sorted descending by enrolled_at.

  5. Dual-source caveat: Cohort quests exist in both learner_group_quests (metadata) and group_enrollments (active enrollment). Reporting components merge and deduplicate via lib/learnerGroups/groupQuestScope.ts (fetchGroupQuestsBySubgroup).

Frontend Flow:

  1. EnrollmentPage mounts and renders GroupEnrollmentTable. On load, the table invokes the list endpoint; a 401 or 403 hides the card.

  2. canManage gates the "Assign to Groups" button and row-level "Unenroll" actions.

  3. "Assign to Groups" (AssignToGroupsModal) queries GET /api/agency/groups?all=1, filters to ACTIVE groups, disables already-enrolled groups, and estimates learner count via member_count.

  4. "Confirm" (ConfirmEnrollmentModal) summarizes learners and groups, issuing one POST request per selected group (independent calls; partial success reported via toasts).

  5. Upon completion, the page increments refreshKey to 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_users

  • Auth Guards: authenticateAgencyManager and authenticateAgencyMember via lib/auth/authenticate.ts

  • Permission Resolvers: lib/learnerGroups/subGroupPermissionAccess.ts

Downstream:

  • Email Service: EmailService and renderQuestEnrollmentEmail via lib/email/*

  • Notification Pipeline: notifications table (data.type = 'GROUP_ENROLLED')

  • Database Triggers: private.sync_quest_enrollment_count_ins/del/upd (20260925 migration)

Monitoring & Alerting

Current observability relies on structured console.error logs with the prefix [groupEnrollmentService].

Symptom

Likely cause

Fix

Email failed: <email>

Welcome email failed for a single learner

Monitor count trend; alert if spikes occur indicating email provider issues.

Failed to send enrollment emails

Entire email block threw during rendering or branding

Set alert on immediate occurrence.

Notification insert failed: <msg>

GROUP_ENROLLED batch insertion failure

Set alert on immediate occurrence.

Auto-enroll skipped: quest ... reached its enrollment limit

New member skipped because quest capacity is full

Monitor as product metric count per quest (non-critical error).

Auto-enroll insert failed: <msg>

Per-quest insert error during auto-sync workflow

Set alert on count trend.

autoEnrollNewGroupMember error

Uncaught top-level auto-sync execution failure

Set alert on immediate occurrence.

Deployment Plan

Migration Order (Forward-Only):

  1. 20260806_create_learner_groups.sql: Tables and RLS helper functions (is_agency_member, is_agency_manager, is_agency_quest). Must precede 20260812.

  2. 20260812_create_group_enrollments.sql: group_enrollments table, indexes, RLS, and triggers.

  3. 20260924_... and 20260925_...: Hardening migrations implementing statement-level enrollment_count triggers.

Rollout Notes:

  • Ordering Constraint: Migration 20260925 must be applied before deploying application code that drops syncEnrollmentCount() RPC calls (a49329ed). Without RPC calls, the DB trigger is the sole updater for quests.enrollment_count; skipping this causes stale capacity counts.

  • Feature Flags: No flag is required. Availability is implicit: non-agency creators receive 403 and the UI hides the card.

  • Idempotency: Migrations are additive and idempotent (drop-then-create on triggers and policies).

  • Backfill Strategy: Cohorts predating 20260812 lack group_enrollments rows. Execute scripts/backfill-learner-cohorts.ts or 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, validating enrolled_at/archived_at restoration); 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): Validates QA-071 (manual single learner) and QA-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_id exists 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_limit enforcement is not a database invariant; concurrent cohort enrollments can race and oversubscribe.

  • Eventual Consistency: enrollment_count is maintained via triggers without transactional locks during application-level check-then-insert routines.

  • UI Copy Discrepancy: ConfirmEnrollmentModal states 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: listQuestGroupEnrollments loads group memberships and member progress into memory to compute aggregate statistics.


Maintenance & Support

Troubleshooting

Symptom

Likely cause

Fix

400 "Group is already enrolled in this quest"

An ACTIVE group_enrollments row exists or concurrent requests clashed (23505 mapped)

Unenroll first, or reuse existing active enrollment.

400 "Enrollment limit reached for this quest"

enrollment_count >= enrollment_limit

Raise quest limit or unenroll learners. Check trigger count integrity.

400 "Not enough enrollment slots: this group needs N slots, only M remain"

Cohort size exceeds remaining capacity under reject-not-truncate policy

Increase enrollment limit or reduce cohort size. Do not truncate.

400 "Quest is not published and cannot be enrolled"

quests.publishing_status != 'published'

Publish the quest before enrolling groups.

400 "Quest does not belong to this agency"

Quest creator is not within agency owner, member, or direct set

Confirm quest ownership; cross-agency assignments are prohibited.

400 "Cannot enroll an archived group"

learner_groups.status = 'ARCHIVED'

Unarchive or restore the group before initiating enrollment.

403 "You do not have edit access to this subgroup"

Caller lacks owner role, agency OWNER/ADMIN, or explicit edit grant

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 401/403)

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 group_enrollments ACTIVE rows and inspect logs for [groupEnrollmentService].

Email or in-app notification not received

Transient mail failure, null address, or notification_prefs.quest_updates_in_app = false

Inspect logs for "Email failed" and check app_users.notification_prefs.

Operational Checks

  • Stuck Group Enrollment: If a group is ACTIVE with 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 20260924 and 20260925.

  • Dual-Source Reconciliation: If a cohort quest is absent from group reporting, verify whether it exists in learner_group_quests or group_enrollments, ensuring readers use fetchGroupQuestsBySubgroup.

Changelog

  • August 11, 2026 — Initial group-based enrollment for learner cohorts (group_enrollments, service, API, UI) on feat/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 RLS INSERT (ACTIVE + agency quest), scope listing to agency groups on feat/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 on feat/group-based-enrollment (4a8a5f88).

  • August 2026 — Gate UI on manager role (canManage), idempotency before capacity in auto-enroll, restore enrolled_at on failed reactivation, map 23505 to BadRequestError, service tests on feat/group-based-enrollment (49f279dd).

  • August 2026 — Bound auto-enroll fan-out (concurrency 5), side-effect idempotency tests, document 403 -> hidden-card contract on feat/group-based-enrollment (28c00451).

  • September 2026 — Removed API syncEnrollmentCount() calls; enrollment_count now maintained by DB triggers (RLS/hardening follow-ups) on main (a49329ed, Issue #860).

  • September 2026 — Default-cohort reactivation preserves original enrolled_at; consolidation moves/retargets group_enrollments on main (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


Was this article helpful?