Commit Graph
1810 Commits
Author SHA1 Message Date
Rotimi Best 14db5363e7 docs: fix railway.mdx steps failing 2026-07-13 02:18:54 +01:00
Rotimi Best 5e8188fbd3 fix: replace stale program references (#749) 2026-07-13 02:09:57 +01:00
Rotimi Best 484e4fb674 docs: replace Railway and Coolify screenshots with compressed WebP
Converted 19 screenshots from PNG to WebP with quality 80, width capped at 1280px.
File sizes reduced from 1.3-1.5 MB each down to 12-25 KB.
Updated all image references in railway.mdx and coolify.mdx accordingly.
2026-07-13 01:40:27 +01:00
Rotimi Best faa8eae011 docs: rename programs references to cohorts 2026-07-13 01:09:01 +01:00
Rotimi Best 54fa0875c8 docs: prd and pp updates 2026-07-13 01:02:15 +01:00
d1deb1be22 fix(dashboard): improve AI chat word wrapping readability (#746)
Replace break-all on chat message prose with break-words so normal text
wraps at word boundaries. Apply overflow-wrap only to inline code and
links, and use horizontal scroll for fenced code blocks.

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-13 00:24:23 +01:00
Rotimi Best 2815a61437 fix(dashboard): fix instructor avatar upload on course landing page (#745)
* fix(dashboard): fix instructor avatar upload on course landing page

The crop dialog was hidden behind the full-screen (z-250) landing-page
editor because bits-ui portals it to <body> at z-modal (200), and every
crop triggered an unbounded loop of POST /media/image requests.

- Add opt-in `elevatedDialog` prop to UploadImage that raises the crop
  dialog above the editor takeover; enable it in the instructor,
  certificate, and review landing-page editor forms.
- Break the upload effect loop in instructor-form by reading `course.id`
  through `untrack`, so the effect no longer re-runs when the setter
  reassigns `course` after each unique upload URL.
- Handle upload failures in instructor-form with a snackbar and always
  reset `isUploading`, so a failed upload no longer leaves the widget
  stuck and silent.

* docs(email): add email notification preferences PRD

Specify a Settings feature letting users enable/disable notification emails at two scopes: personal per-user preferences and org-wide admin defaults. Both gates must allow a send; transactional emails stay mandatory. Grounds storage in organization.settings and a new profile.settings jsonb, with a single preference guard at the email enqueue chokepoint.
2026-07-13 00:01:11 +01:00
Rotimi Best 7439f1f510 fix(dashboard): keep enroll join button visible with loading state (#744)
Logged-in users no longer lose the Join Course button while session
checks, app init, or auto-enrollment run. The button stays visible and
shows a loading spinner instead of being hidden during those flows.
2026-07-12 23:21:23 +01:00
8bd07a524c fix(org): auto-enroll idempotency, signup enrollment, and pending invite gate (#729)
* fix(org): auto-enroll idempotency, pending invites, and tenant enrollment

- Map Postgres unique violations to 409 CONFLICT instead of 500
- Link email-only roster rows before signup policy gates (preserve role)
- Return pendingInvite when an active org invite exists; surface modal globally
- Enroll at signup via create-profile hook when cio-org-id header is present
- Gate client auto-enroll on session orgRoles and pending invite check
- Throw 409 when org email row is linked to a different profile (Greptile)
- Enforce free-plan student cap via assertStudentCapacityOrThrow on new enrolls
- Hide student-only orgs from admin org switcher; redirect students off /org/[slug]
- Route tutor/admin pending-invite accepts on tenant sites to app host

Co-authored-by: Rotimi Best <[email protected]>

* perf(dashboard): parallelize account and pending invite fetch on tenant bootstrap

Start GET /account and GET /invite/organization/pending together when a
logged-in user visits a tenant site without session membership. Auto-enroll
still runs sequentially after both complete when no invite is found, then
refetches /account if enrollment succeeds.

Co-authored-by: Rotimi Best <[email protected]>

* fix: address PR review — restore AppModeWatcher and drop signup ctx enrollment

Better Auth database hooks do not receive frontend cio-org-id headers in ctx,
so enrollFromSignupOrgContext could never work. Revert to client-side auto-enroll
fallback and restore main's AppModeWatcher in the root layout.

Co-authored-by: Rotimi Best <[email protected]>

* refactor(org): rename auto-enroll-student endpoint to auto-join

POST /organization/auto-join replaces /auto-enroll-student to clarify this
joins org membership, not course enrollment. Renamed service to autoJoinOrg.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-12 22:46:44 +01:00
Rotimi BestandRotimi Best 7eaff0040c fix(dashboard): make course list rows real links like cards (#743)
Wrap list-view course rows in anchor tags so users can open courses in
new tabs via right-click or modifier-click, matching the grid card layout.
Falls back to button behavior when a custom explore click handler is set.

Co-authored-by: Rotimi Best <[email protected]>
2026-07-12 22:46:25 +01:00
Rotimi BestandRotimi Best 7605395f44 fix(dashboard): respect defaultMode light and track=false for color mode (#742)
* fix(dashboard): respect defaultMode light and track=false for color mode

ModeWatcher defaults userPrefersMode to system and enables OS tracking
before components mount, so prefers-color-scheme won on first paint even
with defaultMode="light" and track={false}.

- Add a blocking init script in app.html that defaults empty storage to
  light before any JS bundle runs
- Disable ModeWatcher head injection to avoid duplicate init scripts
- Keep track={false} on ModeWatcher and sync system tracking only when
  userPrefersMode is explicitly system
- Update the sidebar theme toggle to enable OS tracking only when the
  user selects System

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): ignore stale system color mode until user opts in

mode-watcher leaves mode-watcher-mode as system for anyone who never
picked a theme. ModeWatcher onMount then calls setMode('system'), which
overrides defaultMode="light" and track={false}.

Track an explicit sidebar choice separately and normalize stale system
values to light during the app.html init script and on mount.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): bootstrap color mode before mode-watcher module init

Even with cleared localStorage, mode-watcher's module-level derivedMode
could apply the OS color-scheme before ModeWatcher mounted. Stop relying
on ModeWatcher for document classes and bootstrap light mode from
hooks.client.ts immediately after the app.html script runs.

Co-authored-by: Rotimi Best <[email protected]>

* refactor(dashboard): collapse color-mode workaround onto mode-watcher

The scaffolding (color-mode.svelte.ts state module, color-mode-document.ts,
init-color-mode.client.ts) existed only to work around disableHeadScriptInjection,
which had turned off mode-watcher's own setInitialMode script — the very script
that seeds localStorage before the module's eager "system" write and prevents FOUC.

Let ModeWatcher inject that script again (defaultMode="light") and drive theme
application, tracking, and Sonner theming. Keep only the app.html bootstrap for
the one thing mode-watcher can't do: treat a returning user's stale "system" as
light unless they explicitly chose it. The theme toggle now calls mode-watcher's
setMode directly and marks the choice explicit.

* fix(dashboard): run color-mode bootstrap as external script to satisfy CSP

The inline app.html bootstrap and mode-watcher's own setInitialMode script were
both blocked by the nonce-based CSP (script-src has no 'unsafe-inline'), so
localStorage was never seeded before mode-watcher's eager "system" write — every
fresh load followed the OS and came up dark.

Move the seed + FOUC logic to a same-origin static file (static/theme-init.js),
which 'self' allows without a nonce, and load it as a parser-blocking script in
app.html so it still runs before the app bundle. Disable ModeWatcher's inline
head-script injection since it is CSP-blocked and now redundant, and paint the
root element with var(--background) so the pre-hydration shell never flashes
white in dark mode.

Verified with Playwright across fresh/explicit/stale-system cases in light and
dark OS settings; no theme-related CSP violations remain.

---------

Co-authored-by: Rotimi Best <[email protected]>
2026-07-12 22:07:36 +01:00
Rotimi BestandRotimi Best bce0f582ce fix(dashboard): redirect to course start after auto-enroll (#741)
* fix(dashboard): redirect to course start after auto-enroll

After successful enrollment on the org-site enroll page, verified users
were always sent to /lms because resolvePostEnrollRedirect was hardcoded.
Restore the API redirectTo target (/courses/:id/lessons?next=true) so
students land on their first lesson, with /course/:slug as fallback.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): match LMS continue path after auto-enroll

Use the same student course continue URL as the LMS home page
(/courses/:id/lessons?next=true) via a shared helper, and navigate with
goto instead of a hard reload or org-site /course/:slug fallback.

Co-authored-by: Rotimi Best <[email protected]>

---------
Co-authored-by: Rotimi Best <[email protected]>
2026-07-12 22:06:40 +01:00
Rotimi Best 8bed9f6b4e refactor(billing): system email + single-query backfill for the student cap (#735)
* refactor(billing): make student-limit email a system email; tighten backfill query

- Fetch free-plan orgs over the student limit in a single query
  (getFreePlanOrganizationsOverStudentLimit) instead of loading all orgs and
  counting each in memory; the backfill script now just fans out admin emails.
- Report a dry-run summary: how many free orgs are over the limit, how many
  with admins, total admin emails, and how many were skipped for having none.
- Reclassify the student-limit email as a system (ClassroomIO-branded) email
  rather than org-branded — it's the platform telling an admin about their
  plan. Drop the `branding` field and org masthead/theme; both callers updated.
- Reword the email to explain why it's sent (new students are being turned
  away) and that it's throttled to once a day, so it doesn't read as spam.
- Document the system-vs-org-branded email distinction in AGENTS.md.

* feat(billing): send student-limit emails on milestone crossings, once each

Stop emailing admins on every blocked attempt. Instead notify once when the
org crosses a student-count milestone:
- 50% of the limit → new studentLimitApproaching email (heads-up).
- 100% (limit reached) → studentLimitReached email.

Crossings are detected in assertStudentCapacityOrThrow on the allowed path
(blocked attempts no longer email). "Already notified" is persisted on
organization.settings.studentLimitNotified so each milestone fires at most
once, ever — surviving restarts, no per-day approximation. The backfill script
uses the same flag, making reruns idempotent (skips already-notified orgs and
marks them after sending). Email copy updated to explain the trigger and that
it's a one-time-per-milestone message.

* fix(email): use z.url() and drop the frequency footnotes on student-limit emails

Address PR review: `upgradeUrl` uses `z.url()`; remove the "once a day / once
per milestone" footnote lines from both the reached and approaching emails.

* set track={false}
2026-07-12 20:16:12 +01:00
7392be04d7 fix(dashboard): stop rendering mode-watcher init script as visible page text (#740)
createInitialModeExpression returns bare JS, not a script tag. Injecting it
via {@html} in svelte:head printed setInitialMode source on customer org
sites. Let ModeWatcher inject the script correctly instead.

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-12 18:28:35 +01:00
8288f157b3 fix(email): include course name in student welcome subject (#739)
* fix(email): include course name in student welcome subject

The studentCourseWelcome template used a static subject, so bulk course
assignments produced identical inbox titles. Resolve the subject from
courseName at send time and sanitize it like other email headers.

Co-authored-by: Rotimi Best <[email protected]>

* fix(email): use course-only subject for student welcome email

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-12 18:04:07 +01:00
51f90bf450 fix(dashboard): make upgrade plan modal mobile responsive (#736)
* fix(dashboard): make upgrade plan modal mobile responsive

- Constrain modal height and enable scrolling on small screens
- Anchor modal near top on mobile instead of vertical centering
- Stack success-state buttons and use full-width cards in the grid
- Reduce pricing card padding and adjust toggle badge on narrow viewports

Co-authored-by: Rotimi Best <[email protected]>

* chore(ui): regenerate output.css for pricing responsive classes

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-12 17:54:11 +01:00
877b45b1a3 fix(dashboard): honor sidebar theme preference on all routes (#738)
* fix(dashboard): honor sidebar theme preference on all routes

Stop forcing light mode on public org-site pages. Use one ModeWatcher
everywhere so light/dark/system follows the sidebar footer toggle.

- Remove isPublicOrgSitePage forced-light routing (#716 behavior)
- Fix theme toggle active state (light/dark/system mutually exclusive)

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): apply sidebar color mode instead of browser default

ModeWatcher defaulted to system, so prefers-color-scheme won unless
localStorage happened to match. AppModeWatcher now:

- Defaults new visitors to light (not system)
- Injects FOUC script with the same default
- Only tracks OS appearance when the user picks System in the sidebar
- Re-syncs explicit light/dark from mode-watcher localStorage on load

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-12 17:52:23 +01:00
46feccd0fd fix(dashboard): restore paid course payment modal on landing page (#737)
* fix(dashboard): restore paid course payment modal on landing page

Wire the existing PaymentModal back into the theme-based course landing
page. Paid course CTAs now open the name/email modal and redirect to the
configured payment link instead of navigating to the blocked enroll route.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): reset payment modal on close without effect loop

Co-authored-by: Rotimi Best <[email protected]>

* docs: add Svelte 5 rerender and effect loop guidance to AGENTS.md

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): avoid double-applying discount on course landing pricing

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-12 12:22:53 +01:00
Rotimi Best fd8bec3e70 feat(billing): enforce free-plan student cap across the app (#733)
* feat(billing): enforce free-plan student cap across the app

Free (BASIC) plan orgs were able to exceed the advertised 20-student
limit because the cap existed only as dashboard cosmetic copy with no
server-side enforcement.

Add a single source of truth (getStudentLimit / STUDENT_LIMITS) and a
central guard (assertStudentCapacityOrThrow) wired into every path that
creates a new student-role org member: self-enrolment, course-invite
acceptance, bulk audience import, and org-invite acceptance. The guard
throws UPGRADE_REQUIRED (403) and notifies org admins by email (throttled
to once per org per day) when the limit is hit.

Surface the limit in the UI: disable the audience import button and the
course new-student invite section with upgrade banners for admins, and
show a "contact your administrator" message on the public enrol page for
students (who cannot upgrade). Expose a studentLimitReached flag from the
public course endpoint so the enrol button can be disabled proactively.

Add a one-time (manual, non-cron) db script,
db:notify-students-over-limit, to email admins of orgs already over the
limit at rollout.

* feat(billing): guard remaining student-creating paths against the cap

The initial cap enforcement missed several service entry points that also
create a new student-role org member. Add the capacity guard to:

- autoEnrollStudent (public org self-join on a tenant site)
- cohort enrollment (bulk group enrollment and single member accept)
- ensureProgramCourseAccess (program/cohort-granted course access)

For the cohort and program paths, only genuinely new members are counted
(existing org members are skipped) so re-enrolling current students is
never falsely blocked. The core path enforces via the shared limit check;
it throws UPGRADE_REQUIRED without the admin email since the queue lives
in the api layer.

* fix(billing): don't show cap block to existing org members on enroll page

The public course-by-slug endpoint is unauthenticated, so the enroll
page's studentLimitReached flag is org-wide and can't tell whether the
current viewer is already an org member. An existing student of a full
BASIC org would see Join disabled even though the backend correctly lets
existing members enroll without consuming a new seat.

Only pre-block anonymous visitors (would-be new joiners). Logged-in users
are governed by the membership-aware backend, which allows existing
members and returns UPGRADE_REQUIRED (surfaced via the enroll onError
mapping) for genuinely new students.

* feat(billing): deliver org plan-limits via /account with an extensible registry

Replace the ad-hoc, per-surface audience fetches (each calling getOrgAudience
just to read a count) with a single, future-proof delivery of org usage +
limits on the /account payload, gated to admin/tutor members (students never
receive limit data; a plain COUNT, no student rows).

Model it generically so future org limits (courses, etc.) drop in without
reshaping the payload or re-plumbing the frontend:
- New packages/utils plan-limits registry: PLAN_LIMIT_RESOURCES, PLAN_LIMITS
  (reuses STUDENT_LIMITS), getPlanLimit, and a JSON-safe ResourceUsage /
  OrgUsageLimits shape with isResourceLimitReached / resourceLimitRemaining.
- getAccountData attaches a per-org `limits` map for admin/tutor orgs.
- Store selectors currentOrgLimits / studentUsage / isStudentLimitReached;
  sidebars, upgrade card, People pages, and invite modals read these instead
  of fetching audience rows.

Bring cohort surfaces to parity with course: cohort People page banner and
cohort sidebar People upgrade icon, matching the course equivalents.

Also: close the upgrade modal that opened over the invite modal (drop the
`add` param via openUpgradeModalOver), redesign the sidebar upgrade card
(progress toward the student limit), add a Progress `variant`, and document
the no-complex-expressions-in-object-literals rule in AGENTS.md.

* refactor(billing): address PR review on the limits work

- Dedupe new student profile IDs before the cohort seat check so duplicate
  batch entries can't overcount against the cap (Greptile P1).
- Drive the org-sidebar upgrade indicator from a dynamic nav-item `upgrade`
  flag (audience config declares `upgradeResource: 'students'`; groups set it
  from a `limitsReached` map) instead of a hardcoded path check.
- Collapse the audience Import button into a single button with ternary
  `disabled`/`href`.
- Simplify the notify-students-over-limit upgrade link to app.classroomio.com.
- Drop the comment on the org `limits` field.
- Add .greptile/rules.md documenting the manual one-time db/src/scripts
  convention (operator-controlled reruns) so idempotency isn't reflagged.

* fix(billing): name the org in the enroll capacity message

Change the enroll-page student-limit message from "This course has reached
its student capacity" to "{orgName} has reached its student capacity", since
the cap is org-wide, not course-specific. Pass orgName from the loaded org on
the page and from the currentOrg store in the enroll onError snackbar.

* style(billing): use PremiumIcon for the Authentication upgrade indicator

Replace the RocketIcon on paid settings sub-items (e.g. Authentication) with
the PremiumIcon used on the Audience item, right-aligned (ml-auto) to match.
2026-07-12 11:33:04 +01:00
Rotimi Best 0b5247bed3 fix(org): route admin and tutor invites to app domain (#731)
Team member invite emails and link-invite copy URLs were built with the
org tenant host (custom domain or siteName.myclassroomio.com). Admin and
tutor invites should always point at the admin dashboard origin
(app.classroomio.com in cloud). Student org invites are unchanged.
2026-07-12 09:16:39 +01:00
Rotimi BestandRotimi Best f181bb483a fix(website): cache GitHub stars on server and show in nav (#734)
* fix(website): cache GitHub stars on server for 3 hours

Move star count fetching from universal +layout.ts to server-only code
so the GitHub API is not called on every page load or client navigation.

Add an in-memory cache with a 3-hour TTL and in-flight request
deduplication. On API errors (including rate limits), return the last
successful value when available.

Co-authored-by: Rotimi Best <[email protected]>

* fix(website): forward GitHub stars from server load to layout

Universal +layout.ts was shadowing +layout.server.ts data, so stars
never reached +layout.svelte and the nav rendered an empty span.

Co-authored-by: Rotimi Best <[email protected]>

---------
Co-authored-by: Rotimi Best <[email protected]>
2026-07-12 09:14:55 +01:00
c03c08fc84 fix(email): make team org invites more transactional (#732)
* fix(email): make team org invites more transactional

Use org-branded sender and subject instead of the default
"Best from ClassroomIO" marketing identity. Include the inviter
name in the body, remove promotional emojis, and thread optional
subject overrides through the email job pipeline.

Co-authored-by: Rotimi Best <[email protected]>

* fix(email): escape invite fields and sanitize email headers

Address Greptile review: escape inviter/org/role values in the team
invite template, sanitize display names in buildEmailFromName, and
strip control characters from dynamic subjects.

Co-authored-by: Rotimi Best <[email protected]>

* fix(email): use RFC 5322 backslash escaping in display names

Co-authored-by: Rotimi Best <[email protected]>

* test(email): add vitest coverage for email header helpers

Cover escapeHtml, sanitizeEmailDisplayName, sanitizeEmailSubject, and
buildEmailFromName. Collapse repeated whitespace when stripping control
characters from header values.

Co-authored-by: Rotimi Best <[email protected]>

* fix(email): strip all ASCII control chars from email headers

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-11 22:22:36 +01:00
1a9c240501 fix(website): cache GitHub stars on server for 3 hours (#730)
Move star count fetching from universal +layout.ts to server-only code
so the GitHub API is not called on every page load or client navigation.

Add an in-memory cache with a 3-hour TTL and in-flight request
deduplication. On API errors (including rate limits), return the last
successful value when available.

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-11 21:31:25 +01:00
ce0dd376e6 fix(dashboard): respect URL tenant org for multi-org students (#727)
* fix(dashboard): respect URL tenant org for multi-org students

When a student belongs to multiple organizations, setOrgStore was falling
back to localStorage or the first /account organization even on tenant
sites (subdomain or custom domain). That overwrote the server-resolved
org from the URL and sent the wrong cio-org-id on API calls.

Pin currentOrg to the URL tenant on org sites, preserve the server org
when membership is missing, sync org from /org/[slug] routes, and fix
the goToLMS single-org length check.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): update currentOrg when switching tenant sites

Address Greptile P1: shouldSetPublicOrg only ran when the site name
matched, so navigating from one tenant subdomain to another left the
previous org in currentOrg. React to URL tenant changes in an effect
and re-pin via setOrgStore when account data is already loaded.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): harden tenant sync when switching org sites

- Reset globalStore when leaving a tenant URL
- Split org-site seeding from account-backed tenant sync
- Add syncTenantOrgFromUrl with deduping and auto-enroll on switch
- Stop calling setOrgStore directly from the root layout

Co-authored-by: Rotimi Best <[email protected]>

* refactor(dashboard): consolidate org context sync in root layout

Address PR review: document why post-setup sync is needed, merge setupApp
and syncOrgContext into one effect, resolve /org/[slug] from the root
layout via resolveAppOrgParams, and remove duplicate sync from org slug layout.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-11 09:17:45 +01:00
0836d9b5bb refactor(z-index): introduce a shared named z-index scale (#723)
* refactor(z-index): introduce a shared named z-index scale

Replace scattered magic z-index numbers (100, 150, 200, 201, 250) with
named layers (z-app-bar, z-app-bar-elevated, z-modal, z-menu-elevated)
so intent is documented at each call site and future additions insert
predictably instead of colliding.

Defined via Tailwind's @utility directive in two places that mirror
each other: packages/ui/src/index.css (ui:-prefixed, for components
that live in the @cio/ui package) and apps/dashboard/src/app.css
(unprefixed, for dashboard-only files) — a ui:-prefixed custom utility
only compiles if it is also used somewhere under packages/ui/src, so
dashboard-only usages need the unprefixed twin.

Also fixes two pre-existing dropdown/select menus (tags-admin.svelte,
mark-exercise-modal.svelte) that used ui:z-[250], which never actually
compiled to any CSS rule since nothing under packages/ui/src used that
exact class.

Co-authored-by: Rotimi Best <[email protected]>

* fix(ui): safelist the packaged z-index scale so it always compiles

@utility only emits a class when Tailwind's scanner sees that exact
candidate under packages/ui/src, so ui:z-app-bar-elevated and
ui:z-menu-elevated were documented but silently missing from the
compiled @cio/ui CSS since nothing inside the package itself used
them. Safelist all four layers via @source inline() so the shipped
scale always matches what's documented, regardless of which layers
this package happens to reference internally.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-11 08:25:11 +01:00
c0f82d9573 fix(auth): sync profile email verification with auth user (#726)
* fix(auth): sync profile email verification with auth user

Repair stale profile rows where user.email_verified is true but
profile.is_email_verified is false, which blocked the LMS with the
verify-email modal while send-verification-email returned
EMAIL_IS_ALREADY_VERIFIED.

Sync on session login, account fetch, and user update; update the
modal to respect session emailVerified and self-heal on that error.

Co-authored-by: Rotimi Best <[email protected]>

* chore(db): add backfill script for stale profile email verification

Co-authored-by: Rotimi Best <[email protected]>

* docs(db): document profile email verification backfill command

Co-authored-by: Rotimi Best <[email protected]>

* fix(auth): never demote profile email verification on sync

Greptile: self-hosted first signup can verify profile before
user.email_verified is set. Sync now only promotes verification.

Co-authored-by: Rotimi Best <[email protected]>

* fix(auth): promote user.email_verified when profile is verified

Self-hosted bootstrap and other flows can set profile.is_email_verified
before user.email_verified. Sync and createProfileHook now update both
tables when promoting verification.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-11 06:38:28 +01:00
2fb6b1c477 fix(auth): strip internal port from OAuth redirects on BYOD domains (#728)
* fix(auth): strip internal port from OAuth redirects on BYOD domains

When BYOD custom domains proxy auth requests through the dashboard to
PRIVATE_SERVER_URL (e.g. http://cio-api-vymh:10000), Node's URL host setter
preserves the upstream port. Better Auth then redirects browsers to
https://customer.example:10000/... which fails.

- Add oauth-proxy-redirect helpers to clear backend ports explicitly
- Rewrite inbound auth request URLs and outbound oauth-proxy-callback Location headers
- Send hostname-only X-Forwarded-Host from dashboard proxy when port matches backend

Co-authored-by: Rotimi Best <[email protected]>

* fix(auth): address Greptile review on oauth port stripping

- Only treat PRIVATE_SERVER_URL as an internal backend port
- Add test for rewriteAuthRequestUrl when forwarded host still has :10000

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-11 06:17:26 +01:00
Rotimi Best 3eb5e1268d fix(dashboard): stop enroll effect loop and duplicate verify emails (#721) 2026-07-10 16:05:30 +01:00
Cursor AgentandRotimi Best e4406481d6 fix(dashboard): stop enroll effect loop and duplicate verify emails
The auto-enroll effect re-fired because completeEnrollment reset
enrollmentAttemptKey in finally after each run. That re-triggered enroll
API calls and sendVerificationEmail on every tick for unverified users.

Track enrollmentSucceeded and send verification email at most once.

Co-authored-by: Rotimi Best <[email protected]>
2026-07-10 07:58:11 +00:00
Rotimi Best e04d024a3a fix(sidebar): scope elevated mobile z-index to course sidebar (#720) 2026-07-10 08:43:37 +01:00
Cursor AgentandRotimi Best b888725125 fix(sidebar): scope elevated mobile z-index to course sidebar
Remove the global z-150 mobile sheet stacking from Sidebar.Root so LMS
and org sidebars keep the default sheet layer. Course sidebar opts in via
mobileOverlayClass and its existing z-150 class.

Co-authored-by: Rotimi Best <[email protected]>
2026-07-10 07:36:06 +00:00
Rotimi Best 035d976d14 fix(dashboard): enroll verify flow and org-aware verification email (#718) 2026-07-10 06:59:52 +01:00
Cursor AgentandRotimi Best 9cedaf8153 fix(dashboard): address PR feedback on enroll load and copy
Remove unnecessary getApiKeyHeaders from public course-by-slug fetch.
Shorten enrolled_verify_email copy, fix ru.json duplicate keys, and
translate enroll verification strings in non-English locales.

Co-authored-by: Rotimi Best <[email protected]>
2026-07-10 05:51:36 +00:00
Rotimi Best 893681de3e fix(sidebar): raise mobile sidebar above sticky lesson headers (#719)
* fix(course): keep lesson header below mobile sidebar

Remove the z-100 override on sticky lesson/exercise page headers so they
stack below the mobile sidebar sheet (z-50). Also make the lesson page
header sticky to match the exercise page.

Co-authored-by: Rotimi Best <[email protected]>

* fix(sidebar): raise mobile sidebar above sticky lesson headers

Restore lesson/exercise page header z-100 stacking and fix the overlap
by lifting the mobile sidebar sheet and overlay to z-150, matching the
desktop course sidebar.
---------
2026-07-10 06:19:37 +01:00
Cursor AgentandRotimi Best 553c126e3c fix(sidebar): raise mobile sidebar above sticky lesson headers
Restore lesson/exercise page header z-100 stacking and fix the overlap
by lifting the mobile sidebar sheet and overlay to z-150, matching the
desktop course sidebar.

Co-authored-by: Rotimi Best <[email protected]>
2026-07-10 05:15:39 +00:00
Cursor AgentandRotimi Best daae6c1d40 fix(course): keep lesson header below mobile sidebar
Remove the z-100 override on sticky lesson/exercise page headers so they
stack below the mobile sidebar sheet (z-50). Also make the lesson page
header sticky to match the exercise page.

Co-authored-by: Rotimi Best <[email protected]>
2026-07-10 05:12:32 +00:00
Cursor AgentandRotimi Best 5de1f51e76 fix: address PR review comments on enroll and verify email
- Revert org-site layout 404 bypass; custom domain org lookup uses hostname
- Remove unnecessary comment in enroll redirect helper
- Restore email verification flow documentation
- Use generic welcome copy when no org branding is resolved

Co-authored-by: Rotimi Best <[email protected]>
2026-07-10 04:51:30 +00:00
Cursor AgentandRotimi Best a6d06f356f feat(email): make verify-email org-aware like other transactional mail
Resolve the tenant org from the verification link callback URL (custom
domain, tenant subdomain, or ?org=) and apply org logo, theme color, from
name, and subject. Falls back to ClassroomIO branding on the main app.

Co-authored-by: Rotimi Best <[email protected]>
2026-07-10 04:32:11 +00:00
Cursor AgentandRotimi Best f262a092ed fix(dashboard): keep unverified users on enroll page after join
After email login, auto-enroll was redirecting to /lms or the admin
/courses/:id/lessons route. Unverified users cannot use the LMS yet
(VerifyEmailModal on app routes), which felt like a permission error.

Enroll without verification, but stay on the enroll page with a verify
prompt instead of redirecting until email is confirmed. Also fix custom
domain org lookup to use hostname without backend port.

Co-authored-by: Rotimi Best <[email protected]>
2026-07-10 04:32:10 +00:00
be0085f090 fix(dashboard): save validation feedback, public site light mode, global verify modal (#716)
* fix(dashboard): surface save errors, force public site light mode, global verify modal

- Show snackbar with first validation error when course update fails client-side validation
- Force light mode on public org-site pages; lesson player and dashboard keep theme selector
- Mount VerifyEmailModal in root layout for all authenticated routes; keep trigger=app email guard

Co-authored-by: Rotimi Best <[email protected]>

* fix: instructor editor sync, auto-enroll after auth, email verification API

- Initialize instructor form from org fallbacks and batch metadata updates
- Auto-complete course enrollment after login/signup return from enroll page
- Block new org auto-enroll and course enroll until email is verified

Co-authored-by: Rotimi Best <[email protected]>

* fix: address PR review on instructor setter, color scheme, auto-enroll

- Use existing setter with batched metadata.instructor updates in InstructorForm
- Allowlist public org-site paths so /lms and /courses/:id keep theme selector
- Auto-enroll logged-in users via Better Auth session; remove sessionStorage flow
- Retry enrollment after email verification without duplicate Join Course clicks

Co-authored-by: Rotimi Best <[email protected]>

* fix: allow enrollment without verification; gate dashboard only

- Remove API email verification checks from enroll and auto-enroll flows
- Move VerifyEmailModal to (app) layout so public org-site pages stay usable
- Unverified users can enroll but hit the verification modal in LMS/dashboard

Co-authored-by: Rotimi Best <[email protected]>

* fix: address PR feedback on verify modal, routes, instructor form

- Restore currentOrg.id guard on VerifyEmailModal open condition
- InstructorForm imports currentOrg directly instead of prop drilling
- Move public org-site light-mode route patterns to routes constants
- Reset enrollment attempt key on enroll failure to allow retry

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-10 01:55:13 +01:00
35f5216839 fix(org): favicon fallback, OG logo styling, and Discord embed CDN URLs (#714)
* fix(org): serve OG images from CDN for Discord embeds

Discord times out on slow dynamic OG generation (~3-5s). Persist generated
images to R2, point meta tags at the media CDN, and warm the API endpoint
on org-site page loads so crawlers get a fast static PNG URL.

Co-authored-by: Rotimi Best <[email protected]>

* docs: add org-site Open Graph and favicon guides

Document user-facing sharing behavior and contributor architecture
for dynamic OG images, favicon fallback, R2/CDN caching, and embed
cache invalidation.

Co-authored-by: Rotimi Best <[email protected]>

* fix(org-site): address PR review on OG CDN fallback and docs

- Fall back to API OG URL when CDN object is not yet available (HEAD check)
- Cancel warm-fetch response body to avoid connection pool exhaustion
- Align tenant-router OG edge cache TTL with one-hour API revalidation
- Fix favicon docs navigation and add Custom Domain tab screenshot
- Replace Chiberry Artistry example with AI Academy

Co-authored-by: Rotimi Best <[email protected]>

* fix(org-site): accept docker hostnames for OG warm URL

Use isValidHttpUrl for PRIVATE_SERVER_URL so internal values like
http://api:3081 work for SSR warm fetches in Docker Compose.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-09 13:38:49 +01:00
Abdulmuizz Hamzat bd2585f8fd docs(self-hosted): rework Railway + Coolify self-hosting guides with walkthroughs and explicit env vars (#700) 2026-07-09 10:01:24 +01:00
22a3e6484b fix(org): show org logo for favicon and OG image (#713)
When no custom favicon is set, fall back to the organization logo on
public org-site pages instead of the default ClassroomIO icon.

For OG images, only apply the theme-color background behind the logo
when rendering initials; real logo images now render without the tint.

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-08 03:28:38 +01:00
3d15bc0a01 fix(org): OG redesign, public meta URLs, and org-site meta copy (#712)
* feat(org): add dynamic open graph images for school sites

Generate branded 1200x630 OG images for org public sites via GET /org/og/:siteName.png.
Paying cloud orgs include their landing-page tagline; free plans show a Powered by
ClassroomIO watermark. Dashboard org-site meta tags now point at the dynamic image.

Co-authored-by: Rotimi Best <[email protected]>

* feat(org): enable custom favicon upload and org-site rendering

Allow org admins to upload a favicon from domain settings, persist it via
PUT /organization, and render it on public org sites with logo fallback.

Co-authored-by: Rotimi Best <[email protected]>

* fix(org): scope custom favicon to public org-site routes only

Co-authored-by: Rotimi Best <[email protected]>

* refactor(api): inline org-site OG rendering, remove @cio/og-images package

Move template and render helpers into apps/api/src/utils/org-site-og.ts.
Certificates stay in a shared package because they support multiple templates;
org OG is a single API-only concern.

Co-authored-by: Rotimi Best <[email protected]>

* chore: remove packages/og-images directory

Co-authored-by: Rotimi Best <[email protected]>

* refactor(api): mount org OG image under /org-site/og

Align with existing org-site public routes (/org-site/course) instead of a
top-level /org prefix.

Co-authored-by: Rotimi Best <[email protected]>

* refactor(plans): consolidate isOrgOnFreePlan into options-based utility

Move orgId guard and self-hosted/plan checks into @cio/utils/plans.
Dashboard and API callers pass plans, isSelfHosted, and optional orgId
directly; remove the dashboard store wrapper.

Co-authored-by: Rotimi Best <[email protected]>

* fix(org): require orgId for free-plan check and translate favicon strings

Treat missing orgId as not on free plan so nav gating matches prior
behavior when currentOrg is unset. Pass organization.id from OG service.

Add favicon settings translations for all non-English locales.

Co-authored-by: Rotimi Best <[email protected]>

* refactor(dashboard): move meta tag helpers to metaTags.server.ts

Keep PRIVATE_SERVER_URL resolution in a server-only module so dynamic
private env imports cannot leak into universal or client bundles.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): allow File favicon in org update before upload

Skip favicon from pre-upload Zod validation when a File is provided,
matching how avatar uploads bypass ZUpdateOrganization until resolved.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): use public API URL for org OG meta tags

Never emit PRIVATE_SERVER_URL in og:image URLs. Prefer PUBLIC_SERVER_URL
for crawlers; fall back to same-origin /proxy when unset.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): use org landing copy and public OG URLs for org sites

Populate og:title, description, and site_name from org landing page hero
copy on cloud tenant sites. Build og:image from the tenant origin /proxy
path so crawlers never receive internal PRIVATE_SERVER_URL values.

Co-authored-by: Rotimi Best <[email protected]>

* feat(api): redesign org OG image with logo top-left

Replace the saturated gradient layout with a light theme-tinted card:
Academy label, org name, and tagline bottom-left; logo top-left;
decorative circle top-right; watermark on free plans.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-07 23:33:48 +01:00
b8fca607aa feat(org): dynamic open graph images for school sites (#709)
* feat(org): add dynamic open graph images for school sites

Generate branded 1200x630 OG images for org public sites via GET /org/og/:siteName.png.
Paying cloud orgs include their landing-page tagline; free plans show a Powered by
ClassroomIO watermark. Dashboard org-site meta tags now point at the dynamic image.

Co-authored-by: Rotimi Best <[email protected]>

* feat(org): enable custom favicon upload and org-site rendering

Allow org admins to upload a favicon from domain settings, persist it via
PUT /organization, and render it on public org sites with logo fallback.

Co-authored-by: Rotimi Best <[email protected]>

* fix(org): scope custom favicon to public org-site routes only

Co-authored-by: Rotimi Best <[email protected]>

* refactor(api): inline org-site OG rendering, remove @cio/og-images package

Move template and render helpers into apps/api/src/utils/org-site-og.ts.
Certificates stay in a shared package because they support multiple templates;
org OG is a single API-only concern.

Co-authored-by: Rotimi Best <[email protected]>

* chore: remove packages/og-images directory

Co-authored-by: Rotimi Best <[email protected]>

* refactor(api): mount org OG image under /org-site/og

Align with existing org-site public routes (/org-site/course) instead of a
top-level /org prefix.

Co-authored-by: Rotimi Best <[email protected]>

* refactor(plans): consolidate isOrgOnFreePlan into options-based utility

Move orgId guard and self-hosted/plan checks into @cio/utils/plans.
Dashboard and API callers pass plans, isSelfHosted, and optional orgId
directly; remove the dashboard store wrapper.

Co-authored-by: Rotimi Best <[email protected]>

* fix(org): require orgId for free-plan check and translate favicon strings

Treat missing orgId as not on free plan so nav gating matches prior
behavior when currentOrg is unset. Pass organization.id from OG service.

Add favicon settings translations for all non-English locales.

Co-authored-by: Rotimi Best <[email protected]>

* refactor(dashboard): move meta tag helpers to metaTags.server.ts

Keep PRIVATE_SERVER_URL resolution in a server-only module so dynamic
private env imports cannot leak into universal or client bundles.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-07 22:15:11 +01:00
Rotimi Best af95c6280c fix(dashboard): prevent CDN caching of HTML pages to fix stale builds after deploy 2026-07-07 21:14:10 +01:00
Abdulmuizz Hamzat 2e402efa78 chore(ci): publish latest from main and retire the edge tag (#711)
* fix(docs): update versioning strategy and tag policies for Docker images

* fix(docs): clarify versioning strategy in environment configuration
fix(docker): update docker-push script to remove local latest tag handling

* fix(docs): clarify VERSION variable usage in Docker push script
2026-07-07 12:59:33 +01:00
Rotimi Best e8181b296b chore(dashboard): add SVG logo variants alongside existing PNGs 2026-07-07 07:17:11 +01:00
Rotimi BestandRotimi Best bce38aa530 fix(dashboard): show course cards in landing page settings preview (#697)
* fix(dashboard): show course cards in landing page settings preview

Fetch public courses for the org and pass them to the theme preview
on the landing page settings screen, matching the editor preview.

Co-authored-by: Rotimi Best <[email protected]>

* refactor(dashboard): skip redundant public course fetches

Add loadPublicCoursesIfNeeded so settings, editor, and live landing
page reuse cached courses for the same org site instead of clearing
and refetching on every mount.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): always refetch courses on live org landing page

Keep loadPublicCoursesIfNeeded for settings preview and editor only.
The public-facing landing page should fetch fresh on each visit.

Co-authored-by: Rotimi Best <[email protected]>

* feat(ui): add shared empty state for org landing page catalogs

Introduce OrgLandingPageCoursesEmpty using the dashboard Empty pattern
with landing-theme colors. Show it across all ten org landing templates
when no courses are published, and wire translated labels through
buildOrgLandingPageProps.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): address Greptile review on public courses cache

- Add coursesLoaded prop so empty catalog state waits for fetch
- Deduplicate concurrent public course requests via in-flight promise
- Refresh courses on settings mount; reuse cache in editor only
- Expose invalidatePublicCourses for explicit cache clearing

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): load landing preview courses when org store hydrates

Replace onMount course fetch with a reactive effect keyed on the route
slug and currentOrg.siteName so public courses load after app init.
Mark the fetch as complete in finally so failed requests show the empty
state instead of a permanently blank catalog section.

Co-authored-by: Rotimi Best <[email protected]>

---------
2026-07-06 20:51:17 +01:00
8a22ee60bc fix(website): restore dotted hero backgrounds on PageHeader (#705)
* fix(website): restore radial hero backgrounds on page headers

PageHeader lost its decorative background when hero-bg was replaced by
DotField. Add a bottom-centered radial glow via a CSS class so it renders
reliably without Tailwind arbitrary-value parsing issues.

Also move the signup CTA gradient from an inline arbitrary Tailwind class
to the same CSS-class pattern for consistency.

Co-authored-by: Rotimi Best <[email protected]>

* fix(website): restore dotted DotPattern hero background on PageHeader

Revert the radial glow added to page headers and bring back the static
DotPattern grid that was replaced by DotField in c31ad6395.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-02 13:20:14 +01:00