Drop `position` from the `asset_usages` unique constraint so the same
asset can't be attached to one target/slot more than once, and update
the insert's onConflict target + lookup to match the narrower key.
Guard it at the service layer too: attachAssetService now rejects an
attach when the asset already has a usage for that target with a new
ASSET_ALREADY_ATTACHED (409) error, backed by an
assetUsageExistsForTarget query.
Add an admin flow to permanently delete a media asset and clean up
everything it leaves behind.
- Delete dialog with a destructive, irreversible confirmation; deletion
is blocked while the video is still attached and shows the in-use
lessons so the admin can detach first.
- Usage list resolves lesson/course context for each attachment, links
to the lesson editor via an anchor (right-click "open in new tab"),
and exposes a refresh button to re-check usage after detaching
elsewhere.
- Storage cleanup runs as a background maintenance job: the producer
captures the asset's object keys/prefixes up front and an idempotent
worker sweeps HLS segments, thumbnails, audio, transcript, and the
raw upload from object storage.
Also harden usage accounting so stale attachments cannot trap an asset:
- asset_usages has no FK to its polymorphic target, so prune a lesson's
usage rows whenever the lesson is hard-deleted (single + bulk paths).
- Usage resolution drops orphaned lesson usages (lesson gone or course
soft-deleted) so they neither appear in the list nor block deletion.
Document the icon-only button convention (variant="secondary") in
AGENTS.md.
The env module was moved to @cio/core/config/env but the upload-openapi-spec
script still imported from the removed apps/api/src/config/env path, causing
CI upload:openapi to fail with ERR_MODULE_NOT_FOUND.
Co-authored-by: Rotimi Best <[email protected]>
* feat(course): add reset student course progress action
Give course admins and tutors a destructive reset-progress flow on the
student people detail page. The backend clears learner progress for one
(courseId, memberId) pair in a single transaction while keeping the
groupmember enrolled, and logs an audit summary. The dashboard shows a
confirmation dialog with impact counts from analytics before deleting.
Co-authored-by: Rotimi Best <[email protected]>
* fix(i18n): add localized reset progress strings for all locales
Co-authored-by: Rotimi Best <[email protected]>
* fix(course): scope reset progress to course data only
Stop deleting org-level community questions and answers during course
progress reset. Fix course newsfeed deletion order so comments on a
student's posts are removed before the posts themselves. Restrict reset
to student members, move analytics loading to the person detail layout,
and rename impact copy to course newsfeed only.
Co-authored-by: Rotimi Best <[email protected]>
* fix(course): address Greptile review on reset progress
Make progressImpact opt-in via includeProgressImpact query param so
analytics requests avoid extra DB work unless the reset dialog needs it.
Count only student-authored course newsfeed posts and comments in the
impact summary to avoid double-counting replies on the student's posts.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
Disabled items were overriding props.class with a custom class string,
stripping the Sidebar.MenuButton's height, padding, and color styles.
Drop the inline class and rely on the existing aria-disabled CSS rules.
* fix(dashboard): course menu icons, delete scroll, and settings tags auth
Remove inconsistent dropdown icons, scroll to settings sections for delete
and related actions when already on the settings page, and skip org tag
group fetches for non-admin users to avoid ORG_TEAM_NOT_AUTHORIZED errors.
* fix(api): allow org members to fetch organization tags
Use orgMemberMiddleware for GET /organization/tags instead of restricting
reads to admins. Revert the dashboard settings workaround that skipped tag
group fetches for non-admin users.
---------
Disabled items were overriding props.class with a custom class string,
stripping the Sidebar.MenuButton's height, padding, and color styles.
Drop the inline class and rely on the existing aria-disabled CSS rules.
Add View course site and Publish course options to org course context menus.
Replace the course header View as student button with View Course Site and
a companion overflow menu that includes View as student plus list actions.
Show a publish prompt modal for unpublished courses and support #publish
deep links in course settings.
Clamping currentTime in the seeking/seeked handlers is ignored on progressive
MP4 (the forward seek takes effect immediately), so the must-watch lock leaked
even though the snackbar showed.
Block at the source: a Plyr `seek` listener returns false for targets past the
watched point, which skips Plyr's default seek so the media never moves.
Rewinding and re-seeking up to the watched point stay allowed; the snackbar
fires when a forward skip is blocked. furthestSeconds is now shared between the
listener and the playback tracker. The seeking/seeked clamp is kept as a
backstop for keyboard/programmatic seeks that bypass the progress bar.
* fix(analytics): show per-student last seen from login and session activity
Return ISO last-seen timestamps from course analytics instead of
pre-formatted strings so the dashboard can format them per student.
Batch-fetch last seen for all students and include session activity
alongside login events for more accurate, distinct values.
Co-authored-by: Rotimi Best <[email protected]>
* fix(analytics): destructure first row from getLastLogin query results
Promise.all returns an array per Drizzle select; nested destructuring
is required so loggedInAt and updatedAt are read from row objects.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
The lock clamped currentTime only during the seeking event. HLS defers the
forward seek until the fragment loads, so that clamp caught it; but for
progressive MP4 the forward seek takes effect immediately and the mid-seek
write is ignored, so playback leaked past the watched point (snackbar showed,
no snap-back).
Keep the immediate seeking-time clamp (HLS relies on it to avoid buffering
ahead) and add an authoritative re-clamp in seeked: if the playhead still
landed past the limit, snap back via a backward seek into buffered content,
which holds. Snackbar now fires once per blocked gesture and only when a
clamp actually happens.
Resetting currentTime inside the seeking handler doesn't reliably stick
across sources (HLS re-buffering, emulated YouTube events, coalesced native
seeks), so the snackbar showed but playback continued ahead. The timeupdate
handler then ignored the large forward jump via its delta < 2 guard, making
the skip permanent.
Re-clamp in seeked once the position settles, and add a timeupdate backstop
that snaps back forward jumps (delta >= 2) landing past furthestSeconds.
The query selected groupmember.certificate_earned_at as a non-aggregated
column but only grouped by course.id and course_completion_record.id,
causing Postgres error 42803. Add groupmember.id (its PK) to the GROUP BY
so the certificate column is functionally determined.
Adds live-class session scheduling on lessons (join link, date/time,
timezone) with automatic 24h and 1h reminder emails plus a calendar
invite (.ics), a time-change notification with an updated invite, a
teacher-authored welcome email and session invite on enrollment, and a
"Notify students" quiz nudge.
LMS gets an upcoming live-sessions card and Join buttons, plus a
reusable live-session card component (3 states) in @cio/ui with a
Storybook story. The sidebar content tree now reflects sequential
progression locking like the content list, and the course-completion
confetti regression is fixed.
Org-based emails no longer reference ClassroomIO by name. Docs add
live-class, welcome-email, and course-progression guides, expand the
certificates guide (design, signatures, corrected labels), move
plan-required callouts to the top, flatten how-to-guides to the docs
root with redirects, and add screenshots. Includes an add-docs-image
skill.
* fix(dashboard): redirect students on app domain to org LMS
When a student logs in on app.classroomio.com, send them to their org
tenant domain with a full page reload instead of staying on /lms at the
app host. Org sites and self-hosted installs keep the existing behavior.
Students who explicitly open /onboarding while already belonging to an
org are no longer redirected away, so they can still create their own
academy with the same email.
Co-authored-by: Rotimi Best <[email protected]>
* refactor(dashboard): use getOrgPublicUrl instead of duplicate helper
Main added getOrgPublicOrigin for tenant URLs; extend it with
getOrgPublicUrl for path-based redirects and drop the parallel
getOrgSiteUrl helper from org utils.
Co-authored-by: Rotimi Best <[email protected]>
---------
* fix(dashboard): use verified custom domain for view-as-student handoff
Prefer the org's verified custom domain over window.location.origin when
resolving the public tenant host. This prevents admin URLs like
app.example.com from being used for the student login-link handoff when
the org's public site is example.com.
Co-authored-by: Rotimi Best <[email protected]>
* fix(dashboard): keep self-hosted org origin on window.location
Self-hosted deployments serve admin and LMS from the same URL, so
getOrgPublicOrigin should always return window.location.origin there
rather than preferring a configured custom domain.
Co-authored-by: Rotimi Best <[email protected]>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
* docs(prd): add Zapier integration PRD
Draft PRD for Zapier integration with locked decisions on private-first distribution, Early Adopter+ plan gating, zapier:read/zapier:write scopes, polling-only triggers for v1, and apps/zapier app location. Covers trigger/action/search catalog, public API surface gaps, dashboard tab, and rollout plan.
Co-authored-by: Rotimi Best <[email protected]>
* docs(prd): revise Zapier integration PRD with OAuth and binary plan gating
Update PRD per planning decisions:
- Auth: switch from API key paste to OAuth 2.0 (authorization_code + refresh_token) shipping in v1; drop type: 'zapier' from organization_api_key.type enum.
- Plan gating: binary feature (Early Adopter+); no monthly Zapier credits, only per-minute rate limits. Plan downgrade revokes all grants immediately.
- Invite link: single shared private invite URL (we want adoption).
- Revoke UX: per-grant revoke in dashboard for safety net only; no bulk Disconnect button.
- Triggers: drop program_completed from v1 (now 5 triggers); deferred to v2.
- Add OAuth schema (oauth_authorization_code, oauth_access_token, oauth_refresh_token), routes, consent screen, plan-guard hook into license.ts.
Co-authored-by: Rotimi Best <[email protected]>
---------
The course cover Upload tab stored the raw file and relied on CSS
object-cover to fake the 280x200 frame at display time. Route uploads
through the existing ImageCropper at a 280/200 aspect ratio so the saved
asset matches the frame. Also teach the cropper UploadTrigger to accept
drag-and-drop so the prior drop-to-upload UX is preserved.
The footer grid (brand, socials, columns, CTAs) was only rendered for
the 'classic' variant. For 7 other variants (minimal, bold, saas, tech,
studio, corporate, terminal) the grid content was defined as a snippet
but never invoked, silently dropping social links and other footer
content.
The active tab's data-[state=active]:text-foreground rule outranked the
plain text-primary hover class, so hovering the active tab left white
text on the white hover pill (invisible in dark mode). Mark the hover
text-primary as important so the hovered active tab matches the hover
color of inactive tabs.
The org landing templates used 'Program(s)' in catalog headings, the
'view all' link, and the studio/terminal hero counters. That collided
with the app's actual Programs feature and confused users into thinking
a landing-page Program meant an app Program. Default copy now says
'Courses'; renamed the programsCount var to coursesCount to match.
A subdomain like xxazzz.myclassroomio.com set isOrgSite=true with a null
org, and the course page fell back to resolving the org from the course
itself — so courses rendered under subdomains that don't exist. Guard
the (org-site) layout so an unresolved org site 404s instead.
The preview forced images into a 280x200 box with default object-fit,
squeezing non-matching aspect ratios. Use object-cover so the image
fills the frame at its true aspect ratio and crops overflow instead.
Public course slugs carry a -<timestamp> uniqueness suffix that pushes
them past the 80-char ZSlug cap, so the org-site lookup returned a 400
and the page fell through to the landing page instead of redirecting to
the first lesson. The course.slug column is unbounded, so validate the
public-course route param with a no-max ZCourseSlug.
Use sanitizeOptionalHtml for reward.description now that the DB type
allows it to be omitted, fixing @cio/core build failures.
Co-authored-by: Cursor <[email protected]>
Make reward.description and instructor.coursesNo optional in the course
metadata JSON type so API builds match Zod validation.
Co-authored-by: Cursor <[email protected]>
- Allow coursesNo to be omitted in instructor metadata (not required)
- Allow reward description to be omitted when reward object is present
- Replace raw {@html} with SafeHtmlContent in course-pricing,
course-info-blocks, and embed landing-page components
Mirror the course list-row fix in the demo story: make the group a
container query that stacks below 48rem, cap tags at 3 with a +N pill,
and render the Default story full-width (padded) so both the stacked and
column layouts are visible. Uses unprefixed utilities since Storybook
generates those from its own Tailwind scan (prefixed classes only ship
in the prebuilt @cio/ui output.css).
The rigid 7-track grid always rendered in one row, so when the list was
squeezed the tag track shrank below the badge widths and tags overflowed
into the lessons/exercises column. Make the ResourceListRow.Group a
container query and stack each row's grid into a single column below
48rem, passing the column template via a CSS variable so the responsive
override applies. Also cap the row at 3 tags with a +N pill.
The moving-icons family sizes its SVG via a numeric `size` prop and only
forwards `class` to the wrapper div, so the cap's `size-3.5` class never
reached the SVG and icons rendered at the default 24px, overflowing the
cap. Pass size={14} to the cap icon.
A student opening /org/[slug] on self-hosted stayed on the admin
dashboard. The existing routeUserToNextPage redirect is gated by
shouldRedirectOnAuth (login/signup/onboarding only) and runs once at app
init, so it never caught this route. Add a reactive guard in the admin
org layout that sends students to /lms, gated on
appInitApi.isInitializedAndReady so the role is actually known before
deciding (the student flag defaults to false while loading).
The auth button derived from stores that hydrate in stages, so it
flashed through Login -> Dashboard -> Go to LMS as isLoggedIn and the
role/basePath settled at different times. Gate the label on
appInitApi.isInitializedAndReady so it only renders once the account
(and thus role) has loaded, and show a spinner in the button while a
logged-in user's experience resolves. Add separate loading and disabled
flags to authAction and wire them through all theme navs.
The hovered tab used text-primary-foreground for inactive tabs, which is
near-white — the same token as the hover pill background — so the label
disappeared. Drop the override and keep the hover label on text-primary.
Loosen the custom-domain regex to accept apex domains (1+ labels) in
addition to subdomains; the TLD already allowed any letters. Suppress
the CNAME record for apex hostnames since a CNAME is invalid at the zone
apex, and surface only the A record there. Drop the frontend apex
rejection and the misleading ".com" input suffix, and render the
domain-input prefix/suffix addons only when set.
Wrangler resolves @cio/utils/constants/* from dist/. Run utils build in
predeploy, predev, and pretypecheck so Cloudflare deploy does not fail with
a missing embeds module.
Serve widgets from short public URLs via tenant-router with R2-backed
assets and runtime API_URL injection, so copy-paste snippets hide infra
paths. Update widget payloads, CSP, upload purge URLs, and dev-only API
inject; document format/stage workflow in AGENTS.md and fix ui-prefix-check
YAML syntax.
The /org/[slug] routes loaded 250+ JS chunks and, after a deploy, every
chunk missed Cloudflare's edge cache and stampeded the single origin (up
to 48s to serve one static file) — the "keeps loading" lockout.
- tenant-router: opt /_app/immutable/* into the edge cache via
cf.cacheEverything; setting Cache-Control alone never populated it
- course/api: mark the 14 API singletons /* @__PURE__ */ so the barrel
tree-shakes instead of dragging every API into each importer
- courses page: import directly instead of via the feature barrels
Cuts the courses route from 257 to 225 static chunks and keeps static
assets off the origin after the first edge fill.
Sync modal editor state from the updated feed on open, clear editFeed
on reset, and remount TextEditor so a second edit shows the latest HTML
instead of stale content.
Gate student content by completion order, track per-video watch progress
with seek enforcement, and auto-complete lessons at the configured
threshold. Show ring progress in the lesson header, keep watch progress
on rewatch, and sync progression locks client-side so the next item
unlocks after completion.
- Add paginated recommended courses to LMS dashboard (top 3, list
view)
and explore page (12 per page); exclude courses blocking new
students
- Polish course preview modal: remove padding, render requirements as
HTML, round banner image, secondary close button
- Redirect to course start page after successful enrollment instead of
/lms;
keep spinner active until navigation completes
- Auto-enable "allow new students" when a course is published
- Move Explore after My Learning in LMS sidebar nav
- Move Certificates after Content in course sidebar
- Add hiddenColumns prop to CourseListRow; show "Learn more" button
instead of actions menu in LMS explore context
- Updated the CSS classes for the AI assistant bar to adjust positioning based on the new side panel width variable.
- Introduced a new `onWidthPreview` prop in the SidePanelRail component to handle live resizing and update the side panel width dynamically.
- Added width state management in the SidePanelStore to ensure proper width handling during panel interactions.
- Improved layout handling in the course layout component to accommodate the new side panel width adjustments.
- Implemented a new endpoint to delete failed manual p1080 renditions, restoring the master manifest and updating metadata.
- Enhanced the HLS upload process with better error handling and cleanup for aborted uploads.
- Introduced tests for HLS encoder functionality to ensure correct selection of rungs based on video source properties.
- Updated video upload component to manage reserved asset IDs and perform cleanup on abo
Add get_lesson_transcript (teacher) and read_lesson_transcript (student)
tools that fetch a lesson's uploaded-video transcript from media_transcript
by the video assetIds. The agents call them only when a question is about
the video's spoken content, so text-only chats keep a cacheable prefix.
- Introduced a concurrency gate to limit parallel uploads, preventing R2 overload.
- Implemented batching for presign requests, allowing up to 1500 paths per request.
- Added content type mapping for HLS uploads to ensure correct MIME types.
- Refactored upload logic to utilize the new concurrency and presign features, improving reliability and performance.
- Replaced email/token-based authentication with HTTP Basic Auth for the queue dashboard in production.
- Updated course components to use courseId instead of courseSlug for better consistency.
- Adjusted role-based security checks to utilize isStudentExperience for conditional rendering.
- Enhanced view-as-student functionality to redirect correctly based on courseId.
The openapi/storybook/embeds workflows pinned pnpm 8 and cypress pinned
pnpm 6 via action-setup@v2. The repo's lockfile is lockfileVersion 9.0
(written by the pinned packageManager [email protected].0), which pnpm 8/6 cannot
read — it ignored the lockfile and --frozen-lockfile failed with
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. Drop the explicit version and let
action-setup@v4 resolve pnpm from the packageManager field, matching the
already-working publish-mcp / publish-course-app workflows.
.claude/skills is now a symlink to the repo-root skills/ dir, so the
duplicate tracked copy under .claude/skills/ no longer exists on disk and
showed as a permanent deletion. Drop it from tracking and gitignore the
symlink plus the scheduled_tasks.lock runtime file.
These were used by the redis client and document-parsing services that moved
from apps/api into packages/core, but the dependencies stayed in apps/api,
breaking the @cio/core build with TS2307 module-not-found errors. Move them
(and @types/pdf-parse) to where the code now lives.
New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows
Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it