Skip to content

feat(marketplace): hands-on preview in a shared sandbox course - #8527

Closed
LWS49 wants to merge 25 commits into
lws49/feat-marketplace-pr7c-versioning-adminfrom
lws49/feat-marketplace-pr8-singleton-attempt
Closed

feat(marketplace): hands-on preview in a shared sandbox course#8527
LWS49 wants to merge 25 commits into
lws49/feat-marketplace-pr7c-versioning-adminfrom
lws49/feat-marketplace-pr8-singleton-attempt

Conversation

@LWS49

@LWS49 LWS49 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the hands-on half of marketplace preview. Until now a teacher evaluating a listing could only read its questions; there was no way to answer one, submit it, watch auto-marking run, or grade and publish it before committing to a duplicate. "Try it hands-on" on a listing preview page now opens the listing's served snapshot as an ordinary submission inside a single shared sandbox course on the dedicated preview instance, where the previewer can rehearse the whole student-and-grader loop end to end.

Everything that makes that safe keys off the preview flag on the sandbox course, never off an instance id: content is frozen, the sidebar and breadcrumbs are de-linked, a persistent banner says nothing here is real, graded-submission emails and external-LMS task completion are suppressed, and each previewer can reach only their own submission - not the sandbox's other submissions, its gradebook, or its roster. Attempts age out on a TTL, and a "Reset submission" button in the banner is the explicit way to start over sooner.

Also included: two crashes that only surfaced once real attempting happened inside the sandbox - a scribing canvas that threw when its loading indicator toggled after Fabric.js re-parented the canvas node, and an unhandled promise rejection when a Voice question was left unrecorded.

Design decisions

  • A preview rehearses the listing's current version snapshot, not a fresh per-preview copy. That snapshot already lives in the container course and is the same row a duplicate hands an adopter, so the preview is faithful by construction and cannot drift from what gets adopted. Duplicating again per preview is precisely what would reintroduce that drift, so the launch service only enrols the previewer and builds the attempt URL.
  • One sandbox course shared by every previewer, with previewers enrolled as manager - the lowest stock role that can attempt, grade and publish, since there is no grade-only role. Sharing one course means a manager's ordinary reads would otherwise reach every other previewer's submission, so the preview ability revokes submission management, the submissions list, the gradebook and the roster wholesale, then carves back exactly the previewer's own submission by creator_id.
  • Self-service reset is its own verb, :reset_own_preview_submission, rather than a creator-scoped carve-out of :delete_submission, which CanCan cannot express alongside a blanket cannot on the same action. The endpoint never accepts a submission id from the client - the row is always looked up by (assessment, current user) - and it is update, not destroy, so the submission keeps its id and the previewer watches it come back blank instead of being redirected away.
  • Relaunching resumes an existing attempt rather than resetting it, and attempts are cleared by a TTL keyed on last activity. One submission per (assessment, previewer) is already enforced by a unique index, and keying the reaper on updated_at means an in-progress rehearsal is never reaped out from under someone and async auto-marking has time to land. The reaper only ever removes submissions: the container course, the snapshot copies and the enrolments are deliberately persistent and reused.
  • The reaper runs weekly, not hourly, which makes the 24-hour TTL a floor rather than a ceiling - an aged attempt can linger up to a week past it. That slack is affordable precisely because "Reset submission" exists: a previewer never waits on the reaper to start over, so the only thing the interval controls is how promptly dead rows leave the sandbox. It does mean the per-run deletion cap now bounds a week's reaping rather than an hour's, so the cron is what to raise first if preview volume ever outgrows it.
  • The finalise response hands back the auto-marking job it just enqueued instead of exposing a new endpoint, because the job object already exists in-process by the time the controller renders. A previewer has no grader colleague to refresh the page for them, so a preview-only banner polls that job and refreshes the marks in place; platform-wide auto-refresh is a follow-up. The poller lives in the banner's own effect rather than the submission page's, since that page is a class component and the shared fire-and-forget poll helper orphans pollers across navigation.
  • The sandbox explainer ships as the container course's description rather than as UI copy, because that home page is otherwise blank - the sandbox has no announcements, todos or activity of its own - so it is the only place to explain the sandbox before the previewer opens the assessment. The copy lives in config/locales/{en,zh,ko}/course/assessment/marketplace.yml, matching how every other default record's user-facing text is sourced here, and is written once at creation, pinned to :en because the container is a cross-instance singleton whose column can only hold one language.
  • That locale key carries an _html suffix, which is load-bearing rather than decorative: the suite's stubbed i18n backend returns the raw key for most translations and makes an explicit exception for _html, so without the suffix every spec would see the key where the description should be and the copy could never be asserted on. The container's title stays a Ruby constant for the same reason in reverse - routing it through i18n would resolve to a key string under that stub, which no spec could assert against without either becoming tautological or failing on a container an earlier run had already created.

Regression prevention

Backend covers: launch authorization, including a published listing with no snapshot being denied rather than crashing; enrolment and attempt-URL construction on the preview host, and its idempotency per (listing, previewer); the reset endpoint accepting only the caller's own submission, only inside a preview course, and clearing answers back to genuinely blank with no past-answer or test-result residue; cross-previewer denial of another previewer's submission, plus denial of the submissions list, gradebook and roster inside the sandbox; the auto-marking job url appearing only in a preview course and only on the request that finalised; graded-submission email and external-LMS completion suppression; the container being created with the sandbox explainer from the locale file; and the reaper's TTL, batch cap and leaving the container, copies and enrolments intact.

Frontend covers: the launch button's new-tab flow, popup-blocked fallback and failure toast; the reset button being absent where the URL carries no assessment id, and re-hydrating the submission page in place when it does; the sandbox banner and the isPreview gate that mounts it; breadcrumbs rendering inert; the container home page showing its description to an enrolled previewer while normal courses are unchanged; the auto-marking banner's poll-to-settle path, its timeout ceiling, the purged-sandbox case reading as gone rather than "refresh", and teardown on unmount; the preview-specific publish toast; the scribing canvas render order; and the recorder no longer rejecting when nothing was recording.

Manually verified: launch into the sandbox on the preview host and the popup-blocked fallback; banner, inert breadcrumbs, unlinked sidebar and the explainer on the container home page; finalise refreshing marks in place without a manual refresh; the preview publish toast; reset clearing answers in place with the button absent off assessment pages; a second previewer blocked from the first's submission, the submissions list, the gradebook and the roster; a mid-poll listing purge reading as no longer available; the reaper removing only aged submissions; no graded email or external-LMS push from the sandbox; a published listing with no snapshot returning 403; and scribing and unrecorded Voice questions no longer crashing.

Nothing changes for existing courses: every behaviour here is gated on the sandbox course's preview flag. The sidebar partial's link-or-not argument now carries that flag alongside the read check, and the submission publish and refetch actions gained optional trailing arguments that are inert when omitted.

LWS49 added 9 commits July 30, 2026 10:02
A version IS its publication datetime, and the snapshot it serves is duplicated
into a `preview` container course so a published version can never change under
the courses that adopted it. Non-admins cannot edit container content.
Deleting the source assessment now orphans the listing instead of destroying it:
the marketplace keeps serving the snapshot, and the authoring copy is rebuilt in
the container automatically so a new version can still be published.
Nothing rolls back in this suite, so two unscoped `delete_all`s leaked into every
later spec file and failed them under some seeds: `User::Email.delete_all` stripped
the address off every earlier file's users (surfacing as `SMTP To address may not be
blank` in the mailer specs and a nil `user.email` in the external-assessment import
specs), and allow-list rows left behind granted `:access_marketplace` to users the
ability spec asserts cannot have it.

Scope the email cleanup to the domains that file hardcodes, and clear the allow-list
tables in the ability spec the way access_list_query_spec.rb already does.
Browse, listing/question preview and duplication all read the current version's
snapshot rather than the live source assessment, and a manager can cut a new
version from the assessment page.
The banner dates both content vintages rather than numbering them, and cannot be
dismissed or muted: it is a statement of fact about the copy, so it stands until
the copy is updated or deleted. It offers the in-place update only while no
student has submitted; once one has, it explains why no action is possible.
Inside the preview container every listing's snapshots share one title, so the
index chip dates each one and links it to the listing it belongs to.
A snapshot in the container course is an ordinary assessment with every management
affordance live, and editing one is silently destructive: it changes what future
adopters copy for a version that was never published, and stops the version's
publication date describing its content.

A soft guard only. Nothing is disabled, because the surface is admin-only and the
escape hatch for repairing served content without minting a version is deliberate.
The banner names the risk and links straight at the source assessment to edit
instead, on that assessment's own host since it may live on another instance.
Every listing across instances, with its version history, adoption list and source
provenance, plus the actions only an admin has: unlist, re-list, rebuild the
authoring copy, and permanently delete a listing that is off the marketplace.

The whole read path runs tenant-free — listings span instances while their snapshots
live in the preview container — and links to a source assessment are absolute, since
a course id only resolves on its own instance's host.
The listings table, its per-listing page, and the two maintenance buttons. Actions sit
in one fixed order so a given action always occupies the same slot, and delete is
present on every row — disabled until the listing is unlisted — so its tooltip can
state the rule rather than leaving the admin to infer it from a missing icon.
@LWS49 LWS49 changed the title Lws49/feat marketplace pr8 singleton attempt feat(marketplace): hands-on preview in a shared sandbox course Jul 30, 2026
LWS49 added 16 commits July 31, 2026 16:34
Adds `PreviewLaunchService` and the `launch_preview` endpoint: a previewer is
enrolled as a manager of the singleton container course and handed the attempt
url for the listing's served snapshot, on the preview host. No copy step — the
snapshot the marketplace already serves is what a duplicate would give an
adopter, so a preview cannot drift from the adopted copy.

Also adds `Listing.serving_assessment?`, which the sandbox lock uses to tell a
handed-out snapshot from the rest of the container, and moves the container's
description into locales so the sandbox explainer can be written as HTML.
A rehearsal in the sandbox must not reach the outside world. Guarded at the
three writers rather than their call sites, so a fourth cannot reintroduce it:

- no graded-submission email (the previewer grades and publishes their own
  work, so it would land in their own inbox for a fake grade),
- no task completion pushed to the previewer's real external LMS,
- no `pending_staff_reply` flag, which in a container with no staff only ever
  surfaces as a nag on the Pending tab and the Comments badge.
Both crumb handles on any assessment page fetch `show`, and so does the page
itself. One endpoint serving both means a breadcrumb costs the page's ~50
queries to render two strings — and made the marketplace sandbox's crumb
allowance a licence to read the whole authoring surface.

`show` now renders `crumb` for a `?crumb=true` request, skipping the two
`before_action`s the full payload needs. Split on the request rather than on
the viewer, so the payload stays the same for everyone.
A previewer is enrolled as a `manager` of the shared container course — the
lowest role that can attempt, grade and publish. On the ordinary abilities that
is also a licence to read and edit the whole course, and a subtractive
allow-list can never be complete: it has to name every component, every custom
verb and every collection action, and each new one arrives switched on.

`ApplicationPreviewSandboxConcern` inverts it. On the preview instance a
non-administrator reaches only what a controller explicitly claims via
`preview_sandbox_accessible?`, mirroring how `publicly_accessible?` marks out
the unauthenticated surface. A component added tomorrow is denied without
anyone remembering to deny it.

Alongside it, `restrict_preview_course_reads` revokes the manager grants that
would otherwise reach another previewer's submission, the aggregate gradebook
and the roster of everyone who has ever previewed here. The lock is
per-VIEWER: a system administrator curates the container from inside it, so
the sandbox stays navigable for them.
The sandbox exists to rehearse the one assessment the previewer opened, and
the lock denies everything else — so leaving live links around only offers
them a 403. Sidebar items and breadcrumbs render as inert text instead.

Home is withheld separately: it is the one sidebar entry that does not come
from the `sidebar` payload, so the jbuilder's de-link never reached it.

Gated on `isPreviewRestricted`, not `isPreview`: a system administrator
curates the container from inside it and keeps working links. The same flag
also keeps them off the sidebar's member identity, which they only hold there
because launching a preview enrolled them.
… page

Everyone in the sandbox is enrolled, so `CourseShow` skipped the description —
yet it is the only thing on that page, since the container has no
announcement, todo or activity of its own.

Reached only by the administrators who curate the container: the sandbox lock
denies this page to a restricted previewer, who is landed straight on a
submission and told the same thing by the banner there.
`unique_assessment_id_and_creator_id` allows one submission per (assessment,
previewer), and launching a preview resumes it rather than resetting it — so
without this, starting over means waiting for the TTL.

`Submission#reset_preview!` clears every answer, not just the current ones: a
previewer who finalises and unsubmits accumulates Past Answers, which is
exactly the trace a reset is meant to erase. It does not reuse `unsubmit`'s
`recreate_current_answers`, which preserves in-progress drafts, copies the old
content forward as `last_attempt`, and keeps the old rows as history — all
correct for a real course, all wrong here.

The endpoint never accepts a submission id from the client: the row is always
looked up by (assessment, creator), which makes resetting someone else's
attempt structurally impossible before the ability check even runs.
Opens the sandbox in a new tab from a listing's preview page, behind a
confirmation that says what will happen: a separate sandbox, possibly a
sign-in redirect, and no effect on the viewer's own course.

`navigateTo` exists as a seam, not an abstraction: jsdom seals the whole
navigation surface, so a component that assigns `location.href` inline has no
assertable behaviour and its test passes whether or not the branch still
exists.
Says what the sandbox is on the page a previewer is actually landed on, and
hosts the Reset submission button there.

Mounted by `CourseContainer` because the flag lives on the layout payload, the
only data available on every course page — but it narrows itself to submission
pages: the promise it makes is about work a previewer produces, and the action
it hosts has nothing to act on elsewhere.

Gated on `isPreview`, not `isPreviewRestricted`: "nothing here is real" is a
fact about the course, equally worth saying to an administrator, who also
needs the Reset action. Only the LOCK is per-viewer.
A previewer has no grader colleague to refresh the page for them, so
finalising a preview submission left them looking at an unmarked attempt with
no indication anything was happening.

The finalising request now hands back the auto-grading job it just enqueued —
absent outside a preview course, and absent on any request that did not itself
finalise — and the banner polls it, refetching the submission when it lands.
It reads a 404 during polling as a purged sandbox and says so, rather than
reporting a generic failure.
"Submission updated successfully" says nothing about what publishing a grade
would actually do. In the sandbox the previewer is rehearsing the grader's
side, so the toast names the consequence they came to see: the student would
now be able to read this grade and feedback.
A submission url whose record does not exist rendered the page's normal shell
with empty state, which reads as a broken page rather than a wrong address.
The page's own load now redirects to the not-found page on a 404 —
deliberately a separate thunk, since the preview banner refetches through
`fetchSubmission` and reads the same 404 as a purged sandbox.

Three things make that page fit once you arrive:

- the redirect carries the address it came from and the page puts it back, so
  the viewer sees the url they asked for rather than `/404`, the way the route
  catch-all already behaves;
- `/submissions/:id` with no `edit` used to match a parent route with children
  but no index, rendering an empty outlet inside the course shell for any id,
  real or invented. An index route redirects it to `edit`;
- a restricted previewer gets no "go back home" link — `/` is the sandbox
  container, which the lock denies, so the link only led to a 403.
Fabric.js re-parents the raw <canvas> node into its own container div, so a
sibling rendered before it would need React to `insertBefore` relative to a
node that is no longer a direct child — which throws on every toggle, e.g. a
redundant re-initialize resetting `isCanvasLoaded`. Appending a trailing
sibling never needs a reference node.
`stopRecord()` rejects with "Recorder has already stopped", and nothing awaits
it — so it becomes an unhandled rejection, surfacing as a full-page crash under
StrictMode's dev-only double mount/unmount on every unrecorded Voice question.
Weekly, keyed on last activity rather than creation so an in-progress
rehearsal is never reaped out from under someone. Never touches the container
course, the assessment copies or the previewers' enrolments — those are
deliberately reused across preview sessions.

The cron sets only how long past the TTL a submission may linger, not how long
it is kept: starting over is the banner's Reset submission button, not this.
The design doc these cite is local-only and unversioned, so "design §4.2" and
friends point at nothing a reader of this repo can open. Each comment already
says the thing it was citing.
@LWS49

LWS49 commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by a split, not abandoned. The whole branch is preserved across three stacked PRs:

Rationale: 4.1k lines in one PR, of which only ~1.3k were production code. The three heads are each independently green.

Two changes to content while splitting:

  1. The sandbox explainer is dropped. It was written for previewers, but ApplicationPreviewSandboxConcern allow-lists only :sidebar on CoursesController, so no restricted previewer could ever load the page that rendered it. The container course keeps a one-line internal description; the preview_container.description_html locale entries (en, zh, ko) and the CourseShow special case are gone.
  2. The dangling design-spec comment references moved down the stack into the commits that own those files (feat(marketplace): serve versioned snapshots instead of live content #8524, feat(marketplace): version-aware browse, duplicate and adopter update #8525, feat(marketplace): system-admin listing and version management #8526), since none of the 14 files they touched belong to this branch.

Closing in favour of the three above.

@LWS49 LWS49 closed this Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant