Workshops PRD
Purpose
Enable organizations to publish one-off video training sessions — webinars, recorded talks, live events — without building a full course. A Workshop is a course with exactly one lesson: one video (YouTube embed or upload) plus optional rich text and a callout CTA. Workshops get their own admin surface, public catalog, landing-page section, and LMS entry, while reusing the entire course machinery underneath.
Reference products:
- Cursor Workshops: https://cursor.com/workshops — card grid with title, host ("By AJ Valenty" + headshot), upcoming date or duration; grouped into "Upcoming events" (Luma registration) and "On-demand sessions" (YouTube links, grouped by topic).
- Our differentiation: cards link to an internal workshop page (video + text + callout on the org's branded site) instead of sending visitors to YouTube/Luma, and registration is native enrollment.
Problem Statement
Orgs that run trainings and webinars have single-video content that doesn't fit the course model:
- Creating a course for one video forces them through the full course builder (sections, lessons, exercises, certificates) for content that is "watch once."
- There is no public surface for a webinar library — no host attribution, no upcoming-event registration, no duration-first card layout.
- Teams currently send audiences to YouTube or Luma, losing branding, lead capture, and the conversion callout.
Confirmed Decisions
- A Workshop IS a course — a new
WORKSHOPvalue in the existingCOURSE_TYPEenum, with exactly one auto-created lesson. No new table. (All three candidate plans — claude/codex/composer — converged on course reuse; the enum-vs-contentKind-column debate was settled by evidence: thetype === 'PUBLIC'anonymous-access audit is 7 occurrences in 3 files, and a second classification axis would need a new column, CHECK constraints, and parallel filter plumbing.) - The lesson row is the event. Host =
lesson.teacherId(existing FK →profile.id); schedule =lesson.lessonAt(nullable — workshop creation writesnullexplicitly for on-demand); meeting link =lesson.callUrl; recording =lesson.videos. No relational IDs in JSONB. - Lifecycle is derived, never stored:
draft(unpublished) →upcoming(published,lessonAtfuture) →live(insidelessonAt + durationMinutes) →replay-pending(past, no video) →on-demand(published video exists). No status field that can go stale. - Watch gating is the admin's choice per workshop: public (anyone watches, no login) or registration-required. Maps to the lesson
publicflag. - Registration = enrollment (existing enroll flow). Registered users see the meeting link and get a confirmation email. Optional per-workshop external registration URL (Luma escape hatch) is a cheap, cuttable add.
- Topics = existing course tags. On-demand lists group by tag; untagged fall into "General".
- Workshops and courses never mix: excluded from the courses catalog, admin course list, course-type filters, the course create modal, and LMS My Learning / progress / certificate lists. (A workshop certificate issued by a passed assessment — decision 9 — is delivered by email and downloadable from the workshop page; it does not enroll the workshop into LMS progress/completion surfaces.)
- v1 scope is all four surfaces: admin, public org-site pages, landing-page section (all 10 themes), LMS entry.
- Optional post-session assessment (CE mode): an admin can enable exactly one assessment on a workshop — a quiz with a pass percentage (default 80%) — and registrants who pass are automatically issued a certificate. This is the standard certified-training (continuing-education) workflow — register → attend live → take the test → pass → certificate emailed → replay stays up; the grading, pass-gate, and certificate machinery already exist on courses. Off by default; a workshop without an assessment behaves exactly as decisions 1–8 describe.
- Calendar invites ship in v1, not later — reclassified after auditing the codebase:
.icsgeneration (packages/email/src/ics.tsbuildSessionIcs) and attachment-on-enrollment (services/course/session-invite.tsgetWelcomeSessionIcs) already work in production forLIVE_CLASScourses, gated by one line (course.type !== 'LIVE_CLASS'). Widening that gate to includeWORKSHOPgives every scheduled-workshop registrant an auto-attached calendar invite (title, time, join link, default reminders) with no new subsystem — see Data Model and Phase 1. - Internal-audience workshops need no new visibility flag — with one exception. "Published, but only reachable/usable by people already in the organization" is achieved by combining the existing per-workshop gating choice (decision 4) with the org-level
organization.disableSignup/settings.signup.inviteOnlytoggles (apps/api/src/middlewares/signup-guard.ts,services/organization/auto-join.ts): a registration-required workshop on an org with signup disabled cannot be joined by a stranger (they can't create the account the enroll flow requires), while existing team members log in and register as usual. The gap: the public (no-login) gating choice bypassesdisableSignupentirely — anyone with the link watches regardless of the org's signup settings. The admin UI must warn (or block) choosing "public" gating on an org that has signup disabled, since it silently defeats the org's own intent. - Zero-friction internal delivery is a real, separate feature — deferred. Even with the combination above, an existing employee still clicks "Register" once (having an org account ≠ having a
groupmemberrow on that specific workshop). True zero-click delivery — the workshop just appears already-registered for every teammate — needs a newmetadata.workshop.audience: 'public' | 'organization'setting that auto-enrolls every currentorganizationmember(role ≥ STUDENT) into the workshop's group on publish, and auto-enrolls new hires as they join (mirroring the existingautoJoinOrgpattern). Out of v1; tracked as a Later item, not built alongside decisions 1–9.
Current-State Audit
| Workshop concept | Existing storage / code | Notes |
|---|---|---|
| Title, description, cover, slug, publish | course table |
type enum at packages/db/src/schema.ts:27, values from packages/utils/src/constants/course-type.ts |
| Org linkage + enrollment | course.groupId → group.organizationId; groupmember rows |
Registration = student groupmember |
| Video / rich text / documents | lesson.videos (jsonb, incl. duration metadata), lesson.note, lesson.documents |
Already the exact workshop content shape |
| Host | lesson.teacherId FK → profile.id (lesson_teacher_id_fkey, schema.ts:1033) |
Real constraint; barely used today |
| Schedule | lesson.lessonAt (nullable, defaultNow() only when omitted) |
Session queries, ICS invites (services/course/session-invite.ts), and the reminder job (session-reminder-scan.ts) already key on it — reminder scan is gated by one type = 'LIVE_CLASS' condition |
| Meeting link | lesson.callUrl |
Must never appear in anonymous API payloads |
| Conversion CTA | course.callout + packages/ui/src/custom/public-course/callout |
Built for PUBLIC courses |
| Anonymous viewing pipeline | packages/db/src/queries/course/public-course.ts + apps/api/src/routes/org-site/public-course.ts + HLS cookie |
Gated type = 'PUBLIC' today — widen to include WORKSHOP |
| Student lesson renderer | packages/ui/src/custom/public-course/lesson-view.svelte |
Video + note + callout; exactly the workshop page |
| Host render pattern | packages/ui/src/custom/org-landing-page/course-instructor.svelte |
Name + role + image |
| Enrollment flow | (org-site)/course/[slug]/enroll + POST /course/:courseId/enroll |
Handles login redirect, student limits, email verification, invite tokens |
| Landing pages | 10 themes in packages/ui/src/custom/org-landing-page/<theme>/; props built in apps/dashboard/src/lib/features/org/utils/landing-page.ts; config JSON on organization.landingpage |
Sections hardcoded per theme's org.svelte; shared token-styled components are precedented (callout, links, instructor) |
| Catalog + filters | (org-site)/courses + getPublishedCoursesBySiteName (accepts types) |
No default type exclusion today — workshops would leak in without Phase 1 filters |
| Team members (host picker) | GET /organization/team → OrgTeamMember |
Lacks avatarUrl — add via profile join |
Key insight: lesson already holds who (teacherId), when (lessonAt), where (callUrl), and the recording (videos) — all relational. The feature is a product layer (type value, routes, simplified admin, catalog), not a new content entity.
Product Goals
- An admin can create, edit, and publish a workshop from a dedicated, simple editor — never touching the full course builder.
- Visitors browse a branded
/workshopscatalog: Upcoming events (date, Register) and On-demand sessions grouped by tag (duration, Watch). - Each workshop has a stable public page (
/workshops/[slug]) through every lifecycle state: registration page before the event, replay page after. - Hosts are org team members, shown with name + avatar on cards and detail pages.
- Registration is native: enroll → confirmation email → meeting link visible to registered users only.
- Orgs can showcase workshops on their landing page (all 10 themes) with a section toggle, heading, and item limit.
- Learners see their registered/available workshops in the LMS, linking to the org-site workshop page.
- Courses and workshops remain fully separated in every list, filter, count, and progress surface.
- An admin running certified trainings (CE) can enable a post-session assessment: registrants take the quiz on the workshop page after the session, it is auto-graded, and everyone at or above the pass percentage is automatically issued and emailed a certificate — no manual steps between registration and certificate delivery.
Non-Goals (v1)
- No-account (email-only) registration — Luma-grade friction reduction ships later (verify-email token → email-only groupmember, idempotent confirm/cancel, rate limits, reconcile to profile).
- Paid workshops (course pricing plumbing exists; deferred).
- Capacity limits, waitlists, approval, custom registration questions.
- Rescheduling/cancellation calendar updates —
buildSessionIcssupportsmethod: 'CANCEL'but no caller wires it today; a rescheduled or cancelled workshop does not yet push an updated/cancelled calendar event to prior registrants. - Zero-click internal-audience auto-enrollment (
metadata.workshop.audience— see Confirmed Decision 12). - Multiple hosts per workshop; replay-available notifications; workshop analytics dashboards; dedicated topic taxonomy (tags suffice); landing-page section reordering (no section-ordering system exists — the section renders after courses).
- Assessment extras beyond one quiz + pass mark + certificate: multiple assessments, retake limits, timed tests, manual grading, attendance verification before the quiz unlocks, accreditation-body reporting.
Data Model
- Add
'WORKSHOP'toCOURSE_TYPE_VALUES(packages/utils/src/constants/course-type.ts); the pgEnum, Drizzle types, and Zod mirror follow from that one edit. Schema work stops at a passing@cio/dbbuild (migrations handled outside this workflow). - Course
metadatagains scalar settings only:workshop?: { durationMinutes?: number; registrationMode?: 'enrollment' | 'external'; registrationUrl?: string; assessment?: { enabled: boolean; passPercentage: number } }. - Workshop creation (one transaction): group + course (
type: 'WORKSHOP') + creator tutor member + the single lesson (title = course title,publicper gating choice, explicitlessonAt,teacherId= host, slug via the slug service). No welcome newsfeed. - Invariants enforced in services, not just UI: lesson/section create services reject additions to WORKSHOP courses. Exercise creation is likewise rejected, with one controlled exception: the workshop assessment service may create exactly one exercise on the single lesson when
assessment.enabledis turned on (and general exercise routes still reject workshop courses — the assessment service is the only entry point). Publish readiness (extendgo-live-readiness.ts): title + host required; upcoming needslessonAt; on-demand needs a playable video. - Security: anonymous payloads never contain
callUrl; an authed membership check returns it for enrolled users (and drives the "Registered ✓ / Join" card state). - Calendar invite reuse: widen
getWelcomeSessionIcs'scourse.type !== 'LIVE_CLASS'gate (services/course/session-invite.ts) to admitWORKSHOP, sobuildSessionIcsattaches a.icsto the confirmation email exactly as it does for live classes today (see Confirmed Decision 10). No new email template, no new package. - Admin footgun guard: the workshop editor's gating toggle warns (or blocks) selecting "public, no login" when the org has
disableSignupenabled, since that combination silently exposes the workshop to anyone despite the org's closed-signup intent (see Confirmed Decision 11).
API
Dedicated thin router delegating to shared course/lesson/group/tag services (single response type per route, per repo conventions):
POST /workshop — atomic create, returns course + lesson
GET /workshop/:id — course + lesson + derived state (editor shape)
PUT /workshop/:id — transactional course + lesson update; publish folds in
GET /org-site/workshops — public list: course + lesson join (duration, slug)
+ profile join on teacherId (host name/avatar),
tags, derived state; callUrl stripped
GET /org-site/workshops/:slug — public detail; lesson body/HLS reuses the existing
public-course item endpoint + HLS cookie
No DELETE /workshop (course delete works). Default course queries (getPublishedCoursesBySiteName, counts, admin org-courses) exclude type = 'WORKSHOP'; the 3-file type === 'PUBLIC' anonymous pipeline is widened to admit WORKSHOP.
User Experience
Admin (/org/[slug]/workshops)
- New "Workshops" entry in the org sidebar (
contentgroup, beside Courses). - List page: cards with title, host, derived-state badge, published status.
- Single-page editor (
/workshops/[id]) — no course sidebar: title, description, host picker (team members fromGET /organization/team, which gainsavatarUrl), schedule (lessonAt+durationMinutes), video + rich-text note (reusingfeatures/course/components/lesson/video/*andnote/*by hydrating the existing lesson store), callout, tags, gating toggle, registration mode, publish. - Assessment section (Phase 5): enable toggle, pass percentage (default 80), and the quiz builder (reusing the existing exercise question editor against the single lesson's one exercise). Enabling requires registration-required gating — anonymous viewers can't take a graded, certificate-issuing test. Results view lists registrants with score, pass/fail, and certificate status.
/courses/[id]for a workshop-typed course redirects to/workshops/[id].
Public org site
/workshops— full catalog: Upcoming events (date, Register; "Join" while live) then On-demand sessions grouped by tag (duration, Watch; "Replay coming soon" for replay-pending). Theme tokens via the same pattern as/courses./workshops/[slug]— stable URL through every state: host header (course-instructor.sveltepattern) +lesson-view.svelte(video, note, callout). Public → plays for anyone; gated → Register; upcoming → date + Register, meeting link for registered users. Admin adds the recording after the event and the same page becomes on-demand automatically.- With an assessment enabled, registered users see a "Take the assessment" section on the same page once the session is past (or on-demand video watched for evergreen enrollees): quiz → auto-grade → pass ≥ threshold → certificate emailed + downloadable there; below threshold → score shown with the org's contact path. Anonymous visitors see that a certificate assessment exists (conversion hook) but must register.
- Org-site nav shows a Workshops link when the org has published workshops.
Enrollment (registration) flow — traced
Reuses (org-site)/course/[slug]/enroll, with these deliberate deltas:
- Redirect chain: the existing flow hard-redirects post-enroll to the LMS (
/courses/{id}/lessons?next=true) and rebuilds login/signup redirects to the enroll page. Workshops thread a return destination through all hops so the registrant lands back on/workshops/[slug](extendgetStudentCourseContinuePathor pass an explicit redirect — don't fork the enroll page). - Email verification gate: kept — protects
callUrland keeps registrant emails real. - Free reuse:
allowNewStudent === falsedoubles as "registration closed";?invite_token=gives private/unlisted workshops. - Confirmation email: the org-branded student-course-welcome email, now with a
.icscalendar invite attached for scheduled workshops (widenedgetWelcomeSessionIcsgate — Confirmed Decision 10); verify wording fits workshops (date + call link for scheduled ones). - Internal-only orgs: registration-required gating + the org's own
disableSignup/inviteOnlysettings already keep strangers out (Confirmed Decision 11) — no workshop-specific code needed here beyond the gating-toggle warning noted in Data Model.
Landing page (all 10 themes)
Flow mirrors the courses section exactly:
- Admin enables it in the landing-page editor (new
workshops-section.sveltepanel) → writeslandingpage.workshops = { show, title?, subtitle?, itemLimit? }. - Org root
+page.server.tsfetchesGET /org-site/workshops(capped atitemLimit) only whenshowis true, in parallel with the courses fetch. buildOrgLandingPagePropsgainsworkshops;mapPublicWorkshopsToLandingPageWorkshopsmaps API rows →WorkshopItem[](id, slug, title, host name + avatar, precomputed lifecycle state).- Each theme's
org.sveltegets a small conditional block rendering a shared token-styledworkshops-section.svelte+workshop-card.svelte(--landing-*vars only). The theme loader prefers a theme-localworkshop-card.sveltewhen a theme ships one — none required for v1; themes with strong card identity get bespoke variants incrementally. - Section renders only when enabled AND workshops exist; "View all" links to
/workshops. Position is fixed after the courses section.
LMS
- "Workshops" nav entry →
/lms/workshops: registered upcoming/live workshops + the org's published on-demand workshops. Cards link to the org-site workshop URL (tenant-origin helper) — the LMS does not render workshops through the course lesson shell. - Workshops are excluded from My Learning, progress totals, completion/certificate lists.
Open Questions
- Do workshop registrants count toward the org's plan student limit? Enrollment creates a student
groupmember, andstudentLimitReachedblocks signups — a popular free webinar collides with per-seat limits. v1 default until decided: they count; surface the limit clearly in admin. Alternative: exclude WORKSHOP groups fromcountActiveStudents(needs an abuse guard). - Keep the external
registrationUrlescape hatch in v1, or cut for leanness? - Should the enrollment confirmation email get a workshop-specific template variant, or is adjusted generic wording enough?
- Assessment certificate template: reuse the existing course certificate templates as-is, or does a workshop certificate need its own fields (session date, host, duration/credit hours)? CE buyers may need credit hours printed — verify against a real CE customer's certificate before building.
- Does Phase 5 ship inside v1 (if certified trainings are the go-to-market wedge) or immediately after the four v1 surfaces? Default until decided: build Phases 1–4 first, but design Phase 1 metadata/invariants so the assessment slot needs no migration.
- Should the "public gating on a signup-disabled org" case (Confirmed Decision 11) hard-block save, or just show a warning the admin can dismiss? Recommendation: warn, don't block — an org might legitimately want one public workshop as a lead magnet while keeping account signup closed.
- Is zero-click internal auto-enrollment (Confirmed Decision 12 /
metadata.workshop.audience) worth pulling into v1, or does the registration-required + disableSignup combination cover internal-training customers well enough for launch?
Phased Delivery
Phase 1 — Data model & backend: enum value; metadata.workshop settings; createWorkshop service; invariant guards + publish readiness; widen the PUBLIC anonymous pipeline; widen the LIVE_CLASS-only ICS/calendar-invite gate (session-invite.ts) to include WORKSHOP; exclusions everywhere (courses catalog default, admin list, LMS student queries, filter UIs, create modal); /workshop + org-site routers; team avatarUrl; email copy check.
Phase 2 — Admin surface: sidebar entry; list + single-page editor reusing lesson video/note components; host picker; schedule; gating; translations (en.json + pnpm translate).
Phase 3 — Public org-site: /workshops catalog (Upcoming / On-demand-by-tag); /workshops/[slug] detail; enrollment redirect threading; org-site nav link.
Phase 4 — LMS + landing: /lms/workshops; shared landing section + card with per-theme override hook; OrgLandingPageJson.workshops config; editor panel; 10 theme org.svelte conditional blocks; storybook fixtures; ui: prefix checks.
Phase 5 — Assessment & certificates (CE mode): metadata.workshop.assessment; assessment service (sole entry point that may create the single exercise); editor assessment section + results view; workshop-page assessment flow (quiz → auto-grade → pass gate → certificate email + download); certificate stays out of LMS progress/completion lists.
Later: no-account registration, reschedule/cancel calendar updates (method: 'CANCEL' wiring), metadata.workshop.audience zero-click internal auto-enrollment, paid workshops, capacity/waitlists, replay notifications, workshop analytics, multiple hosts, featured collections, assessment extras (retakes, timing, attendance gating, accreditation reporting).
Verification
- Builds per repo rules:
@cio/db,@cio/api(+deps), dashboard (+deps);pnpm format:check;pnpm --filter @cio/ui prefix:check. - Service tests: create yields exactly one course/group/tutor-member/lesson; second lesson/section/exercise rejected; course queries exclude workshops and vice versa; lifecycle derivation covers all five states; anonymous payloads never contain
callUrl. - E2E (two browser profiles — admin window vs incognito
?org=udemy-test):- Admin: create → video + note + team-member host → publish. Not in Courses list; not in the course create modal.
- Incognito: landing section (after enabling) →
/workshops→ card (host avatar + duration) → detail plays video, callout renders. - Gating: registration-required → Register → sign up → returned to the workshop page → video plays; student absent from LMS My Learning, present under
/lms/workshops. - Upcoming: future
lessonAt+callUrl→ "Upcoming events" card with date; register → confirmation email with a.icsattachment (title, time, join link, reminders) that the mail client offers to add to calendar; call link visible only to enrolled; anonymous API response contains nocallUrl. Add recording after the date → same URL now on-demand. - Internal-only: set org
disableSignup: true, workshop gating "registration-required" → an unauthenticated stranger hitting/workshops/[slug]is redirected to signup and blocked; an existing team member logs in and registers normally. Re-run with gating set to "public" → confirm the admin UI surfaces the disableSignup-conflict warning. - Assessment (Phase 5): enable assessment + 80% pass mark → registered student takes quiz on the workshop page → scoring above threshold emails a certificate and shows the download; scoring below shows the score with no certificate; a second exercise cannot be created through any route; the workshop still absent from LMS My Learning and completion/certificate lists.