diff --git a/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/fdd.md b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/fdd.md new file mode 100644 index 00000000000..4efc87718e0 --- /dev/null +++ b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/fdd.md @@ -0,0 +1,467 @@ +# Functional Design Document: Adaptive Page Duplication + +## 1. Executive Summary + +Adaptive page duplication needs a dedicated authoring-path implementation because adaptive pages do not behave like ordinary page duplication. The page revision stores an ordered deck of screen references, while each duplicated adaptive screen may also contain resource-id-bearing references that must be remapped after copy. The design in this document introduces a standalone server-side module, `AdaptiveDuplication`, that performs a transactional, set-based duplication of an adaptive page and all referenced adaptive screens, then rewrites resource-id references before the new page is attached back into the project. + +The key design choice is to treat "single query" as "single bulk phase per table and rewrite step," not "one SQL statement for the entire workflow." A literal one-statement copy across `resources`, `project_resources`, and `revisions` is possible with hand-written SQL CTEs, but it would be harder to maintain and reason about than a single transaction containing a small number of set-based inserts and updates. The accepted design therefore uses one transaction with one bulk insert into `resources`, one bulk insert into `project_resources`, one bulk insert into `revisions`, one bulk insert into `published_resources` for the current working publication, one bulk update for changed duplicated screen revisions, one bulk insert triplet for the page copy, and one update for the new page revision. This satisfies the performance intent behind FR-003 and AC-004 while keeping the implementation auditable. + +This design fulfills FR-001, FR-002, FR-003, FR-004, FR-005, and FR-006. Acceptance criteria are covered explicitly through the algorithm, rewrite surface inventory, transaction boundaries, feature gating, and verification strategy in AC-001 through AC-010. + +## 2. Requirements & Assumptions + +### 2.1 Requirements Snapshot + +- FR-001: Adaptive page duplication must create a new adaptive page plus duplicated adaptive screens rather than pointing at the original screens. +- FR-002: Page-level sequence mapping must remain valid after duplication. +- FR-003: Duplication must use bulk, set-based persistence rather than N-per-screen inserts. +- FR-004: Adaptive screen content must be remapped anywhere duplicated screen resource ids are referenced. +- FR-005: The capability must be gated behind an adaptive authoring feature flag. +- FR-006: Existing non-adaptive page duplication behavior must remain unchanged. + +### 2.2 Assumptions + +- The entry point receives a `project` and adaptive page `resource_id`, per product direction, and resolves the current authoring-head page revision from that resource. +- The source page is an authoring-page resource whose content has `advancedDelivery: true`, `model[0].type = "group"`, and `model[0].layout = "deck"`. If not, the adaptive duplication module exits with a structured non-adaptive error and the caller falls back to the existing path. +- The source adaptive page references adaptive screens through `activity-reference` children whose `activity_id` values are screen resource ids and whose `custom.sequenceId` / `custom.sequenceName` values are retained unchanged in the duplicate. +- The user-visible requirement is bulk duplication, not a hard requirement for a single SQL statement across all touched tables. The implementation should favor deterministic set-based inserts inside one transaction. +- Only authoring-head resources in the same project are in scope. Published artifacts, section attempts, and delivery data are unaffected. +- This FDD assumes the canary rollout feature `adaptive_duplication` described in the PRD remains the gating mechanism. + +## 3. Repository Context Summary + +Adaptive pages are represented as deck-layout pages whose sequence list lives in page JSON. The server already extracts this page-level screen mapping in `Oli.Conversation.AdaptivePageContextBuilder`, and the delivery path resolves the deck children by `activity-reference.activity_id` via `Oli.Delivery.ActivityProvider`. On the client side, adaptive navigation and state scoping primarily use `sequenceId`, while server-side loading, attempts, and cross-screen evaluation still rely on actual screen `resource_id` values. + +The current generic duplication path in `Oli.Authoring.Editing.ContainerEditor` deep-copies `activity-reference` children one by one through existing activity creation helpers. That approach is acceptable for ordinary pages, but it is the wrong primitive for adaptive duplication because it does not provide an efficient old-to-new screen resource map, and it makes it difficult to perform a full second-pass remap of adaptive-only resource-id references. + +The repository already contains strong precedents for the required mechanics: + +- bulk `insert_all` resource and revision creation patterns in `Oli.Interop.Ingest.Processor.Common` +- internal activity-reference rewiring logic in `Oli.Interop.Ingest.Processor.InternalActivityRefs` +- adaptive link and page-link rewiring logic in `Oli.Interop.Ingest.Processor.Rewiring` and `Oli.Interop.RewireLinks` + +Those patterns justify a dedicated duplication module that combines authoring-specific duplication orchestration with existing or extracted JSON rewiring helpers. + +## 4. Proposed Design + +### 4.1 Module Boundary + +Introduce a standalone server-side module under the authoring editing layer: + +- `Oli.Authoring.Editing.AdaptiveDuplication` + +This module owns adaptive-page-specific duplication only. It is invoked by the existing page duplication flow after the caller determines that the target page is adaptive and the feature flag is enabled. The existing duplication path remains the default for non-adaptive pages, satisfying FR-006 and AC-010. + +### 4.2 Top-Level API + +Expose a single top-level function: + +```elixir +duplicate(project, adaptive_page_resource_id, opts \\ []) +``` + +Expected responsibilities: + +- resolve the source page revision from the provided page resource id +- validate adaptive shape and feature-flag eligibility +- duplicate all referenced adaptive screens in bulk +- remap duplicated screen revisions in bulk where needed +- duplicate the page resource and rewrite its sequence map +- attach the duplicated page into the project using the existing authoring/container mechanisms +- return the duplicated page revision or a structured error + +The API intentionally takes a page resource id rather than a page revision id because the operation is conceptually "duplicate this adaptive page in the project," not "duplicate this historical revision." That keeps the contract aligned with the user workflow and with AC-001. + +### 4.3 Algorithm Overview + +The duplication algorithm is: + +1. Resolve and validate the source page. +2. Read page content and collect the ordered `activity-reference` children from the deck. +3. Bulk duplicate the referenced screen resources and their head revisions, producing an `old_resource_id => new_resource_id` map. +4. Fetch the newly duplicated screen revisions, rewrite any duplicated-screen resource references in memory, and bulk update only the revisions whose content changed. +5. Duplicate the adaptive page resource and initial revision. +6. Rewrite the duplicated page revision so its `activity-reference.activity_id` values and any other duplicated-screen resource references point at the new screen resource ids. +7. Attach the new page into the project/container and return it. + +All steps occur in one transaction. Any mismatch in inserted row counts, missing source screen revisions, or invalid rewrite state causes rollback, satisfying AC-004 and AC-009. + +### 4.4 Step 1: Resolve And Validate Source Page + +The module loads the authoring-head page revision for the supplied page resource id and verifies: + +- the resource belongs to the given project +- the feature flag is enabled for the project and actor context, per FR-005 and AC-008 +- the page content is adaptive (`advancedDelivery: true`) +- the top-level model is a deck group + +If any validation fails, the module returns an explicit error and does not mutate storage. + +### 4.5 Step 2: Extract Ordered Screen References + +The module traverses the page content and extracts each deck child with: + +- `type == "activity-reference"` +- `activity_id` +- `custom.sequenceId` +- `custom.sequenceName` + +The extracted list is used in two ways: + +- as the authoritative ordered screen listing for rebuilding the duplicated page content +- as the source of the unique screen resource ids to duplicate + +`sequenceId` and `sequenceName` are preserved as-is. They are the runtime screen key and display label respectively, and there is no evidence that adaptive duplication requires regenerating them. Preserving them avoids unnecessary churn and satisfies FR-002 and AC-002. + +### 4.6 Step 3: Bulk Duplicate Adaptive Screens + +This step replaces the current per-screen `ActivityEditor.create` style behavior with a set-based bulk duplication phase. + +#### Accepted approach + +Inside the transaction, perform: + +- one bulk insert into `resources` for all duplicated screens +- one bulk insert into `project_resources` for the new screen resources +- one bulk insert into `revisions` for the duplicated initial screen revisions +- one bulk insert into `published_resources` for the current working publication so standard authoring resolution can load the duplicated screens + +Each inserted revision initially carries content identical to the source screen revision. No remapping is done in the insert payload itself. The primary output of this phase is: + +- `screen_resource_map :: %{old_screen_resource_id => new_screen_resource_id}` + +Secondary outputs may include: + +- `screen_revision_map :: %{old_screen_resource_id => new_screen_revision_id}` +- ordered tuples binding source screen ids to duplicated revision ids for later bulk update assembly + +#### Why not a literal one-statement SQL copy + +Three-table insertion with generated ids and deterministic old-to-new pairing is feasible with raw SQL CTEs, but it adds complexity without changing the observable requirements. The design requirement that matters is AC-004: avoid N-per-screen insert/update behavior. A transactional, set-based three-query copy phase is the maintainable interpretation of that requirement. + +### 4.7 Step 4: Remap Duplicated Screen Revisions + +After the duplicated screen revisions exist, the module loads their content and rewrites any resource-id-bearing fields that can point at duplicated adaptive screens. Only changed revisions are included in the bulk update set. This satisfies FR-004 and AC-005. + +#### Rewrite surfaces in adaptive screen content + +The remapper must inspect, at minimum, these fields: + +1. `authoring.flowchart.paths[*].destinationScreenId` + This is an authored screen-to-screen navigation target stored as a resource id. If it points to one of the original duplicated screens, it must be replaced with the mapped new resource id. + +2. `authoring.activitiesRequiredForEvaluation[*]` + This compiled evaluation dependency list stores screen resource ids, not `sequenceId` values. Any entry pointing at a duplicated original screen must be rewritten. + +3. Adaptive rich-text or part-link nodes that store internal resource references under `idref` + This includes authored links represented as nodes such as `%{"tag" => "a", "idref" => ...}` in adaptive authoring structures. + +4. Adaptive node payloads and embedded components that store internal resource references under `resource_id` + This includes known internal page/screen link payloads and iframe-style adaptive nodes where the resource-bearing field is `resource_id`. + +5. Any nested `activity-reference.activity_id` nodes if an adaptive activity payload happens to embed them + This is defensive and keeps the remapper robust even if adaptive authoring grows additional nested structures later. + +The module should not rewrite: + +- `custom.sequenceId` +- `custom.sequenceName` +- runtime-only delivery state keys +- arbitrary ids that are not project resource ids + +#### Reuse vs extraction + +Where practical, the JSON rewiring helpers already used by interop ingest should be extracted or wrapped rather than reimplemented. That is the preferred implementation path because it reduces drift across authoring paths. The FDD therefore recommends a small internal remapping helper layer shared with or derived from: + +- internal activity-ref rewiring +- adaptive link/page-link rewiring + +### 4.8 Step 5: Duplicate And Rewrite The Adaptive Page + +Once the duplicated screens are fully remapped, the module duplicates the page resource itself: + +- insert new page `resource` +- insert new page `project_resource` +- insert new initial page `revision` + +The new page revision is initially copied from the source page revision unchanged. The module then rewrites the duplicated page revision content so that: + +1. every deck child `activity-reference.activity_id` pointing at an original screen now points at the duplicated screen resource id +2. any other nested resource-id-bearing references in page content that point at duplicated screens are also rewritten + +The page rewrite is the authoritative place where the adaptive page’s screen-to-sequence listing is re-established for the duplicate, satisfying FR-001, FR-002, AC-001, AC-002, and AC-006. + +### 4.9 Page-Level Rewrite Surfaces + +The page remapper must inspect, at minimum, these locations in page content: + +1. `model[0].children[*]` where `type == "activity-reference"` + Rewrite `activity_id` using the screen resource map. + +2. Nested adaptive link structures under rich-text or custom nodes storing duplicated-screen references as `idref` + Rewrite only when the referenced resource id appears in the screen resource map. + +3. Nested adaptive component payloads storing duplicated-screen references as `resource_id` + Rewrite only when the referenced resource id appears in the screen resource map. + +The page remapper preserves: + +- deck order +- `custom.sequenceId` +- `custom.sequenceName` + +### 4.10 Container Integration + +The adaptive duplication module should return a new page revision ready for insertion into the authoring container. The existing higher-level duplication flow remains responsible for deciding where in the container tree the duplicate is placed and for emitting any existing authoring notifications or broadcasts. + +This keeps the adaptive module focused on duplication correctness rather than UI placement concerns. + +## 5. Interfaces + +### 5.1 Primary Interface + +`Oli.Authoring.Editing.AdaptiveDuplication.duplicate/3` + +Inputs: + +- `%Project{}` +- adaptive page `resource_id` +- optional execution context, such as actor or placement metadata + +Outputs: + +- `{:ok, duplicated_page_revision, metadata}` +- `{:error, reason}` + +Suggested metadata: + +- duplicated page resource id +- duplicated screen count +- changed duplicated screen revision count +- `old_to_new_screen_resource_ids` + +### 5.2 Internal Helper Interfaces + +Recommended helper seams: + +- `extract_adaptive_screen_refs(page_content)` +- `bulk_duplicate_resources_and_revisions(project, source_resource_ids, source_revisions, type)` +- `remap_adaptive_screen_content(content, screen_resource_map)` +- `remap_adaptive_page_content(content, screen_resource_map)` +- `bulk_update_revision_contents(changed_revision_updates)` + +These helpers may remain private to the module or move into dedicated persistence/rewiring collaborators if the implementation becomes large. + +## 6. Data Model & Storage + +No new tables are required. The feature operates against existing authoring tables: + +- `resources` +- `project_resources` +- `revisions` +- `published_resources` + +The key storage behavior is: + +- source screen resources remain unchanged +- new screen resources are created in the same project +- new screen revisions initially copy source content, then only the changed duplicated revisions are updated +- new duplicated screen revisions are also added to the current working publication mappings used by authoring resolution +- the new page resource and revision are created after the screen duplication/remap phase succeeds + +This design preserves the existing resource/revision model and avoids any schema migration. + +### 6.1 Stored Identity Rules + +- `sequenceId` remains page-content metadata and is preserved verbatim. +- `sequenceName` remains page-content metadata and is preserved verbatim. +- actual adaptive screen identity for persistence and evaluation remains the duplicated screen `resource_id`. + +That distinction is critical because server evaluation and delivery lookup paths depend on real resource ids, not only on sequence ids. + +## 7. Consistency & Transactions + +The entire workflow runs inside one `Repo.transaction`. + +Transactional guarantees: + +- no duplicated screen is left orphaned without its page if the workflow fails +- no rewritten screen revisions are committed unless the page duplicate also succeeds +- no page duplicate is committed if any screen mapping or rewrite step fails + +Validation checks inside the transaction should include: + +- inserted screen resource count equals unique source screen count +- inserted screen revision count equals unique source screen count +- every source screen id has a mapped duplicated screen id +- every duplicated screen revision slated for update still belongs to the expected duplicated resource +- page duplicate exists before the page rewrite update is issued + +Any mismatch must raise or return an error that causes rollback. This directly supports AC-004, AC-005, AC-006, and AC-009. + +## 8. Caching Strategy + +No cache layer is introduced or required. + +The operation is an authoring-side write transaction with deterministic inputs. All necessary source content should be read directly from the database at the start of the transaction. Existing cache behavior elsewhere in the application does not need special invalidation logic beyond whatever current authoring revision updates already trigger. + +## 9. Performance & Scalability Posture + +The design explicitly avoids the current N-per-screen duplication pattern. Performance posture is: + +- one read phase for the source page and source screen revisions +- one set-based insert phase for duplicated screen resources and revisions +- one set-based update phase for changed duplicated screen revisions only +- one set-based insert phase for the duplicated page resource and revision +- one update for the duplicated page revision content + +This is expected to scale predictably with the number of screens in an adaptive page and to materially outperform per-screen duplication, satisfying FR-003 and AC-004. + +The implementation should preserve source screen order in memory, but all persistence work should be set-based. If ordering is needed to pair returned resource ids with source ids, the insert payload should carry a deterministic source-order index in memory rather than relying on implicit database ordering. + +## 10. Failure Modes & Resilience + +Primary failure modes: + +- non-adaptive page passed to adaptive duplication +- feature flag disabled +- page references a screen resource whose head revision cannot be resolved +- screen duplication insert counts do not match expectations +- remapper encounters malformed content +- bulk update affects fewer rows than expected +- page duplication insert/update fails + +Resilience posture: + +- fail fast before any writes when validation errors are known up front +- fail closed and roll back the full transaction on persistence mismatch +- include enough error detail for logs and tests without exposing internals to the authoring UI + +Malformed content should be treated as a hard duplication error rather than silently producing a partially broken duplicate. + +## 11. Observability + +No feature-specific telemetry, metrics, or dashboard work is required for this feature. + +The implementation may continue to rely on standard application error handling and existing logs produced by the surrounding authoring stack, but adaptive duplication does not introduce any dedicated observability contract and should not add telemetry as part of scope. + +## 12. Security & Privacy + +The feature operates entirely on authoring content already accessible to project authors. No new privacy boundary is introduced. + +Security expectations: + +- authorize duplication against the project and page resource before mutation +- scope all copied resources to the same project +- never duplicate or rewrite resources outside the resolved source project +- do not follow arbitrary external ids embedded in content; only rewrite resource ids that are present in the old-to-new duplicated screen map + +Feature gating through the scoped flag is also part of the safety posture because it limits rollout while the adaptive-only path is verified. + +## 13. Testing Strategy + +Testing should focus on remapping correctness, transactional rollback, and non-adaptive regression coverage. + +### 13.1 Unit And Integration Coverage + +- unit tests for adaptive page screen-ref extraction +- unit tests for page-content remapping of `activity-reference.activity_id` +- unit tests for screen-content remapping of `authoring.flowchart.paths[*].destinationScreenId` +- unit tests for screen-content remapping of `authoring.activitiesRequiredForEvaluation[*]` +- unit tests for adaptive nested `idref` and `resource_id` rewrite surfaces +- integration tests for full adaptive page duplication producing a new page plus new screen resources +- transaction rollback tests when one bulk phase fails +- regression tests proving ordinary page duplication still uses the existing path unchanged + +### 13.2 Acceptance Criteria Coverage + +- AC-001: duplicated adaptive page has a new page resource and duplicated screen resources +- AC-002: duplicated page retains the same ordered `sequenceId` / `sequenceName` listing while pointing at new screen resource ids +- AC-003: source page and source screens remain unchanged +- AC-004: duplication performs set-based persistence rather than per-screen inserts +- AC-005: duplicated screen revisions rewrite all known duplicated-screen resource references +- AC-006: duplicated page revision rewrites all page-level screen references +- AC-007: attempts and delivery for the duplicate resolve through the duplicated screen resource ids +- AC-008: feature flag gates the adaptive path +- AC-009: any failure rolls back all adaptive duplication writes +- AC-010: non-adaptive duplication behavior remains unchanged + +### 13.3 Delivery-Facing Confidence Checks + +Although the feature is authoring-side, one end-to-end confidence test should confirm that a duplicated adaptive page still builds adaptive context and can resolve the duplicated screen sequence through the standard delivery and conversation builders. That specifically protects AC-007. + +## 14. Backwards Compatibility + +The design is additive and isolated. + +- non-adaptive duplication keeps using the existing path +- adaptive duplication is gated behind a feature flag +- no schema changes are required +- no published content or delivery attempts are mutated + +This keeps rollout risk low while preserving existing authoring behavior. + +## 15. Risks & Mitigations + +### 15.1 Risk: Incomplete Rewrite Surface Inventory + +Adaptive content may contain additional resource-id-bearing structures beyond the known fields. + +Mitigation: + +- centralize remapping logic in one module +- reuse existing interop rewiring helpers where possible +- add targeted tests from known adaptive payload fixtures +- fail closed on malformed or ambiguous structures + +### 15.2 Risk: Over-Literal "Single Query" Interpretation + +A hand-written SQL mega-query could become fragile and opaque. + +Mitigation: + +- define success in terms of set-based, non-N-plus-1 persistence +- keep the workflow transactional +- use a small number of bulk queries with explicit row-count assertions + +### 15.3 Risk: Sequence Identity Drift + +Regenerating `sequenceId` values would break navigation and state assumptions. + +Mitigation: + +- preserve `sequenceId` and `sequenceName` verbatim during duplication +- rewrite only real resource-id-bearing fields + +### 15.4 Risk: Hidden Cross-Screen Evaluation Breakage + +If `activitiesRequiredForEvaluation` is not rewritten, server evaluation could look up the original screens. + +Mitigation: + +- treat that field as a first-class required rewrite surface +- include explicit tests for multi-screen rule evaluation dependencies + +## 16. Open Questions & Follow-ups + +- Should the final implementation extract shared JSON rewiring helpers from interop into a more neutral module, or should adaptive duplication call those helpers through a thin compatibility wrapper? +- Are there any adaptive activity payload variants in production that store duplicated-screen references under additional keys beyond `destinationScreenId`, `activity_id`, `idref`, and `resource_id`? +- Should the module attach the duplicated page into the container directly, or should that remain fully owned by the caller after the duplication transaction returns? +- If future requirements allow duplicating only a subset of adaptive screens, the current all-screens copy contract would need a broader mapping model. That is out of scope here. + +## 17. References + +- `docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/prd.md` +- `docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/requirements.yml` +- `lib/oli/authoring/editing/container_editor.ex` +- `lib/oli/conversation/adaptive_page_context_builder.ex` +- `lib/oli/delivery/activity_provider.ex` +- `lib/oli/delivery/attempts/activity_lifecycle/evaluate.ex` +- `lib/oli/interop/ingest/processor/common.ex` +- `lib/oli/interop/ingest/processor/internal_activity_refs.ex` +- `lib/oli/interop/ingest/processor/rewiring.ex` +- `lib/oli/interop/rewire_links.ex` +- `test/oli/conversation/adaptive_page_context_builder_test.exs` +- `test/oli/interop/rewire_links_test.exs` +- `assets/src/apps/authoring/store/groups/layouts/deck/actions/updateActivityRules.ts` +- `assets/src/apps/delivery/store/features/groups/actions/deck.ts` diff --git a/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_1_execution_record.md b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_1_execution_record.md new file mode 100644 index 00000000000..bcefef773b1 --- /dev/null +++ b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_1_execution_record.md @@ -0,0 +1,40 @@ +# Phase 1 Execution Record + +Work item: `docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication` +Phase: `1` + +## Scope from plan.md +- Create the adaptive-specific entry path without changing non-adaptive duplication behavior. +- Add the scoped feature-flag gate in curriculum actions and in the server-side duplication branch. +- Scaffold the adaptive duplication module interface and structured error contract. + +## Implementation Blocks +- [x] Core behavior changes +- [x] Data or interface changes +- [x] Access-control or safety checks +- [x] Observability or operational updates when needed + +## Test Blocks +- [x] Tests added or updated +- [x] Required verification commands run +- [x] Results captured + +## Work-Item Sync +- [x] PRD, FDD, and plan updated when implementation diverged +- [x] Open questions added to docs when needed + +## Review Loop +- Round 1 findings: + - `Actions.render/1` required the new `project` assign but some existing callers still passed only `project_slug`. +- Round 1 fixes: + - Updated the workspace curriculum entry and both pages table models to pass the full project assign through to `Actions.render/1`. +- Round 2 findings (optional): + - No additional actionable findings from the repository review-guideline pass. +- Round 2 fixes (optional): + - None. + +## Done Definition +- [x] Phase tasks complete +- [x] Tests and verification pass +- [x] Review completed when enabled +- [x] Validation passes diff --git a/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_2_execution_record.md b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_2_execution_record.md new file mode 100644 index 00000000000..0e3a483e624 --- /dev/null +++ b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_2_execution_record.md @@ -0,0 +1,43 @@ +# Phase 2 Execution Record + +Work item: `docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication` +Phase: `2` + +## Scope from plan.md +- Implement source-page validation and ordered extraction of adaptive screen refs from the deck page content. +- Implement the bulk duplication phase for adaptive screens and produce deterministic old-to-new resource and revision mappings. +- Add row-count assertions and rollback-safe behavior for missing source revisions and partial-write protection. + +## Implementation Blocks +- [x] Core behavior changes +- [x] Data or interface changes +- [x] Access-control or safety checks +- [x] Observability or operational updates when needed + +## Test Blocks +- [x] Tests added or updated +- [x] Required verification commands run +- [x] Results captured + +## Work-Item Sync +- [x] PRD, FDD, and plan updated when implementation diverged +- [x] Open questions added to docs when needed + +## Review Loop +- Round 1 findings: + - The new test helper referenced `Publishing.get_unpublished_publication_id!/1` without the `Oli.Publishing` alias. + - Screen slug generation used a piped `Slug.generate/2` call with reversed arguments, which caused slug-table lookup failures. +- Round 1 fixes: + - Added the missing `Oli.Publishing` alias in the test module. + - Corrected `generate_screen_slugs/1` to call `Slug.generate("revisions", titles)` with the proper argument order. +- Round 2 findings (optional): + - The implementation needed working-publication `published_resources` rows for duplicated screens so `AuthoringResolver` could resolve them in later phases. +- Round 2 fixes (optional): + - Added bulk `published_resources` insertion to the screen duplication engine. + - Synced the FDD and plan to reflect the working-publication mapping requirement. + +## Done Definition +- [x] Phase tasks complete +- [x] Tests and verification pass +- [x] Review completed when enabled +- [x] Validation passes diff --git a/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_3_execution_record.md b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_3_execution_record.md new file mode 100644 index 00000000000..6af73da4254 --- /dev/null +++ b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_3_execution_record.md @@ -0,0 +1,43 @@ +# Phase 3 Execution Record + +## Scope Delivered + +Phase 3 completed the adaptive duplication transaction end-to-end: + +- duplicated adaptive screen resources are remapped in-place after bulk copy +- duplicated adaptive page resources are created and their deck references are remapped +- duplicated adaptive pages are attached back into the requested container +- curriculum duplication flows now succeed for adaptive pages when the feature flag is enabled + +## Implementation Notes + +- `lib/oli/authoring/editing/adaptive_duplication.ex` + - completed `duplicate/3` so the transaction now: + - bulk duplicates referenced adaptive screens + - bulk rewrites only changed duplicated screen revisions + - creates the duplicated adaptive page resource/revision + - rewrites duplicated page content to the new screen resource ids + - attaches the duplicated page to the destination container + - added focused remap helpers for: + - `authoring.flowchart.paths[*].destinationScreenId` + - `authoring.activitiesRequiredForEvaluation[*]` + - nested `activity-reference.activity_id` + - nested adaptive `idref` + - nested adaptive iframe `resource_id` / `idref` + - implemented a single bulk SQL update for heterogeneous revision content rewrites + +- Tests updated: + - `test/oli/authoring/editing/adaptive_duplication_test.exs` + - `test/oli/editing/container_editor_test.exs` + - `test/oli_web/live/curriculum/container_test.exs` + - `test/oli_web/live/workspaces/course_author/curriculum_live_test.exs` + +## Verification + +- `mix format lib/oli/authoring/editing/adaptive_duplication.ex test/oli/authoring/editing/adaptive_duplication_test.exs test/oli/editing/container_editor_test.exs test/oli_web/live/curriculum/container_test.exs test/oli_web/live/workspaces/course_author/curriculum_live_test.exs` +- `mix test test/oli/authoring/editing/adaptive_duplication_test.exs test/oli/editing/container_editor_test.exs test/oli_web/live/curriculum/container_test.exs test/oli_web/live/workspaces/course_author/curriculum_live_test.exs` + +## Follow-Up + +- No additional telemetry work was added. +- No work-item doc drift was introduced beyond recording execution outcomes. diff --git a/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_4_execution_record.md b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_4_execution_record.md new file mode 100644 index 00000000000..3cc433c3856 --- /dev/null +++ b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/phase_4_execution_record.md @@ -0,0 +1,41 @@ +# Phase 4 Execution Record + +## Scope Delivered + +Phase 4 closed the feature with targeted verification and rollout-readiness evidence: + +- added explicit independence coverage showing a duplicated adaptive screen can be edited without mutating the original +- added failure-path curriculum coverage showing adaptive duplication surfaces a user-facing error when duplication fails +- reran the targeted backend and LiveView suites for the adaptive duplication slice +- revalidated the work item after the final verification updates + +## Verification Additions + +- `test/oli/authoring/editing/adaptive_duplication_test.exs` + - verifies duplicated adaptive screens remain independent from originals after a post-duplication edit +- `test/oli_web/live/curriculum/container_test.exs` + - verifies a broken adaptive page duplication attempt shows the authoring error flash +- `test/oli_web/live/workspaces/course_author/curriculum_live_test.exs` + - verifies the course author curriculum surface also shows the authoring error flash + +## Automated Verification + +- `mix format lib/oli/authoring/editing/adaptive_duplication.ex test/oli/authoring/editing/adaptive_duplication_test.exs test/oli/editing/container_editor_test.exs test/oli_web/live/curriculum/container_test.exs test/oli_web/live/workspaces/course_author/curriculum_live_test.exs` +- `mix test test/oli/authoring/editing/adaptive_duplication_test.exs test/oli/editing/container_editor_test.exs test/oli_web/live/curriculum/container_test.exs test/oli_web/live/workspaces/course_author/curriculum_live_test.exs` +- `python3 /Users/darren/.local/share/harness/skills/validate/scripts/validate_work_item.py docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication --check all` + +## Manual Verification Notes + +Manual browser-driven authoring verification was not performed in this CLI environment. The recommended guarded-rollout checklist remains: + +- duplicate an adaptive page from curriculum authoring with the feature flag enabled +- confirm the duplicated page title uses the standard copy naming convention +- confirm the duplicated page deck points only at duplicated adaptive screen resources +- edit a duplicated adaptive screen and confirm the original screen is unchanged +- attempt duplication of a deliberately broken adaptive page and confirm no duplicate remains and the author sees the existing failure flash + +## Release Readiness + +- feature remains guarded behind `adaptive_duplication` +- no telemetry or observability work was added +- no work-item doc drift required reconciliation beyond this execution record diff --git a/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/plan.md b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/plan.md new file mode 100644 index 00000000000..a4bdd13da24 --- /dev/null +++ b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/plan.md @@ -0,0 +1,120 @@ +# Adaptive Duplication - Delivery Plan + +Scope and reference artifacts: +- PRD: `docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/prd.md` +- FDD: `docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/fdd.md` + +## Scope +Implement adaptive page duplication as a feature-flagged authoring workflow that duplicates an adaptive page, bulk-copies its referenced adaptive screens, remaps duplicated resource references, and preserves existing non-adaptive duplication behavior. + +Guardrails: +- keep the existing non-adaptive duplication path intact (`FR-006`, `AC-010`) +- preserve `custom.sequenceId` and `custom.sequenceName` on the duplicated page (`FR-004`, `AC-005`) +- use set-based persistence inside one transaction rather than per-screen inserts (`FR-003`, `AC-004`) +- fail closed on unresolved or malformed adaptive references (`FR-005`, `AC-008`, `AC-009`) +- do not add feature-specific telemetry or observability work for this item +- do not introduce schema changes, migrations, or cross-project duplication behavior + +## Clarifications & Default Assumptions +- The adaptive path will be introduced behind the canary rollout feature `adaptive_duplication`; when the rollout stage is `off`, current UI and backend behavior remain unchanged (`FR-001`, `AC-001`, `AC-002`). +- The new module boundary is `Oli.Authoring.Editing.AdaptiveDuplication`, with `ContainerEditor.duplicate_page/4` remaining the caller and non-adaptive fallback. +- "Single query" is implemented as a small number of set-based bulk insert/update queries inside one `Repo.transaction`, not as a single hand-written SQL statement across all touched tables. +- The first implementation will target the rewrite surfaces named in the FDD: page `activity-reference.activity_id`, screen `destinationScreenId`, `activitiesRequiredForEvaluation`, and nested `idref` / `resource_id` references (`FR-003`, `FR-004`, `AC-004`, `AC-006`). +- Scenario coverage is not planned initially; targeted ExUnit and LiveView tests are the expected confidence level unless implementation reveals workflow risk that cannot be covered there. + +## Phase 1: Feature Gate And Call Path +- Goal: Create the adaptive-specific entry path without changing non-adaptive duplication behavior. +- Tasks: + - [ ] Add a scoped feature-flag check for adaptive duplication at the curriculum action affordance and at the server-side duplication branch (`FR-001`, `FR-006`, `AC-001`, `AC-002`, `AC-010`). + - [ ] Update `ContainerEditor.duplicate_page/4` to detect adaptive pages and delegate to `Oli.Authoring.Editing.AdaptiveDuplication`, while preserving the current path for non-adaptive pages (`FR-006`, `AC-010`). + - [ ] Scaffold the adaptive duplication module interface and structured error contract expected by the caller (`FR-005`, `AC-008`, `AC-009`). +- Testing Tasks: + - [ ] Add or update LiveView coverage for duplicate action visibility with the feature flag on and off (`AC-001`, `AC-002`). + - [ ] Add a regression test proving non-adaptive duplication still uses existing behavior (`AC-010`). + - [ ] Command(s): `mix test test/oli_web/live/curriculum/container_test.exs test/oli/editing/container_editor_test.exs` +- Definition of Done: + - Adaptive pages are routed to a dedicated backend branch only when the feature flag is enabled. + - Non-adaptive duplication behavior and tests remain unchanged. +- Gate: + - The adaptive path is reachable only behind the flag and does not regress non-adaptive duplication. +- Dependencies: + - None. +- Parallelizable Work: + - LiveView affordance updates and backend branch scaffolding can proceed in parallel once the feature-flag contract is agreed. + +## Phase 2: Bulk Screen Duplication Engine +- Goal: Implement the transactional, set-based duplication of adaptive screen resources and revisions and produce the old-to-new resource mapping (`FR-002`, `FR-003`, `AC-003`, `AC-004`). +- Tasks: + - [ ] Implement source-page validation and ordered extraction of adaptive screen refs from the deck page content (`FR-002`, `AC-003`, `AC-005`). + - [ ] Implement the bulk duplication phase for adaptive screens: set-based inserts into `resources`, `project_resources`, `revisions`, and current working-publication `published_resources`, all inside one transaction (`FR-003`, `AC-003`, `AC-004`). + - [ ] Return deterministic mapping structures for source screen resource ids to duplicated screen resource ids and duplicated revision ids (`FR-002`, `FR-003`, `AC-003`, `AC-004`). + - [ ] Add row-count and ownership assertions that force rollback on any mismatch (`FR-005`, `AC-008`). +- Testing Tasks: + - [ ] Add backend tests for successful duplication of an adaptive page with multiple screens, verifying new screen resources and unchanged originals (`AC-003`, `AC-007`). + - [ ] Add rollback tests for missing source revisions or insert-count mismatches (`AC-008`). + - [ ] Command(s): `mix test test/oli/authoring/editing/adaptive_duplication_test.exs` +- Definition of Done: + - The module can duplicate all referenced adaptive screens in one transaction and produce a complete old-to-new screen map. + - Any persistence mismatch causes rollback with no partial duplicate left behind. +- Gate: + - Bulk duplication is set-based and transactionally safe before any remapping logic is layered on. +- Dependencies: + - Phase 1. +- Parallelizable Work: + - Test fixture creation for adaptive source pages can proceed alongside the bulk insert implementation. + +## Phase 3: Screen Remapping And Page Finalization +- Goal: Rewrite duplicated adaptive screen/page content to point exclusively at duplicated resources, then finalize the duplicated page (`FR-002`, `FR-004`, `FR-005`, `AC-004`, `AC-005`, `AC-006`, `AC-009`). +- Tasks: + - [ ] Implement screen-content remapping for `authoring.flowchart.paths[*].destinationScreenId`, `authoring.activitiesRequiredForEvaluation[*]`, and nested `idref` / `resource_id` surfaces using the screen resource map (`FR-004`, `AC-006`). + - [ ] Reuse or extract existing interop rewiring helpers where practical rather than duplicating traversal logic (`FR-004`, `AC-006`). + - [ ] Bulk update only the duplicated screen revisions whose content changed (`FR-003`, `FR-004`, `AC-004`, `AC-006`). + - [ ] Duplicate the adaptive page resource/revision and rewrite page `activity-reference.activity_id` plus any nested duplicated-screen resource refs, preserving deck order, `sequenceId`, and `sequenceName` (`FR-002`, `FR-004`, `AC-004`, `AC-005`). + - [ ] Reattach the duplicated page through the existing container flow and propagate a user-facing failure when the adaptive duplication transaction aborts (`FR-005`, `AC-009`). +- Testing Tasks: + - [ ] Add targeted remapper tests for each known rewrite surface in screen and page content (`AC-004`, `AC-006`). + - [ ] Add an end-to-end authoring-side duplication test proving the duplicated page references only duplicated screens and preserves sequence metadata (`AC-004`, `AC-005`, `AC-006`). + - [ ] Add failure-path tests ensuring no duplicated page entry or screens survive a remap failure (`AC-008`, `AC-009`). + - [ ] Command(s): `mix test test/oli/authoring/editing/adaptive_duplication_test.exs test/oli/editing/container_editor_test.exs test/oli/interop/rewire_links_test.exs` +- Definition of Done: + - All known duplicated-screen resource-id surfaces are rewritten correctly. + - The duplicated page sequence listing points only at duplicated screens and preserves authored sequencing metadata. +- Gate: + - Adaptive duplicates are internally consistent and fail closed on unsupported or malformed content. +- Dependencies: + - Phase 2. +- Parallelizable Work: + - Page-content remapping tests and screen-content remapping tests can be built in parallel once the mapping contract is stable. + +## Phase 4: Verification And Release Readiness +- Goal: Close the feature with targeted regression coverage, manual verification notes, and work-item validation (`FR-001` through `FR-006`, `AC-001` through `AC-010`). +- Tasks: + - [ ] Run targeted backend and LiveView suites covering flag gating, duplication success, remapping correctness, rollback behavior, and non-adaptive regression. + - [ ] Perform manual authoring verification: duplicate an adaptive page, confirm copied-title behavior, confirm copied screens diverge from originals, and confirm forced failure leaves no duplicate behind (`AC-002`, `AC-003`, `AC-007`, `AC-009`). + - [ ] Reconcile any implementation drift back into the work-item docs only if behavior changed from the approved PRD/FDD. +- Testing Tasks: + - [ ] Run the targeted Elixir tests for this feature and any touched regression modules. + - [ ] Run formatting on touched backend files. + - [ ] Re-run harness validation if docs changed. + - [ ] Command(s): `mix test test/oli/authoring/editing/adaptive_duplication_test.exs test/oli/editing/container_editor_test.exs test/oli_web/live/curriculum/container_test.exs && mix format` +- Definition of Done: + - All targeted automated tests pass. + - Manual authoring checks confirm the duplicate behaves as specified. + - The work item remains validated and traceable. +- Gate: +- The feature is ready for guarded rollout behind `adaptive_duplication` using the incremental rollout admin UI. +- Dependencies: + - Phases 1 through 3. +- Parallelizable Work: + - Manual verification can begin once the automated duplication path is stable, while final regression and formatting are run in parallel. + +## Parallelization Notes +- The safest split is by seam, not by file count: UI flag affordance work, backend branch wiring, and backend test fixture setup can overlap early. +- Bulk-copy persistence and remapper implementation should stay serialized once transaction semantics are in flight, because they share the same mapping contract. +- No telemetry, metrics, or dashboard tasks should be added during implementation even though `harness.yml` enables telemetry by default; the PRD/FDD explicitly remove that requirement for this work item. + +## Phase Gate Summary +- Gate A: Adaptive duplication is feature-flagged and non-adaptive duplication is unchanged. +- Gate B: Bulk screen duplication is set-based, mapped, and transactionally safe. +- Gate C: Remapped duplicated screens and duplicated page point only at duplicated resources while preserving sequence metadata. +- Gate D: Targeted automated and manual verification pass, and the feature is ready for guarded rollout. diff --git a/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/prd.md b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/prd.md new file mode 100644 index 00000000000..88fc67e0f14 --- /dev/null +++ b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/prd.md @@ -0,0 +1,130 @@ +# Adaptive Duplication - Product Requirements Document + +## 1. Overview +Enable authors to duplicate adaptive pages within a project from the same curriculum entry points used for basic pages. The duplicated adaptive page must create a new page revision and new screen/activity resources, preserve authored screen order and trap-state logic, and remap page-level internal references so the copied page points at the copied screens rather than the original ones. + +Links: +- `docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/informal.md` +- `docs/exec-plans/current/epics/adaptive_page_improvements/overview.md` +- `docs/exec-plans/current/epics/adaptive_page_improvements/plan.md` +- Jira ticket `MER-4082` + +## 2. Background & Problem Statement +Basic pages can already be duplicated from curriculum authoring, but adaptive pages cannot. The current UI explicitly hides the duplicate action for adaptive pages, and the existing backend duplication path only performs a generic deep copy of page `activity-reference` nodes without adaptive-specific remapping. + +Adaptive pages carry additional structure that makes duplication riskier than regular pages. The adaptive page revision content is the screen sequence map: `content.advancedDelivery = true`, `content.model[0].children[*].type = "activity-reference"`, each child stores `activity_id`, and each child also stores `custom.sequenceId` and `custom.sequenceName`. A concrete repository example appears in `test/oli/conversation/adaptive_page_context_builder_test.exs`, where the page content maps screen sequence entries to adaptive activity resources. + +Each referenced adaptive screen/activity also has authored internal state that depends on stable IDs. Repository examples show adaptive activity content with `partsLayout`, `authoring.parts`, and `authoring.rules`; rules reference part IDs through facts like `stage.dropdown_1.selectedIndex`, and adaptive link-like content can exist in nested paths such as `content.partsLayout[].custom.nodes` or `authoring.parts[].model`. Concrete examples appear in `test/oli/delivery/attempts/activity_lifecycle/evaluate_test.exs`, `test/oli/activities/adaptive_parts_test.exs`, and `test/oli/interop/rewire_links_test.exs`. + +Without an adaptive-specific duplication flow, authors must recreate adaptive pages manually, which is slow and error-prone. If duplication copies the page but leaves references pointing at original screens or stale internal IDs, the copied lesson can behave incorrectly and may corrupt author intent. + +## 3. Goals & Non-Goals +### Goals +- Allow adaptive pages to expose the duplicate action in curriculum authoring when the feature is enabled. +- Duplicate an adaptive page by creating a new page revision plus new resources and revisions for each referenced adaptive screen/activity. +- Preserve authored screen order, `sequenceId`, `sequenceName`, and adaptive trap-state behavior in the copied page. +- Remap page-level activity references so the copied adaptive page references only copied adaptive screens. +- Fail closed when duplication cannot safely preserve required adaptive references. + +### Non-Goals +- No cross-project cloning behavior; this work is only for duplication inside the same project. +- No redesign of adaptive authoring or flowchart authoring UX beyond enabling the existing duplicate affordance. +- No new learner-facing adaptive runtime features. +- No best-effort partial duplicate that leaves unresolved references behind. + +## 4. Users & Use Cases +- Author: duplicates an adaptive page in a project curriculum to reuse an existing adaptive lesson as a starting point. +- Author: edits the duplicated adaptive page without affecting the original page or its original screens. +- Engineer or support staff: can diagnose duplication failures because the operation either succeeds with remapped references or fails without creating a broken duplicate. + +## 5. UX / UI Requirements +- Adaptive pages shall expose the same duplicate action location used by duplicable basic pages in curriculum authoring when the feature flag is enabled. +- When duplication succeeds, the copied entry shall appear in curriculum with the standard copied-title convention already used by page duplication. +- When duplication fails, authoring shall show a clear error flash and shall not leave behind a partially duplicated page entry. +- The duplicate control must remain keyboard reachable and match the existing curriculum dropdown interaction model. +- No new standalone adaptive duplication UI or wizard is required for this work item. + +## 6. Functional Requirements +Requirements are found in requirements.yml + +## 7. Acceptance Criteria (Testable) +Requirements are found in requirements.yml + +## 8. Non-Functional Requirements +- Duplication shall run transactionally so a failure in page creation, screen duplication, or reference remapping rolls back the operation. +- The adaptive-specific duplication path shall preserve project authorization and authoring boundaries already enforced for basic page duplication. +- The operation shall not mutate the original adaptive page or any original adaptive screen/activity revisions. +- Performance should remain acceptable for normal adaptive pages by avoiding unnecessary repeated lookups and by remapping identifiers in a bounded traversal of the duplicated payloads. +- Reliability takes priority over convenience: if required adaptive references cannot be resolved or rewritten safely, the system must fail closed. + +## 9. Data, Interfaces & Dependencies +- Adaptive page content currently stores screen membership and order in page revision JSON. A concrete repository fixture shows: + - `advancedDelivery: true` + - `model[0].type: "group"` + - `model[0].layout: "deck"` + - `model[0].children[*].type: "activity-reference"` + - `model[0].children[*].activity_id: ` + - `model[0].children[*].custom.sequenceId` and `custom.sequenceName` +- Adaptive screen/activity content stores authored runtime state inside its revision content, including: + - `partsLayout[*].id` and `type` + - `authoring.parts[*].id` and `type` + - `authoring.rules[*].conditions[*].fact` references such as `stage....` + - possible nested internal-link structures in adaptive rich-content payloads +- The implementation depends on specializing the current `ContainerEditor.duplicate_page/4` flow so adaptive pages do not use the generic basic-page duplication mapper unchanged. +- The work depends on existing authoring resource/revision creation, adaptive activity creation/editing semantics, and any existing link-rewrite or ID-rewrite helpers that can be reused safely. + +## 10. Repository & Platform Considerations +- Current curriculum authoring intentionally hides the duplicate action for adaptive pages in `lib/oli_web/live/curriculum/entries/actions.ex`, and existing LiveView coverage asserts that behavior in `test/oli_web/live/curriculum/container_test.exs`. +- Current basic-page duplication is implemented in `lib/oli/authoring/editing/container_editor.ex` and deep-copies page `activity-reference` nodes generically; that path must remain intact for non-adaptive pages while adaptive pages take a specialized implementation. +- The design must respect the Torus resource/revision model: duplication means new resources and new revisions, not edits to existing published or authoring resources. +- Backend domain rules belong in `lib/oli/...`; LiveView should remain a thin caller that surfaces success or failure. +- Expected verification should include targeted ExUnit coverage for duplication/remapping behavior and LiveView coverage for the duplicate affordance and failure handling. + +## 11. Feature Flagging, Rollout & Migration +- This work item requires a canary rollout feature with slug `adaptive_duplication`. +- The duplicate affordance for adaptive pages and the adaptive-specific duplication backend path shall be gated together so disabled environments preserve current behavior. +- A rollout data migration should seed the global stage to `full` so the feature is available by default after deployment while retaining an Admin kill switch. +- Rollout should be managed through the repository's incremental feature rollout process so administrators can set the global stage to `full` for normal operation and quickly return it to `off` if post-deployment issues are reported. + +## 12. Success Metrics +- Success signal: authors can duplicate adaptive pages without manual recreation, and copied pages reference copied screens rather than originals. +- Failure signal: duplication fails closed instead of producing broken copied pages. +- No feature-specific telemetry, metrics, dashboards, or AppSignal instrumentation are required for this work item. + +## 13. Risks & Mitigations +- Risk: copied adaptive pages could still reference original screen resources. + - Mitigation: build the duplicate as a two-phase mapping flow that first creates copied screen resources, then rewrites the copied page’s `activity-reference.activity_id` values from old-to-new. +- Risk: adaptive screen behavior could break because internal authored references depend on stable part IDs or nested content paths. + - Mitigation: audit and cover the known adaptive payload shapes in tests, including `partsLayout`, `authoring.parts`, `authoring.rules`, and nested adaptive link content. +- Risk: adaptive duplication work could regress existing basic-page duplication. + - Mitigation: explicitly branch adaptive and non-adaptive duplication logic and keep current basic-page tests passing. +- Risk: partial duplicates could be left behind when one screen copy fails mid-operation. + - Mitigation: keep duplication transactional and rollback on any failed copy or remap step. + +## 14. Open Questions & Assumptions +### Open Questions +- Which adaptive payload paths beyond the currently known `partsLayout`, `authoring.parts`, `authoring.rules`, and nested adaptive link nodes require identifier remapping for full safety? +- Should adaptive duplication reuse existing ID/link rewrite helpers directly, or should it introduce an adaptive-specific mapper to avoid unintended behavior on basic pages? +- Do any adaptive screen templates or trap-state authoring constructs maintained primarily by Devesh require additional review before implementation is finalized? + +### Assumptions +- Duplication is only expected within the same project, so copied adaptive screens remain valid in the same project resource namespace. +- `custom.sequenceId` and `custom.sequenceName` are part of the author-intended adaptive flow and must be preserved on the copied page unless an implementation-level reason requires regeneration. +- Blocking duplication is preferable to creating a duplicate with unresolved internal references. + +## 15. QA Plan +- Automated validation: + - Add backend ExUnit coverage for adaptive page duplication that verifies new screen resources are created, page `activity-reference` entries are remapped, and original resources remain unchanged. + - Add targeted tests for failure handling when adaptive references cannot be safely duplicated or remapped. + - Update LiveView tests so adaptive pages expose the duplicate action only when the feature is enabled and continue to surface author-facing errors on failure. + - Preserve or extend existing basic-page duplication coverage to prove non-adaptive behavior does not regress. +- Manual validation: + - Duplicate an adaptive page from curriculum authoring and confirm the copied page appears with copied-title naming. + - Open both original and copied adaptive pages and confirm edits to copied screens do not alter the original screens. + - Verify known internal adaptive references, including screen sequence ordering and trap-state behavior, continue to function on the copied page. + - Verify a forced failure path does not leave a copied page or copied screens behind. + +## 16. Definition of Done +- [ ] PRD sections complete +- [ ] requirements.yml captured and valid +- [ ] validation passes diff --git a/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/requirements.yml b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/requirements.yml new file mode 100644 index 00000000000..c9c07b1d7ae --- /dev/null +++ b/docs/exec-plans/current/epics/adaptive_page_improvements/adaptive_duplication/requirements.yml @@ -0,0 +1,62 @@ +work_item: adaptive_duplication +# No telemetry or feature-specific observability requirement is captured for this work item. +requirements: +- title: Adaptive page duplication shall be available in curriculum authoring when + the `adaptive_duplication` feature flag is enabled. + id: FR-001 +- title: Adaptive page duplication shall create a new adaptive page revision and new + duplicated adaptive screen resources and revisions for every referenced screen + on the source page. + id: FR-002 +- title: Adaptive page duplication shall rewrite the copied page content so every + duplicated `activity-reference.activity_id` points to the duplicated adaptive + screen resource rather than the original resource. + id: FR-003 +- title: Adaptive page duplication shall preserve authored adaptive sequencing metadata + and adaptive screen behavior needed for trap-state logic after duplication. + id: FR-004 +- title: Adaptive page duplication shall fail closed and roll back the operation when + required adaptive resources or internal references cannot be safely duplicated + or remapped. + id: FR-005 +- title: Existing non-adaptive page duplication behavior shall remain unchanged. + id: FR-006 +acceptance_criteria: +- title: Given the feature flag is disabled, when an author views curriculum actions + for an adaptive page, then the duplicate action is not shown and current behavior + is unchanged. + id: AC-001 +- title: Given the feature flag is enabled, when an author views curriculum actions + for an adaptive page, then the duplicate action is shown in the existing page + actions menu. + id: AC-002 +- title: Given an author duplicates an adaptive page with three referenced screens, + when duplication succeeds, then a new page entry is created and three new adaptive + screen resources are created for the duplicate. + id: AC-003 +- title: Given adaptive page duplication succeeds, when the duplicated page content + is inspected, then each copied `activity-reference.activity_id` points to a newly + created adaptive screen resource and none point to the original screen resources. + id: AC-004 +- title: Given adaptive page duplication succeeds, when the duplicated page content + is inspected, then screen order and the authored `custom.sequenceId` and `custom.sequenceName` + metadata are preserved as required by the copied flow. + id: AC-005 +- title: Given adaptive page duplication succeeds, when the duplicated adaptive screens + are inspected, then the duplicated screen content preserves authored adaptive + parts and rule content required for trap-state behavior. + id: AC-006 +- title: Given an author edits a duplicated adaptive screen after duplication, when + the original adaptive screen is reloaded, then the original adaptive screen content + is unchanged. + id: AC-007 +- title: Given duplication encounters an adaptive reference it cannot safely duplicate + or remap, when the operation fails, then no duplicated adaptive page is created + and no partial duplicate is left behind. + id: AC-008 +- title: Given adaptive page duplication fails, when the author remains in curriculum + authoring, then they receive an error message rather than a silent failure. + id: AC-009 +- title: Given an author duplicates a non-adaptive page, when duplication succeeds, + then existing non-adaptive duplication behavior continues unchanged. + id: AC-010 diff --git a/lib/oli/authoring/editing/adaptive_duplication.ex b/lib/oli/authoring/editing/adaptive_duplication.ex new file mode 100644 index 00000000000..089a09ac707 --- /dev/null +++ b/lib/oli/authoring/editing/adaptive_duplication.ex @@ -0,0 +1,750 @@ +defmodule Oli.Authoring.Editing.AdaptiveDuplication do + @moduledoc """ + Adaptive page duplication entry point. + + Adaptive duplication performs a deep copy of the page and all referenced + adaptive screens, then rewires duplicated resource references inside the + copied screen/page content before attaching the duplicated page to the + requested container. + """ + + import Ecto.Query, warn: false + + alias Oli.Accounts.Author + alias Oli.Authoring.Course.Project + alias Oli.Authoring.Broadcaster + alias Oli.Publishing + alias Oli.Publishing.AuthoringResolver + alias Oli.Publishing.ChangeTracker + alias Oli.Publishing.PublishedResource + alias Oli.Repo + alias Oli.Resources.{ResourceType, Revision} + alias Oli.ScopedFeatureFlags + alias Oli.Utils.Slug + + @type screen_ref :: %{ + activity_id: pos_integer(), + sequence_id: String.t() | nil, + sequence_name: String.t() | nil + } + + @type duplication_result :: %{ + duplicated_resource_ids: [pos_integer()], + duplicated_revision_ids: [pos_integer()], + duplicated_screen_revisions: [map()], + screen_resource_map: %{pos_integer() => pos_integer()}, + screen_revision_map: %{pos_integer() => pos_integer()} + } + + @type page_duplication_result :: %{ + resource_id: pos_integer(), + revision_id: pos_integer() + } + + @type duplicate_error :: + {:adaptive_duplication, + :disabled + | :invalid_author + | :source_page_not_found + | :not_adaptive_page + | :missing_deck_group + | :invalid_screen_reference + | :missing_screen_revision + | :invalid_screen_revision + | :screen_resource_insert_mismatch + | :screen_slug_generation_failed + | :screen_revision_insert_mismatch + | :screen_published_resource_insert_mismatch + | :screen_revision_update_mismatch + | :page_resource_insert_mismatch + | :page_slug_generation_failed + | :page_revision_insert_mismatch + | :page_published_resource_insert_mismatch + | :page_revision_update_mismatch + | :page_attach_failed} + + @spec duplicate(Project.t(), pos_integer(), Keyword.t()) :: + {:ok, Revision.t()} | {:error, duplicate_error()} + def duplicate(%Project{} = project, adaptive_page_resource_id, opts \\ []) + when is_integer(adaptive_page_resource_id) and is_list(opts) do + with {:ok, %Author{} = author} <- fetch_author(opts), + :ok <- ensure_feature_enabled(project, author), + {:ok, source_page} <- fetch_source_page(project, adaptive_page_resource_id), + {:ok, screen_refs} <- extract_adaptive_screen_refs(source_page.content), + {:ok, source_screen_revisions} <- + load_source_screen_revisions(project, screen_resource_ids(screen_refs)) do + container = Keyword.get(opts, :container) + + case Repo.transaction(fn -> + with {:ok, duplication} <- + duplicate_screen_resources(project, source_screen_revisions, author), + :ok <- + remap_duplicated_screen_revisions( + source_screen_revisions, + duplication.screen_revision_map, + duplication.screen_resource_map + ), + {:ok, duplicated_page} <- duplicate_page_resource(project, source_page, author), + :ok <- + remap_duplicated_page_revision( + source_page, + duplicated_page.revision_id, + duplication.screen_resource_map + ), + {:ok, duplicated_page_revision} <- + fetch_revision(duplicated_page.revision_id), + {:ok, attached_page_revision} <- + attach_duplicated_page(project, duplicated_page_revision, container, author) do + attached_page_revision + else + {:error, reason} -> Repo.rollback(reason) + end + end) do + {:ok, %Revision{} = duplicated_page_revision} -> + maybe_broadcast_container_update(project, container) + {:ok, duplicated_page_revision} + + {:error, reason} -> + {:error, reason} + end + end + end + + @doc false + @spec extract_adaptive_screen_refs(map()) :: {:ok, [screen_ref()]} | {:error, duplicate_error()} + def extract_adaptive_screen_refs(content) when is_map(content) do + with :ok <- ensure_adaptive_page_content(content), + {:ok, children} <- deck_children(content) do + children + |> Enum.reduce_while({:ok, []}, fn child, {:ok, refs} -> + case to_screen_ref(child) do + {:ok, nil} -> {:cont, {:ok, refs}} + {:ok, screen_ref} -> {:cont, {:ok, refs ++ [screen_ref]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + end + + def extract_adaptive_screen_refs(_), do: {:error, {:adaptive_duplication, :not_adaptive_page}} + + @doc false + @spec screen_resource_ids([screen_ref()]) :: [pos_integer()] + def screen_resource_ids(screen_refs) do + {ids, _seen} = + Enum.reduce(screen_refs, {[], MapSet.new()}, fn %{activity_id: activity_id}, {ids, seen} -> + if MapSet.member?(seen, activity_id) do + {ids, seen} + else + {ids ++ [activity_id], MapSet.put(seen, activity_id)} + end + end) + + ids + end + + @doc false + @spec load_source_screen_revisions(Project.t(), [pos_integer()]) :: + {:ok, [Revision.t()]} | {:error, duplicate_error()} + def load_source_screen_revisions(%Project{} = project, screen_resource_ids) + when is_list(screen_resource_ids) do + revisions = AuthoringResolver.from_resource_id(project.slug, screen_resource_ids) + + cond do + Enum.any?(revisions, &is_nil/1) -> + {:error, {:adaptive_duplication, :missing_screen_revision}} + + Enum.any?(revisions, &(not ResourceType.is_activity(&1))) -> + {:error, {:adaptive_duplication, :invalid_screen_revision}} + + true -> + {:ok, revisions} + end + end + + @doc false + @spec duplicate_screen_resources(Project.t(), [Revision.t()], Author.t()) :: + {:ok, duplication_result()} | {:error, duplicate_error()} + def duplicate_screen_resources( + %Project{} = project, + source_screen_revisions, + %Author{} = author + ) + when is_list(source_screen_revisions) do + count = length(source_screen_revisions) + + if count == 0 do + {:ok, + %{ + duplicated_resource_ids: [], + duplicated_revision_ids: [], + duplicated_screen_revisions: [], + screen_resource_map: %{}, + screen_revision_map: %{} + }} + else + with {:ok, duplicated_resource_ids} <- + allocate_resource_ids(project, count, :screen_resource_insert_mismatch), + {:ok, screen_slugs} <- + generate_slugs( + Enum.map(source_screen_revisions, & &1.title), + :screen_slug_generation_failed + ), + {:ok, duplicated_screen_revisions} <- + insert_revisions( + source_screen_revisions, + duplicated_resource_ids, + screen_slugs, + author.id, + :screen_revision_insert_mismatch + ), + :ok <- + insert_published_resources( + project, + duplicated_resource_ids, + :screen_published_resource_insert_mismatch + ) do + {:ok, + build_duplication_result( + source_screen_revisions, + duplicated_resource_ids, + duplicated_screen_revisions + )} + end + end + end + + def duplicate_screen_resources(_, _, _), do: {:error, {:adaptive_duplication, :invalid_author}} + + @doc false + @spec remap_adaptive_screen_content(map(), map()) :: map() + def remap_adaptive_screen_content(content, screen_resource_map) when is_map(content) do + content + |> rewire_flowchart_destination_screen_ids(screen_resource_map) + |> rewire_activities_required_for_evaluation(screen_resource_map) + |> rewire_activity_references(screen_resource_map) + |> rewire_adaptive_resource_links(screen_resource_map) + end + + @doc false + @spec remap_adaptive_page_content(map(), map()) :: map() + def remap_adaptive_page_content(content, screen_resource_map) when is_map(content) do + content + |> rewire_activity_references(screen_resource_map) + |> rewire_adaptive_resource_links(screen_resource_map) + end + + defp fetch_author(opts) do + case Keyword.get(opts, :author) do + %Author{} = author -> {:ok, author} + _ -> {:error, {:adaptive_duplication, :invalid_author}} + end + end + + defp ensure_feature_enabled(%Project{} = project, %Author{} = author) do + if ScopedFeatureFlags.can_access?(:adaptive_duplication, author, project) do + :ok + else + {:error, {:adaptive_duplication, :disabled}} + end + end + + defp fetch_source_page(%Project{} = project, adaptive_page_resource_id) do + case AuthoringResolver.from_resource_id(project.slug, adaptive_page_resource_id) do + %Revision{} = revision -> {:ok, revision} + nil -> {:error, {:adaptive_duplication, :source_page_not_found}} + end + end + + defp ensure_adaptive_page_content(content) do + if Map.get(content, "advancedDelivery") == true do + :ok + else + {:error, {:adaptive_duplication, :not_adaptive_page}} + end + end + + defp deck_children(content) do + case Map.get(content, "model", []) do + [%{"type" => "group", "layout" => "deck", "children" => children} | _] + when is_list(children) -> + {:ok, children} + + _ -> + {:error, {:adaptive_duplication, :missing_deck_group}} + end + end + + defp to_screen_ref(%{"type" => "activity-reference", "activity_id" => activity_id} = child) + when is_integer(activity_id) do + {:ok, + %{ + activity_id: activity_id, + sequence_id: get_in(child, ["custom", "sequenceId"]), + sequence_name: get_in(child, ["custom", "sequenceName"]) + }} + end + + defp to_screen_ref(%{"type" => "activity-reference"}), + do: {:error, {:adaptive_duplication, :invalid_screen_reference}} + + defp to_screen_ref(_child), do: {:ok, nil} + + defp allocate_resource_ids(%Project{} = project, count, error_reason) do + duplicated_resource_ids = Publishing.create_resource_batch(project, count) + + if length(duplicated_resource_ids) == count do + {:ok, duplicated_resource_ids} + else + {:error, {:adaptive_duplication, error_reason}} + end + end + + defp generate_slugs(titles, error_reason) when is_list(titles) do + slugs = Slug.generate("revisions", titles) + + if length(slugs) == length(titles) do + {:ok, slugs} + else + {:error, {:adaptive_duplication, error_reason}} + end + end + + defp insert_revisions( + source_revisions, + duplicated_resource_ids, + slugs, + author_id, + error_reason + ) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + payload = + Enum.zip([source_revisions, duplicated_resource_ids, slugs]) + |> Enum.map(fn {source_revision, duplicated_resource_id, slug} -> + build_revision_row(source_revision, duplicated_resource_id, slug, author_id, now) + end) + + case Repo.insert_all(Revision, payload, returning: [:id, :resource_id]) do + {count, duplicated_revisions} when count == length(source_revisions) -> + {:ok, duplicated_revisions} + + _ -> + {:error, {:adaptive_duplication, error_reason}} + end + end + + defp insert_published_resources(%Project{} = project, duplicated_resource_ids, error_reason) do + publication_id = Publishing.get_unpublished_publication_id!(project.id) + + query = + from revision in Revision, + where: revision.resource_id in ^duplicated_resource_ids, + select: %{ + publication_id: ^publication_id, + resource_id: revision.resource_id, + revision_id: revision.id, + inserted_at: revision.inserted_at, + updated_at: revision.updated_at + } + + case Repo.insert_all(PublishedResource, query) do + {count, _} when count == length(duplicated_resource_ids) -> + :ok + + _ -> + {:error, {:adaptive_duplication, error_reason}} + end + end + + @doc false + def build_duplication_result( + source_screen_revisions, + duplicated_resource_ids, + duplicated_screen_revisions + ) do + source_resource_ids = Enum.map(source_screen_revisions, & &1.resource_id) + + screen_resource_map = + Enum.zip(source_resource_ids, duplicated_resource_ids) + |> Enum.into(%{}) + + duplicated_revisions_by_resource_id = + Map.new(duplicated_screen_revisions, &{&1.resource_id, &1}) + + ordered_duplicated_screen_revisions = + Enum.map(duplicated_resource_ids, &Map.fetch!(duplicated_revisions_by_resource_id, &1)) + + duplicated_revision_ids_by_resource_id = + Map.new(duplicated_screen_revisions, &{&1.resource_id, &1.id}) + + screen_revision_map = + Enum.into(screen_resource_map, %{}, fn {source_resource_id, duplicated_resource_id} -> + {source_resource_id, + Map.fetch!(duplicated_revision_ids_by_resource_id, duplicated_resource_id)} + end) + + %{ + duplicated_resource_ids: duplicated_resource_ids, + duplicated_revision_ids: Enum.map(ordered_duplicated_screen_revisions, & &1.id), + duplicated_screen_revisions: ordered_duplicated_screen_revisions, + screen_resource_map: screen_resource_map, + screen_revision_map: screen_revision_map + } + end + + defp remap_duplicated_screen_revisions( + source_screen_revisions, + screen_revision_map, + screen_resource_map + ) do + updates = + Enum.reduce(source_screen_revisions, [], fn source_revision, updates -> + remapped_content = + remap_adaptive_screen_content(source_revision.content || %{}, screen_resource_map) + + [ + %{ + revision_id: Map.fetch!(screen_revision_map, source_revision.resource_id), + content: remapped_content, + children: remap_resource_ids(source_revision.children, screen_resource_map), + activity_refs: activity_refs_from_content(remapped_content) + } + | updates + ] + end) + |> Enum.reverse() + + update_revision_references(updates, :screen_revision_update_mismatch) + end + + defp duplicate_page_resource( + %Project{} = project, + %Revision{} = source_page, + %Author{} = author + ) do + duplicated_title = "#{source_page.title} (copy)" + + with {:ok, [duplicated_page_resource_id]} <- + allocate_resource_ids(project, 1, :page_resource_insert_mismatch), + {:ok, [page_slug]} <- generate_slugs([duplicated_title], :page_slug_generation_failed), + {:ok, [%{id: duplicated_page_revision_id}]} <- + insert_revisions( + [Map.put(source_page, :title, duplicated_title)], + [duplicated_page_resource_id], + [page_slug], + author.id, + :page_revision_insert_mismatch + ), + :ok <- + insert_published_resources( + project, + [duplicated_page_resource_id], + :page_published_resource_insert_mismatch + ) do + {:ok, %{resource_id: duplicated_page_resource_id, revision_id: duplicated_page_revision_id}} + end + end + + defp remap_duplicated_page_revision( + %Revision{} = source_page, + duplicated_page_revision_id, + screen_resource_map + ) do + remapped_content = + remap_adaptive_page_content(source_page.content || %{}, screen_resource_map) + + update_revision_references( + [ + %{ + revision_id: duplicated_page_revision_id, + content: remapped_content, + children: remap_resource_ids(source_page.children, screen_resource_map), + activity_refs: activity_refs_from_content(remapped_content) + } + ], + :page_revision_update_mismatch + ) + end + + defp attach_duplicated_page(_project, duplicated_page_revision, nil, _author), + do: {:ok, duplicated_page_revision} + + defp attach_duplicated_page( + %Project{} = project, + %Revision{} = duplicated_page_revision, + %Revision{} = container, + %Author{} = author + ) do + append = %{ + children: container.children ++ [duplicated_page_revision.resource_id], + author_id: author.id + } + + with {:ok, _updated_container} <- + ChangeTracker.track_revision(project.slug, container, append), + {:ok, restored_page_revision} <- + maybe_restore_deleted_revision(project.slug, duplicated_page_revision, author) do + {:ok, restored_page_revision} + else + _ -> {:error, {:adaptive_duplication, :page_attach_failed}} + end + end + + defp maybe_restore_deleted_revision(project_slug, duplicated_page_revision, author) do + case duplicated_page_revision.deleted do + true -> + ChangeTracker.track_revision(project_slug, duplicated_page_revision, %{ + deleted: false, + author_id: author.id + }) + + _ -> + {:ok, duplicated_page_revision} + end + end + + defp fetch_revision(revision_id) when is_integer(revision_id) do + case Repo.get(Revision, revision_id) do + %Revision{} = revision -> {:ok, revision} + nil -> {:error, {:adaptive_duplication, :page_revision_insert_mismatch}} + end + end + + defp maybe_broadcast_container_update(%Project{} = project, %Revision{} = container) do + updated_container = AuthoringResolver.from_resource_id(project.slug, container.resource_id) + Broadcaster.broadcast_revision(updated_container, project.slug) + end + + defp maybe_broadcast_container_update(_project, _container), do: :ok + + defp update_revision_references([], _error_reason), do: :ok + + defp update_revision_references(update_rows, error_reason) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Enum.reduce_while(update_rows, :ok, fn update_row, :ok -> + query = from revision in Revision, where: revision.id == ^update_row.revision_id + + case Repo.update_all(query, + set: [ + content: update_row.content, + children: update_row.children, + activity_refs: update_row.activity_refs, + updated_at: now + ] + ) do + {1, _} -> {:cont, :ok} + _ -> {:halt, {:error, {:adaptive_duplication, error_reason}}} + end + end) + end + + defp rewire_flowchart_destination_screen_ids(content, screen_resource_map) do + update_nested_map(content, "authoring", fn authoring -> + update_nested_map(authoring, "flowchart", fn flowchart -> + Map.update(flowchart, "paths", [], fn + paths when is_list(paths) -> + Enum.map(paths, fn + %{"destinationScreenId" => destination_screen_id} = path -> + Map.put( + path, + "destinationScreenId", + mapped_resource_id(screen_resource_map, destination_screen_id) || + destination_screen_id + ) + + other -> + other + end) + + other -> + other + end) + end) + end) + end + + defp rewire_activities_required_for_evaluation(content, screen_resource_map) do + update_nested_map(content, "authoring", fn authoring -> + Map.update(authoring, "activitiesRequiredForEvaluation", [], fn + activity_ids when is_list(activity_ids) -> + Enum.map(activity_ids, fn activity_id -> + mapped_resource_id(screen_resource_map, activity_id) || activity_id + end) + + other -> + other + end) + end) + end + + defp rewire_activity_references(content, screen_resource_map) do + deep_rewrite(content, fn + %{"type" => "activity-reference", "activity_id" => activity_id} = reference -> + case mapped_resource_id(screen_resource_map, activity_id) do + nil -> reference + mapped_id -> Map.put(reference, "activity_id", mapped_id) + end + + other -> + other + end) + end + + defp rewire_adaptive_resource_links(content, screen_resource_map) do + deep_rewrite(content, fn + %{"tag" => "a", "idref" => idref} = link -> + case mapped_resource_id(screen_resource_map, idref) do + nil -> link + mapped_id -> Map.put(link, "idref", mapped_id) + end + + %{"type" => "janus-capi-iframe"} = iframe -> + rewire_iframe_resource_reference(iframe, screen_resource_map) + + other -> + other + end) + end + + defp rewire_iframe_resource_reference(iframe, screen_resource_map) do + current_resource_id = Map.get(iframe, "resource_id") || Map.get(iframe, "idref") + + case mapped_resource_id(screen_resource_map, current_resource_id) do + nil -> + iframe + + mapped_id -> + iframe + |> Map.put("idref", mapped_id) + |> Map.put("resource_id", mapped_id) + end + end + + defp remap_resource_ids(resource_ids, screen_resource_map) when is_list(resource_ids) do + Enum.map(resource_ids, fn resource_id -> + mapped_resource_id(screen_resource_map, resource_id) || resource_id + end) + end + + defp remap_resource_ids(_resource_ids, _screen_resource_map), do: [] + + defp activity_refs_from_content(content) do + content + |> collect_activity_refs([]) + |> Enum.reverse() + |> Enum.uniq() + end + + defp collect_activity_refs( + %{"type" => "activity-reference", "activity_id" => activity_id} = map, + refs + ) do + map + |> Map.delete("activity_id") + |> collect_activity_refs([activity_id | refs]) + end + + defp collect_activity_refs(map, refs) when is_map(map) do + Enum.reduce(map, refs, fn {_key, value}, refs -> collect_activity_refs(value, refs) end) + end + + defp collect_activity_refs(list, refs) when is_list(list) do + Enum.reduce(list, refs, fn value, refs -> collect_activity_refs(value, refs) end) + end + + defp collect_activity_refs(_value, refs), do: refs + + defp mapped_resource_id(resource_id_map, key) do + case Map.get(resource_id_map, key) do + nil -> + case key do + integer when is_integer(integer) -> + Map.get(resource_id_map, Integer.to_string(integer)) + + binary when is_binary(binary) -> + case Integer.parse(binary) do + {integer, ""} -> Map.get(resource_id_map, integer) + _ -> nil + end + + _ -> + nil + end + + mapped -> + mapped + end + end + + defp build_revision_row(source_revision, duplicated_resource_id, slug, author_id, now) do + %{ + title: source_revision.title, + slug: slug, + deleted: source_revision.deleted, + ids_added: source_revision.ids_added, + author_id: author_id, + resource_id: duplicated_resource_id, + previous_revision_id: nil, + resource_type_id: source_revision.resource_type_id, + content: source_revision.content, + children: source_revision.children, + tags: source_revision.tags, + activity_refs: source_revision.activity_refs, + objectives: source_revision.objectives, + graded: source_revision.graded, + ai_enabled: source_revision.ai_enabled, + batch_scoring: source_revision.batch_scoring, + replacement_strategy: source_revision.replacement_strategy, + duration_minutes: source_revision.duration_minutes, + intro_content: source_revision.intro_content, + intro_video: source_revision.intro_video, + poster_image: source_revision.poster_image, + full_progress_pct: source_revision.full_progress_pct, + max_attempts: source_revision.max_attempts, + recommended_attempts: source_revision.recommended_attempts, + time_limit: source_revision.time_limit, + scope: source_revision.scope, + resource_scope: source_revision.resource_scope, + retake_mode: source_revision.retake_mode, + assessment_mode: source_revision.assessment_mode, + parameters: source_revision.parameters, + legacy: embed_to_map(source_revision.legacy), + explanation_strategy: embed_to_map(source_revision.explanation_strategy), + collab_space_config: embed_to_map(source_revision.collab_space_config), + scoring_strategy_id: source_revision.scoring_strategy_id, + activity_type_id: source_revision.activity_type_id, + primary_resource_id: source_revision.primary_resource_id, + purpose: source_revision.purpose, + relates_to: source_revision.relates_to, + inserted_at: now, + updated_at: now + } + end + + defp embed_to_map(nil), do: nil + defp embed_to_map(embed) when is_struct(embed), do: Map.from_struct(embed) + defp embed_to_map(embed) when is_map(embed), do: embed + + defp update_nested_map(map, key, updater) when is_map(map) do + case Map.fetch(map, key) do + {:ok, value} -> Map.put(map, key, updater.(value)) + :error -> map + end + end + + defp deep_rewrite(value, rewrite_fun) when is_list(value) do + value + |> Enum.map(&deep_rewrite(&1, rewrite_fun)) + |> rewrite_fun.() + end + + defp deep_rewrite(%{} = value, rewrite_fun) do + value + |> Enum.into(%{}, fn {key, child} -> {key, deep_rewrite(child, rewrite_fun)} end) + |> rewrite_fun.() + end + + defp deep_rewrite(value, rewrite_fun), do: rewrite_fun.(value) +end diff --git a/lib/oli/authoring/editing/container_editor.ex b/lib/oli/authoring/editing/container_editor.ex index 6c82c3ce4b8..2c947fa6711 100644 --- a/lib/oli/authoring/editing/container_editor.ex +++ b/lib/oli/authoring/editing/container_editor.ex @@ -14,9 +14,11 @@ defmodule Oli.Authoring.Editing.ContainerEditor do alias Oli.Authoring.Course.Project alias Oli.Publishing.AuthoringResolver alias Oli.Publishing.ChangeTracker + alias Oli.ScopedFeatureFlags alias Oli.Authoring.Editing.PageEditor alias Oli.Repo alias Oli.Authoring.Broadcaster + alias Oli.Authoring.Editing.AdaptiveDuplication alias Oli.Authoring.Editing.ActivityEditor alias Oli.Activities alias Oli.Resources.ScoringStrategy @@ -454,35 +456,59 @@ defmodule Oli.Authoring.Editing.ContainerEditor do Resources.get_revision!(page_id) |> Map.from_struct() - new_page_attrs = - original_page - |> Map.drop([:slug, :inserted_at, :updated_at, :resource_id, :resource]) - |> Map.put(:title, "#{original_page.title} (copy)") - |> Map.put(:content, nil) - |> Map.put(:previous_revision_id, nil) - |> then(fn map -> - if is_nil(map.legacy) do - map - else - Map.put(map, :legacy, Map.from_struct(original_page.legacy)) - end - end) + cond do + Resources.ResourceType.is_adaptive_page(original_page) and + ScopedFeatureFlags.can_access?(:adaptive_duplication, author, project) and + adaptive_duplication_supported?(original_page) -> + AdaptiveDuplication.duplicate(project, original_page.resource_id, + container: container, + author: author + ) - Repo.transaction(fn -> - with {:ok, created_revision} <- add_new(container, new_page_attrs, author, project), - {:ok, model_duplicated_activities} <- - deep_copy_activities( - original_page.content, - project.slug, - author - ), - {:ok, updated_revision} <- - Resources.update_revision(created_revision, %{content: model_duplicated_activities}) do - updated_revision - else - {:error, e} -> Repo.rollback(e) - end - end) + Resources.ResourceType.is_adaptive_page(original_page) and + adaptive_duplication_supported?(original_page) -> + {:error, {:adaptive_duplication, :disabled}} + + true -> + new_page_attrs = + original_page + |> Map.drop([:slug, :inserted_at, :updated_at, :resource_id, :resource]) + |> Map.put(:title, "#{original_page.title} (copy)") + |> Map.put(:content, nil) + |> Map.put(:previous_revision_id, nil) + |> then(fn map -> + if is_nil(map.legacy) do + map + else + Map.put(map, :legacy, Map.from_struct(original_page.legacy)) + end + end) + + Repo.transaction(fn -> + with {:ok, created_revision} <- add_new(container, new_page_attrs, author, project), + {:ok, model_duplicated_activities} <- + deep_copy_activities( + original_page.content, + project.slug, + author + ), + {:ok, updated_revision} <- + Resources.update_revision(created_revision, %{ + content: model_duplicated_activities + }) do + updated_revision + else + {:error, e} -> Repo.rollback(e) + end + end) + end + end + + defp adaptive_duplication_supported?(original_page) do + case AdaptiveDuplication.extract_adaptive_screen_refs(original_page.content) do + {:ok, _screen_refs} -> true + _ -> false + end end def deep_copy_activities(model, project_slug, author) do diff --git a/lib/oli/scoped_feature_flags/defined_features.ex b/lib/oli/scoped_feature_flags/defined_features.ex index 990a2586dc2..418fbb38b08 100644 --- a/lib/oli/scoped_feature_flags/defined_features.ex +++ b/lib/oli/scoped_feature_flags/defined_features.ex @@ -41,6 +41,13 @@ defmodule Oli.ScopedFeatureFlags.DefinedFeatures do "Enable the instructor dashboard insights analytics tab for this section" ) + deffeature( + :adaptive_duplication, + [:authoring], + "Enable adaptive page duplication in curriculum authoring", + rollout_mode: :canary + ) + # Test-only features for comprehensive testing if Mix.env() in [:test] do deffeature(:feature1, [:both], "Test feature for both scopes") diff --git a/lib/oli_web/components/scoped_feature_toggle_component.ex b/lib/oli_web/components/scoped_feature_toggle_component.ex index 32960211af4..0ca80cce111 100644 --- a/lib/oli_web/components/scoped_feature_toggle_component.ex +++ b/lib/oli_web/components/scoped_feature_toggle_component.ex @@ -218,12 +218,16 @@ defmodule OliWeb.Components.ScopedFeatureToggleComponent do when is_list(allowed_scopes) do Enum.filter(features, fn feature -> scope_allowed?(feature, allowed_scopes) and - feature_matches_source_type?(feature, source_type) + feature_matches_source_type?(feature, source_type) and + scoped_feature_toggleable?(feature) end) end defp filter_features_by_scopes(features, _allowed_scopes, source_type) do - Enum.filter(features, &feature_matches_source_type?(&1, source_type)) + Enum.filter( + features, + &(feature_matches_source_type?(&1, source_type) and scoped_feature_toggleable?(&1)) + ) end defp scope_allowed?(feature, allowed_scopes) do @@ -246,6 +250,10 @@ defmodule OliWeb.Components.ScopedFeatureToggleComponent do defp feature_matches_source_type?(_feature, _source_type), do: true + defp scoped_feature_toggleable?(feature) do + Map.get(feature.metadata, :rollout_mode, :scoped_only) == :scoped_only + end + defp filter_olap_features(features, :section) do if Features.enabled?("clickhouse-olap") do features diff --git a/lib/oli_web/live/curriculum/container/container_live.ex b/lib/oli_web/live/curriculum/container/container_live.ex index 799d64b31c9..c85221eee95 100644 --- a/lib/oli_web/live/curriculum/container/container_live.ex +++ b/lib/oli_web/live/curriculum/container/container_live.ex @@ -434,6 +434,10 @@ defmodule OliWeb.Curriculum.ContainerLive do {:error, %Ecto.Changeset{} = _changeset} -> socket |> put_flash(:error, "Could not duplicate page") + + {:error, _reason} -> + socket + |> put_flash(:error, "Could not duplicate page") end {:noreply, diff --git a/lib/oli_web/live/curriculum/entries/actions.ex b/lib/oli_web/live/curriculum/entries/actions.ex index 3ae81e3ab93..80e56eeae48 100644 --- a/lib/oli_web/live/curriculum/entries/actions.ex +++ b/lib/oli_web/live/curriculum/entries/actions.ex @@ -5,12 +5,15 @@ defmodule OliWeb.Curriculum.Actions do use OliWeb, :html + alias Oli.ScopedFeatureFlags + alias Oli.Accounts.Author alias Oli.Resources.ResourceType alias Phoenix.LiveView.JS attr(:child, :map, required: true) - attr(:project_slug, :string) + attr(:project, :map, required: true) attr(:revision_history_link, :boolean, default: false) + attr(:current_author, :any, default: nil) def render(assigns) do ~H""" @@ -62,7 +65,7 @@ defmodule OliWeb.Curriculum.Actions do > Move to... - <%= if ResourceType.is_non_adaptive_page(@child) do %> + <%= if show_duplicate_action?(@child, @project, @current_author) do %>