From d24221527751188ebd6ccb9652ba4827910c5971 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Mon, 29 Jun 2026 07:22:45 +0100 Subject: [PATCH] docs: prd for public course comments --- prd/public-course-comments/README.md | 248 +++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 prd/public-course-comments/README.md diff --git a/prd/public-course-comments/README.md b/prd/public-course-comments/README.md new file mode 100644 index 00000000..97b39c56 --- /dev/null +++ b/prd/public-course-comments/README.md @@ -0,0 +1,248 @@ +# 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 + +1. **Lesson-only**: Comments are added to lesson items only. Exercise pages are excluded from v1. +2. **Read-only for anonymous visitors**: Anyone can see existing comments; a sign-in prompt replaces the input form. +3. **Email verification required to post**: Matching the trust bar set by enrolled-course comments. +4. **No edit on public comments**: Delete only, to keep the feature minimal. +5. **Respects the org's comments toggle**: If `customization.apps.comments` is disabled for the org, the section is hidden — same as enrolled-course behavior. +6. **Separate data model**: Public comments use a new `public_lesson_comment` table tied directly to `profile.id`. The existing `lesson_comment` table (which ties to `groupmember.id`) is not changed. +7. **`PUBLIC` course 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=` 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 `user@example.com`. 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` + +```typescript +// 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` — extends `ZPublicCourseItemParam` with `commentId: z.coerce.number()` + +### Query Layer (`packages/db/src/queries/course/public-comment.ts`) — new file + +- `getPublicLessonCommentsPaginated(lessonId, { cursor, limit })` — cursor-based, joins with `profile` for author name and avatar. +- `createPublicLessonComment(lessonId, profileId, comment)` — inserts and returns the new row with profile join. +- `deletePublicLessonComment(commentId, profileId)` — deletes only if `profileId` matches 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)` — throws `AppError (403, EMAIL_NOT_VERIFIED)` if `user.isEmailVerified` is false; resolves lesson, sanitizes comment via `sanitizeHtml`, 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: +```typescript +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, { 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: +```svelte +{#if lessonView && $currentOrg.customization?.apps?.comments !== false} + +{/if} +``` + +### Translations (`apps/dashboard/src/lib/utils/translations/en.json`) + +Add under `public_course.comments`: +```json +{ + "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 `` below lesson view | +| Translations | `apps/dashboard/src/lib/utils/translations/en.json` | Add `public_course.comments.*` keys | + +## Implementation Order + +### Phase 1 — Data + API +1. Add `publicLessonComment` to `packages/db/src/schema.ts`. +2. Create `packages/db/src/queries/course/public-comment.ts`. +3. Add 3 Zod schemas to `packages/utils/src/validation/course/course.ts`. +4. Create `apps/api/src/services/course/public-comment.ts`. +5. Extend `apps/api/src/routes/org-site/public-course.ts` with comment endpoints. +6. Build: `pnpm --filter @cio/db build && pnpm --filter @cio/utils build && pnpm --filter @cio/api build`. + +### Phase 2 — Frontend +1. Add types to `apps/dashboard/src/lib/features/course/utils/types.ts`. +2. Create `apps/dashboard/src/lib/features/course/api/public-comments.svelte.ts`. +3. Add translation keys to `apps/dashboard/src/lib/utils/translations/en.json`; run `pnpm translate`. +4. Create `apps/dashboard/src/lib/features/course/components/lesson/public-comments.svelte`. +5. 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=` | +| AC-4 | Anonymous user clicks "Log in" | Redirected to `/login?redirect=` | +| 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 |