Skip to content

feat(marketplace): add data model, migrations, and permissions - #8476

Merged
adi-herwana-nus merged 30 commits into
masterfrom
lws49/feat-marketplace-pr1-foundation
Aug 2, 2026
Merged

feat(marketplace): add data model, migrations, and permissions#8476
adi-herwana-nus merged 30 commits into
masterfrom
lws49/feat-marketplace-pr1-foundation

Conversation

@LWS49

@LWS49 LWS49 commented Jul 8, 2026

Copy link
Copy Markdown
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
LWS49 force-pushed the lws49/feat-marketplace-pr1-foundation branch 3 times, most recently from 833d37f to 9926c90 Compare July 8, 2026 04:05
@LWS49
LWS49 force-pushed the lws49/feat-ext-assessments-pr4-import branch 7 times, most recently from 19f4347 to e530ef0 Compare July 13, 2026 14:36
Base automatically changed from lws49/feat-ext-assessments-pr4-import to lws49/feat-gradebook-export July 13, 2026 14:40
Base automatically changed from lws49/feat-gradebook-export to master July 15, 2026 13:51
@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr1-foundation branch from 9926c90 to b34bd1d Compare July 17, 2026 08:06
@adi-herwana-nus

adi-herwana-nus commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Need to resolve merge conflicts (minor).
Otherwise, LGTM, this will be the base branch to merge the other marketplace features on top of.

@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr1-foundation branch from b34bd1d to 4e681d7 Compare July 29, 2026 10:26
LWS49 added 15 commits August 2, 2026 22:05
- 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.
LWS49 added 15 commits August 2, 2026 22:05
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
LWS49 force-pushed the lws49/feat-marketplace-pr1-foundation branch 2 times, most recently from 9a88995 to bbd43f0 Compare August 2, 2026 15:35
@LWS49

LWS49 commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

At staging (until commit 6eac0c4), features verified to be functioning as expected:

Marketplace user-facing features

  • Browsing and duplicating from marketplace (the ability to duplicate an assessment into a course is correctly guarded by the user's role in said course)
  • Previewing assesment and question details in marketplace
  • Notification of updates in listing in adopted assessments
  • Users deleting a listed assessment will be made aware in the deletion prompt prior to deletion
  • Number of adoption increments upon duplication from marketplace into a course
Screenshot 2026-08-02 at 11 51 17 PM Screenshot 2026-08-02 at 11 58 05 PM Screenshot 2026-08-02 at 11 58 22 PM Screenshot 2026-08-02 at 11 58 38 PM Screenshot 2026-08-02 at 11 51 03 PM Screenshot 2026-08-03 at 1 03 45 AM

Marketplace Allowlist

  • Displays all rules and current users with access
  • Allows adding rules enabling a specific user, users with an email domain, or users in an instance to access marketplace
  • Previews all users that will gain access to marketplace from adding a rule before confirmation
  • Allows individual users to be blocked from accessing marketplace
Screenshot 2026-08-02 at 11 54 59 PM Screenshot 2026-08-03 at 12 14 29 AM Screenshot 2026-08-02 at 11 57 14 PM

Versioning

  • Deleting a listed assessment will create an authoring copy in a dedicated container instance and course to hold versions of courses
  • System admins can publish new versions from authoring copies
  • System admins can view all versions stored in the container course
  • Container course tags each version with their published timestamps, and relevant tags "Latest", "Live", "Source Assessment" with on-hover tooltip for explanation
  • Container course had additional provenance fields displayed, and search and filtering functions
  • All other regular courses' views remains unchanged
  • Deleting a listing (irreversible, different from deleting a listed assessment) will delete all version copies stored and, if the authoring copy is in said container course, it will be deleted too
  • A warning banner renders for non-source assessments warning system admins that the assessment should not be modified, and links to the corresponding source assessment
Screenshot 2026-08-03 at 12 55 23 AM Screenshot 2026-08-03 at 12 54 18 AM Screenshot 2026-08-03 at 12 52 42 AM

Marketplace Listing Overview

  • Renders the correct fields, with actions "Open source assessment", List/Unlist, and delete available
  • Deleting a listing is correctly guarded by the listed being unlisted first
  • "Open source assessment" correctly links to original assessment
  • "Open source assessment" correctly switches to authoring copy in container course after original assessment is deleted
  • Provenance fields (original assessment, source course, instance) correctly renders links directing to them
  • Adoption fields are clickable and opens the detailed listing view, detailing version history and adoptions
  • "View version content" links correctly to the version cut in the container course
Screenshot 2026-08-02 at 11 50 45 PM Screenshot 2026-08-03 at 12 59 38 AM

@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr1-foundation branch from bbd43f0 to 6eac0c4 Compare August 2, 2026 16:44
@adi-herwana-nus
adi-herwana-nus merged commit 6d7ef13 into master Aug 2, 2026
14 checks passed
@adi-herwana-nus
adi-herwana-nus deleted the lws49/feat-marketplace-pr1-foundation branch August 2, 2026 17:19
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.

2 participants