feat(marketplace): add data model, migrations, and permissions - #8476
Merged
Conversation
LWS49
commented
Jul 8, 2026
Collaborator
- add Listing and Adoption models under Course::Assessment::Marketplace namespace, with course_assessment_marketplace_ prefix on both tables
- Listing tracks published state and publisher, with a uniqueness constraint per assessment and an adoption_count helper
- Adoption links a listing to a destination course and duplicated assessment, one adoption per duplicated assessment
- wire has_one :marketplace_listing onto Course::Assessment
- add AssessmentMarketplaceAbilityComponent: admins can publish listings, course managers/owners can access, duplicate, and preview published listings
LWS49
force-pushed
the
lws49/feat-marketplace-pr1-foundation
branch
3 times, most recently
from
July 8, 2026 04:05
833d37f to
9926c90
Compare
LWS49
force-pushed
the
lws49/feat-ext-assessments-pr4-import
branch
7 times, most recently
from
July 13, 2026 14:36
19f4347 to
e530ef0
Compare
Base automatically changed from
lws49/feat-ext-assessments-pr4-import
to
lws49/feat-gradebook-export
July 13, 2026 14:40
LWS49
force-pushed
the
lws49/feat-marketplace-pr1-foundation
branch
from
July 17, 2026 08:06
9926c90 to
b34bd1d
Compare
Contributor
|
Need to resolve merge conflicts (minor). |
LWS49
force-pushed
the
lws49/feat-marketplace-pr1-foundation
branch
from
July 29, 2026 10:26
b34bd1d to
4e681d7
Compare
- add Listing and Adoption models under Course::Assessment::Marketplace namespace, with course_assessment_marketplace_ prefix on both tables - Listing tracks published state and publisher, with a uniqueness constraint per assessment and an adoption_count helper - Adoption links a listing to a destination course and duplicated assessment, one adoption per duplicated assessment - wire has_one :marketplace_listing onto Course::Assessment - add AssessmentMarketplaceAbilityComponent: admins can publish listings, course managers/owners can access, duplicate, and preview published listings
- add publish/remove listing endpoints (admin-gated create/destroy) - expose canPublishToMarketplace + listing state on assessment show DTO - add Publish/Remove to Marketplace button on the assessment header - warn in the delete Prompt when a listed assessment is removed - add MarketplaceAPI client, translations, and controller/FE specs
- add cross-instance listings index (published only, live counts) - add browse page with title search, adoptions/newest sort, row select - add sidebar admin entry + /courses/:id/marketplace route - add "Import Assessments" button on assessments index (from_tab) - add FE api/operations/types, controller + component specs
- add DuplicationJob: copies listings into a course tab, writes adoption - add bulk duplicate endpoint enqueuing the job for selected listings - add DuplicateConfirmation modal with row + bulk triggers, job polling - add MarketplaceAPI.duplicate and duplicateListings poll operation - serialize and assert live distinct-course adoption count in index
Add the read-only backend for the marketplace browse flow: - listings#show serializes a curated, read-only view of a published assessment (config + per-question summaries) for the listing preview. - questions#show serializes a single question's detail, dispatching to per-type detail partials (multiple/text/voice/forum/programming/ rubric/scribing) so each renderer gets exactly the data it needs. - The listings index gains destination tabs plus preview/duplicate URLs so the browse table can link into the flow and target a tab. Type labels are serialized human-readable (question_type_readable) to match the real assessment show page, while the demodulized discriminator is kept for frontend renderer dispatch. The base controller pulls in AssessmentsHelper so the preview views can reuse display_graded_test_types, and the sidebar component now uses the :marketplace (storefront) icon.
…port Extract the assessment/tab/question tree from AssessmentsListing into a reusable DuplicationAssessmentTree component so both the duplication page and the marketplace duplicate dialog render an identical tree. The old DuplicateItemsConfirmation listing is rewired onto it. Also add the shared table primitives the marketplace index needs: - renderEmpty flows through TableTemplate -> Body -> MuiTable so a table can render a custom empty state when it has no rows. - hideSelectAll drops the select-all header checkbox while keeping the per-row checkboxes. - Register the storefront icon in COURSE_COMPONENT_ICONS.
Build the read-only browse experience on top of the preview endpoints: - Marketplace index: single-toolbar table with pagination, empty states, hidden select-all, and links into the listing preview / duplicate flow. - Listing preview page: read-only assessment config, per-question cards (type chip, staff-only notes, expandable options) and a Duplicate Assessment action. - Question detail preview: header chip plus a renderer dispatcher with a renderer per question type (multiple/text/voice/forum/programming/ rubric/scribing). - Duplicate dialog now shows the destination course and the shared assessment tree. Includes the api client, operations, types, translations and locale strings backing the above.
Thread the origin assessment tab (from_tab) through the whole browse flow (index -> listing -> question preview and back) via withFromTab helpers, so a duplication always imports into the tab the user started from no matter how they navigate. Add the route data handles that build the marketplace / listing / question breadcrumbs, preserving from_tab on the crumb links.
Replace the static destination summary with an in-dialog tab picker and report duplication results honestly: - Add DestinationTabPicker, a radio tree grouping the current course's tabs by category, so the duplicator chooses the destination tab inside the dialog instead of it being fixed by the launching `from_tab`. The selection seeds from `from_tab` (falling back to the first tab) and re-seeds on each reopen, but a parent re-render never resets a choice mid-decision. - Serve `destinationTabs` from the listing show endpoint too, so the picker is available when duplicating from the listing detail page, not just the marketplace index. - Restyle the dialog: vertically stacked tabs with larger category/tab text, a dense TypeBadge variant, an explicit "Duplicating" heading, the ⊘ "arrives unpublished" hint, and explicit cancel/primary colors. - Report a *completed* duplication (the toast fires from pollJob's completion callback, not on submit) and link to where the copy landed via the job's redirectUrl; reword the failure copy to plain language. Widen the shared toast Toaster type to ReactNode so the toast can carry that link (type-only change, no runtime effect).
`marketplace/listings` with no listing id matched no route and 404'd. Add a redirect so it lands on the same page as `marketplace/`.
Add a "Preview" chip beside the title on the read-only listing detail page, so it is never mistaken for the real assessment it mirrors.
Gate assessment-marketplace browsing per person rather than per current-course role. A typed allow-list (user / instance / email-domain / everyone rules) grants access to baseline-capable users (course manager/owner or instance instructor/admin anywhere), with individual access blocks as overrides. - AllowlistRule and AccessBlock models, migrations, and per-type uniqueness - RuleMatchQuery / RulePreviewQuery / AccessListQuery for matching and audit - ability component gates :access_marketplace on the allow-list minus blocks - User baseline predicates and delete-user FK handling for both tables - System::Admin CRUD, access-list, and block/unblock endpoints
System::Admin page to manage the marketplace allow-list: add/remove typed rules (specific user, whole instance, or email domain) with a live preview of who each rule would let in, and an open-to-everyone / restrict toggle.
Add the access audit section to the allow-list page: an audit list of everyone with effective access (filterable by status and granting rule), block/unblock controls per user, and the rule-match counts that flag allow-list rules which currently grant access to nobody.
A version IS its publication datetime, and its snapshot 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. Browse, preview and duplicate still read the mutable authoring copy — serving the snapshots to them is the next PR.
Deleting the source assessment now orphans the listing instead of destroying it: its snapshots survive, and the authoring copy is rebuilt in the container automatically, which returns the listing to the marketplace and lets a new version 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.
A link row is only ever read back through Course, which is acts_as_tenant :instance, so a row pointing into another instance resolves its course to nil for every later reader: the next duplication dies in Course::LessonPlan::Item#link_default_reference_time, and a plagiarism run either dies in Course::SsidFolderConcern#sync_assessment_ssid_folder or silently uploads that assessment's submissions to SSID. Filter at the single write site instead of at each reader, and against the destination course's instance rather than the current tenant -- marketplace publish, adoption and restore all run without a tenant, so there is none to compare against on exactly the paths this matters for. The filter judges the SOURCE of each candidate link and never the copy. A duplicate made in the same run lands in the destination course, so the links among the copies survive a cross-instance course duplication even though the links back to the originals do not; only a lone assessment moved across the boundary arrives with nothing. linkable_tree_id propagation is deliberately untouched: assessments imported from the marketplace keep the source's duplication root, so they stay comparable to it and to each other. This retires detach_from_link_tree!, whose linkable_tree_id reset was the part that broke that comparability. The server-side guard on update_assessment_links is deliberately not here: it needs a call on whether to mirror the picker's predicate exactly or check the instance only, and the strict reading breaks an existing spec.
…ction Deleting the source assessment of a versioned listing now clones its latest snapshot and re-points authoring_assessment inside the same transaction, instead of orphaning the listing and rebuilding it from an after_commit job. A destroy that fails afterwards unwinds the clone with it, so the listing is never observably orphaned and the rebuild no longer depends on the queue. The trade is deliberate: a failure inside the re-point now aborts the delete that triggered it, so a marketplace bug can stop somebody deleting their own course. That is preferred to a silent rescue, which would put back exactly the orphan state this removes. dependent: :nullify comes off has_one :marketplace_listing: Rails registers it as its own before_destroy, which would write NULL over the pointer the callback just set. The FK (fk_caml_authoring_assessment_id, ON DELETE SET NULL) remains the backstop for a listing with no version to clone from, and the callback is now the single Ruby writer of that column. RestoreAuthoringJob is deleted rather than kept as a manual retry. Nothing enqueued it once the after_commit hook went, no admin action ever reached it, and its guard Listing#restorable? requires orphaned? && current_version_id -- a pair the re-point makes unreachable, since a listing with a version no longer orphans and one without has nothing to restore from. Listing#restorable? goes with it. PurgeService now reclaims an authoring copy that lives in the container. That is the normal shape after a re-point, and once the listing is destroyed nothing references it, so it would leak into the container exactly as an unreclaimed snapshot would. An authoring copy in somebody's own course is still left alone. Also from review: - preview_instance matched host case-sensitively while the DB indexes lower(host) UNIQUE, and saves with validate: false, so a differently-cased row was invisible and then collided on insert. Now case-insensitive, and the loser of a concurrent insert re-reads instead of raising -- inside requires_new: true, because the re-point calls this from a before_destroy and a unique violation would otherwise abort the rescue's re-read with the transaction. - Noted why the listing show page still reads the authoring copy.
`courses.preview` marks exactly one thing -- the marketplace container course.
Its only writer is PreviewContainerService, it is absent from every strong
parameter list, and both readers (AssessmentMarketplaceAbilityComponent's
content freeze, Listing#marketplace_hosted?) answer as though the flag is
unique. Nothing made that true, so two concurrent first-publishes could leave
two containers and a lookup would then pick one arbitrarily.
A partial unique index on courses (instance_id) WHERE preview settles it. Per
instance rather than globally: the lookup already runs inside
ActsAsTenant.with_tenant, so the index matches the query exactly, and an
instance-local sandbox stays possible later.
This retires the title match in container_course. It was the workaround for a
non-unique flag and was itself unenforced -- a renamed container would have
been missed by the lookup and then provisioned a second time. PREVIEW_COURSE_
TITLE stays as the title that is written, just not as a key.
create_container_course gains the rescue create_preview_instance already had,
in both limbs: the model validation loses a genuine race, the index settles it.
Both inside requires_new: true, since the re-point reaches here from a
before_destroy and a unique violation would otherwise abort the rescue's own
re-read along with the caller's transaction.
Course gains a matching validation, scoped to :instance_id. The scope is
load-bearing rather than decorative -- Rails builds the uniqueness query from
`unscoped`, which strips acts_as_tenant's default scope, so a bare uniqueness
check would be global and stricter than the index.
Three specs created preview courses in the shared default instance, which this
suite commits (use_transactional_fixtures is false). They now each bring their
own instance. The example asserting that container_course ignores an unrelated
preview course goes: that course can no longer exist. In its place, the
invariant itself, through both the validation and the index.
course_spec's "valid when preview is true" now validates inside an instance of
its own. acts_as_tenant rewrites instance_id to the current tenant during
validation, so building elsewhere is not enough, and without this the example
would quietly depend on what the default instance held when it ran.
Existing test databases need their duplicates demoted before migrating:
UPDATE courses SET preview = false
WHERE preview AND id NOT IN (
SELECT DISTINCT ON (instance_id) id FROM courses WHERE preview
ORDER BY instance_id, id);
Specs commit (use_transactional_fixtures is false), so a bare per-second timestamp collides whenever two rspec processes start within the same second, tripping unique constraints. Append a random suffix per process.
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 rebuild is the failsafe half of the pair the in-transaction re-point introduced. Losing a source assessment re-points the listing inside the destroy transaction, so an orphan now means that callback was bypassed; this action is how an admin repairs one without a console. Its clone is extracted into RestoreAuthoringService, which Course::Assessment's before_destroy calls too, so a hand-repaired listing is indistinguishable from an automatically re-pointed one. RestoreAuthoringJob returns as a thin wrapper over that service, having been deleted along with the after_commit rebuild it used to back. A job rather than an inline call for the reason adoption's DuplicationJob is one: duplicating a large assessment can outlast a request. The re-point pays that cost inline only because a clone that must precede a destroy cannot be deferred.
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. A listing with no source assessment is chipped in the alarm colour on both surfaces, with a hint saying it should not happen and what to do about it. The re-point makes an orphan unreachable through the model layer, so one on screen is a fault worth naming rather than a state to be inferred from a missing link. The State filter carries it as a facet contributed only by the rows it describes, so the value appears just when there is something to look at -- unlike the marketplace-hosted pair, which every row completes.
LWS49
force-pushed
the
lws49/feat-marketplace-pr1-foundation
branch
2 times, most recently
from
August 2, 2026 15:35
9a88995 to
bbd43f0
Compare
Collaborator
Author
|
At staging (until commit 6eac0c4), features verified to be functioning as expected: Marketplace user-facing features
Marketplace Allowlist
Versioning
Marketplace Listing Overview
|
LWS49
force-pushed
the
lws49/feat-marketplace-pr1-foundation
branch
from
August 2, 2026 16:44
bbd43f0 to
6eac0c4
Compare
adi-herwana-nus
approved these changes
Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.













