15 KiB
Public Course Comments PRD
Purpose
Enable learners to leave comments on public course lessons. Comments are visible to all visitors without authentication but require sign-in and email verification to post. The feature mirrors the comment experience on enrolled courses while keeping the read-only lesson experience intact for anonymous visitors.
Problem Statement
Public course lessons on ClassroomIO are currently one-way. Learners can read and watch content but have no way to engage with it publicly. Courses with enrollment already support comments, but public courses — which are primarily used as top-of-funnel and community content — have no equivalent. This creates a lower-quality experience for orgs who publish public courses as a lead-generation or thought-leadership surface: there is no social proof, no discussion, and no way to build a learner community around the content.
Confirmed Decisions
- Lesson-only: Comments are added to lesson items only. Exercise pages are excluded from v1.
- Read-only for anonymous visitors: Anyone can see existing comments; a sign-in prompt replaces the input form.
- Email verification required to post: Matching the trust bar set by enrolled-course comments.
- No edit on public comments: Delete only, to keep the feature minimal.
- Respects the org's comments toggle: If
customization.apps.commentsis disabled for the org, the section is hidden — same as enrolled-course behavior. - Separate data model: Public comments use a new
public_lesson_commenttable tied directly toprofile.id. The existinglesson_commenttable (which ties togroupmember.id) is not changed. PUBLICcourse type only: Other course types (Self-Paced, Live Class, Compliance) require enrollment and already use the existing enrolled-course comment system.
Non-Goals (v1)
- Comments on exercise pages.
- Editing a posted comment (delete is sufficient).
- Comment moderation tools (flagging, muting, admin delete UI).
- Replies / threaded discussions.
- Notifications when someone comments on a lesson.
- Rich-text / markdown formatting — plain text only.
- Anonymous (unauthenticated) commenting.
- Comment counts in the course sidebar or lesson list.
User Stories
| # | As a… | I want to… | So that… |
|---|---|---|---|
| 1 | Anonymous visitor | See existing comments on a public lesson | I get social proof and learn from others before deciding to sign up |
| 2 | Anonymous visitor | See a clear prompt to sign up or log in from the comment section | I can join the conversation without hunting for the sign-in link |
| 3 | Newly signed-up user | Know my email needs verification before I can comment | I understand why the form is disabled and what to do next |
| 4 | Newly signed-up user | Receive a verification email automatically when I land back on the lesson | I don't have to dig through settings to trigger the verification flow |
| 5 | Newly signed-up user | Resend the verification email if it didn't arrive | I'm not blocked if the first email went to spam |
| 6 | Verified user | Post a comment on any public lesson | I can contribute to the discussion |
| 7 | Verified user | Delete my own comment | I can remove something I posted by mistake |
| 8 | Verified user | Load older comments | I can read the full discussion history |
User Experience
State 1 — Anonymous Visitor
The comment section renders below the lesson content. All existing comments are displayed in full with author avatars, names, and relative timestamps. The input area is replaced by a full-width alert card:
Join the discussion Create an account or sign in to leave a comment. [Sign up] [Log in]
Both buttons append ?redirect=<encoded current lesson URL> so the user is returned to the exact lesson after authentication.
State 2 — Logged In, Unverified
The alert card is replaced by an amber verification banner:
Verify your email to comment We sent a verification email to
[email protected]. Click the link to start commenting. [Resend email] (or: "Resend in 90s…" while cooling down)
The comment textarea is rendered but disabled. On component mount, a verification email is sent automatically once — using the same trigger=app pattern used elsewhere in the app (verify-email-modal.svelte). A 120-second cooldown prevents the "Resend" button from being spammed.
State 3 — Logged In, Verified
Full comment form: textarea + "Post" button. Comment list below with:
- Author avatar, display name (own comments shown as "You"), relative timestamp.
- Delete option on own comments only (ellipsis menu or trash icon).
- "Load more" button when more comments exist beyond the first page.
Email Verification Behavior
Email verification is not sent automatically on signup. The app's sendVerificationEmail handler (packages/db/src/auth/email-verification.ts) is intentionally gated — it only fires when trigger=app is present in the callback URL. Better Auth would otherwise send on signup, but this project blocks that and makes the UI responsible.
The comment component replicates the auto-send pattern from verify-email-modal.svelte: on mount, if isLoggedIn && !isEmailVerified, it calls authClient.sendVerificationEmail once with a callbackURL that includes trigger=app and points back to the current lesson. The trigger=app param is stripped before the link is embedded in the email, so the user's click lands cleanly on the lesson page where the session refreshes and the form unlocks automatically.
Data Model
New Table: public_lesson_comment
// packages/db/src/schema.ts
export const publicLessonComment = pgTable('public_lesson_comment', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
lessonId: uuid('lesson_id').notNull().references(() => lesson.id, { onDelete: 'cascade' }),
profileId: uuid('profile_id').notNull().references(() => profile.id, { onDelete: 'cascade' }),
comment: text('comment').notNull()
});
Public comments use profileId directly (no group membership required). The existing lesson_comment table is unchanged.
API Endpoints
All three are appended to the existing publicCourseRouter under /org-site/course.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/:courseSlug/item/:itemSlug/comment |
None | List comments (cursor-paginated, default 10, max 50) |
POST |
/:courseSlug/item/:itemSlug/comment |
Required + email verified | Create a comment |
DELETE |
/:courseSlug/item/:itemSlug/comment/:commentId |
Required + owns comment | Delete own comment |
The POST endpoint returns 403 with a distinct error code if the user is authenticated but their email is unverified, so the client can show the right prompt rather than a generic error.
Technical Design
Validation (packages/utils/src/validation/course/course.ts)
Add:
ZPublicCommentCreate—{ comment: z.string().min(1).max(2000) }ZPublicCommentCursorQuery—{ cursor?: string, limit: z.coerce.number().min(1).max(50).default(10) }ZPublicCommentDeleteParam— extendsZPublicCourseItemParamwithcommentId: z.coerce.number()
Query Layer (packages/db/src/queries/course/public-comment.ts) — new file
getPublicLessonCommentsPaginated(lessonId, { cursor, limit })— cursor-based, joins withprofilefor author name and avatar.createPublicLessonComment(lessonId, profileId, comment)— inserts and returns the new row with profile join.deletePublicLessonComment(commentId, profileId)— deletes only ifprofileIdmatches the row (ownership check at the query layer).
Service Layer (apps/api/src/services/course/public-comment.ts) — new file
getPublicCommentsService(courseSlug, itemSlug, options)— resolves the lesson from slugs, calls paginated query.createPublicCommentService(courseSlug, itemSlug, user, comment)— throwsAppError (403, EMAIL_NOT_VERIFIED)ifuser.isEmailVerifiedis false; resolves lesson, sanitizes comment viasanitizeHtml, calls create query.deletePublicCommentService(courseSlug, itemSlug, commentId, profileId)— resolves lesson, delegates delete with ownership check to query layer.
API Routes (extend apps/api/src/routes/org-site/public-course.ts)
Add to the existing publicCourseRouter after the HLS cookie endpoint.
Frontend Types (apps/dashboard/src/lib/features/course/utils/types.ts)
Add:
export type GetPublicCommentsRequest = typeof classroomio['org-site'].course[':courseSlug'].item[':itemSlug'].comment.$get;
export type CreatePublicCommentRequest = typeof classroomio['org-site'].course[':courseSlug'].item[':itemSlug'].comment.$post;
export type DeletePublicCommentRequest = (typeof classroomio['org-site'].course[':courseSlug'].item[':itemSlug'].comment)[':commentId']['$delete'];
export type PublicCommentsSuccess = Extract<InferResponseType<GetPublicCommentsRequest>, { success: true }>;
export type PublicCommentItem = PublicCommentsSuccess['data']['items'][number];
API Class (apps/dashboard/src/lib/features/course/api/public-comments.svelte.ts) — new file
Extends BaseApiWithErrors. Reactive state: items, hasMore, totalCount, cursor, isLoading, isSubmitting. Methods: getComments, loadMore, createComment, deleteComment.
Component (apps/dashboard/src/lib/features/course/components/lesson/public-comments.svelte) — new file
Props: courseSlug: string, itemSlug: string
Renders one of three states based on $user.isLoggedIn and $profile.isEmailVerified. Fetches comments on mount via the API class. Width matches the PublicLessonView: mx-auto w-full max-w-3xl px-4.
Page Integration (apps/dashboard/src/routes/(org-site)/course/[slug]/lesson/[itemSlug]/+page.svelte)
Add below the PublicLessonView block:
{#if lessonView && $currentOrg.customization?.apps?.comments !== false}
<PublicComments courseSlug={data.tree.course.slug} itemSlug={itemSlug} />
{/if}
Translations (apps/dashboard/src/lib/utils/translations/en.json)
Add under public_course.comments:
{
"title": "Comments ({count})",
"sign_in_prompt_title": "Join the discussion",
"sign_in_prompt_body": "Create an account or sign in to leave a comment.",
"sign_up_btn": "Sign up",
"login_btn": "Log in",
"verify_title": "Verify your email to comment",
"verify_body": "We sent a verification email to {email}. Click the link to start commenting.",
"resend_btn": "Resend email",
"resend_cooldown": "Resend in {seconds}s",
"placeholder": "Write a comment...",
"post_btn": "Post",
"load_more_btn": "Load more",
"you": "You",
"delete_btn": "Delete",
"empty": "No comments yet. Be the first to start the discussion."
}
Scope of Changes
| Layer | File | Change |
|---|---|---|
| DB schema | packages/db/src/schema.ts |
Add publicLessonComment table |
| DB queries | packages/db/src/queries/course/public-comment.ts |
New — paginated get, create, delete |
| Validation | packages/utils/src/validation/course/course.ts |
Add 3 Zod schemas |
| API service | apps/api/src/services/course/public-comment.ts |
New — business logic + email-verified guard |
| API routes | apps/api/src/routes/org-site/public-course.ts |
Extend publicCourseRouter with 3 endpoints |
| Frontend types | apps/dashboard/src/lib/features/course/utils/types.ts |
Add public comment request/response types |
| Frontend API class | apps/dashboard/src/lib/features/course/api/public-comments.svelte.ts |
New — state + methods |
| UI component | apps/dashboard/src/lib/features/course/components/lesson/public-comments.svelte |
New — 3-state comments section |
| Page | apps/dashboard/src/routes/(org-site)/course/[slug]/lesson/[itemSlug]/+page.svelte |
Mount <PublicComments> below lesson view |
| Translations | apps/dashboard/src/lib/utils/translations/en.json |
Add public_course.comments.* keys |
Implementation Order
Phase 1 — Data + API
- Add
publicLessonCommenttopackages/db/src/schema.ts. - Create
packages/db/src/queries/course/public-comment.ts. - Add 3 Zod schemas to
packages/utils/src/validation/course/course.ts. - Create
apps/api/src/services/course/public-comment.ts. - Extend
apps/api/src/routes/org-site/public-course.tswith comment endpoints. - Build:
pnpm --filter @cio/db build && pnpm --filter @cio/utils build && pnpm --filter @cio/api build.
Phase 2 — Frontend
- Add types to
apps/dashboard/src/lib/features/course/utils/types.ts. - Create
apps/dashboard/src/lib/features/course/api/public-comments.svelte.ts. - Add translation keys to
apps/dashboard/src/lib/utils/translations/en.json; runpnpm translate. - Create
apps/dashboard/src/lib/features/course/components/lesson/public-comments.svelte. - Mount the component in the lesson page.
Acceptance Criteria
| # | Scenario | Expected |
|---|---|---|
| AC-1 | Anonymous user opens a public lesson with existing comments | Comments list renders; sign-in alert card shown; no input form |
| AC-2 | Anonymous user opens a public lesson with no comments | Empty state + sign-in alert card |
| AC-3 | Anonymous user clicks "Sign up" | Redirected to /signup?redirect=<lesson URL> |
| AC-4 | Anonymous user clicks "Log in" | Redirected to /login?redirect=<lesson URL> |
| AC-5 | User completes signup and is redirected back | Amber verification banner shown; textarea visible but disabled |
| AC-6 | Verification email auto-sent on landing | sendVerificationEmail fires once on component mount; user receives the email |
| AC-7 | User clicks "Resend" | Email re-sent; button enters cooldown ("Resend in 117s…"); cannot click again until countdown expires |
| AC-8 | User clicks verification link in email | Redirected to lesson; banner gone; comment form active — no page reload required |
| AC-9 | Verified user submits a comment | Comment appears at top of list; author shown as "You" |
| AC-10 | Verified user tries to post empty input | "Post" button stays disabled |
| AC-11 | Verified user deletes own comment | Comment removed; count decrements |
| AC-12 | Verified user views another user's comment | No delete option shown |
| AC-13 | Lesson has >10 comments | "Load more" button shown; clicking appends next page |
| AC-14 | Unverified user calls POST /comment directly (API bypass) |
Returns 403 with EMAIL_NOT_VERIFIED error code |
| AC-15 | Exercise page | No comments section rendered |
| AC-16 | Org has customization.apps.comments disabled |
Comments section hidden entirely |
| AC-17 | Non-PUBLIC course type (Self-Paced, Live Class, Compliance) |
New public comments section not rendered; enrolled-course comment system unchanged |
Open Questions
| # | Question | Default assumption if not resolved |
|---|---|---|
| Q1 | Should there be a per-org toggle specifically for public course comments, separate from the enrolled-course comments toggle? | Reuse the same customization.apps.comments toggle — one control for both surfaces |
| Q2 | Should comments on a public lesson also be visible to enrolled students viewing the same lesson in the authenticated LMS view? | No — the two tables (lesson_comment vs public_lesson_comment) are separate; enrolled students see enrolled comments, public visitors see public comments |