diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index db64cbb..182cc38 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -53,7 +53,7 @@ jobs: env: DATABASE_URL: postgresql://postgres:test-only@postgres:5432/meguri_test MEGURI_SEED_DEMO: "true" - MEGURI_DEMO_SANDBOXES: "12" + MEGURI_DEMO_SANDBOXES: "14" MEGURI_BROWSER_CHANNEL: chromium run: | cargo build --locked diff --git a/docs/architecture.md b/docs/architecture.md index a87d665..402e502 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ Users, sessions, external identities, workspaces, memberships, audit, history, p Cot 0.7 has no transaction API. `Store::commit` uses one parameterized data-modifying PostgreSQL CTE through Cot `Database::raw_with`: compare-and-swap checks revision and authorization (a workspace grant, or instance administrator), then writes employee-role grants when they changed, history, audit, and the durable sync request atomically. `Store::system_commit` is the same statement without a grant check for configuration sync and identity linking; it never takes request input directly. A constraint failure rolls the entire statement back. Ordinary reads, sessions, and checkpoints use Cot queries/models. Pending requests do not change effective coverage. Published state is separate from desired state. -The worker conditionally claims a five-minute lease token. Publication has a four-minute deadline and HTTP requests a twenty-second timeout; no document lock spans HTTP. Verified operations get checkpoints. Completion updates status and audit atomically, guarded by the lease token. Newer revisions remain queued when older runs succeed or fail. Jobs retry with bounded backoff; fifteen-minute reconciliation extends the horizon and detects drift. Crashed workers leave recoverable leases. This is not a distributed fencing protocol: read-back/conflict handling remains essential after process suspension or an uncertain remote timeout. +The worker conditionally claims a five-minute lease token. Publication has a four-minute deadline and HTTP requests a twenty-second timeout; no document lock spans HTTP. Verified operations get checkpoints. Completion updates status and audit atomically, guarded by the lease token. Newer revisions remain staged when older runs succeed or fail. Authorized jobs retry their pinned document with bounded backoff; a successful publication runs again only after another explicit Sync. Crashed workers leave recoverable leases. This is not a distributed fencing protocol: read-back/conflict handling remains essential after process suspension or an uncertain remote timeout. Run Cot migrations once in a deployment job, then start replicas with `MEGURI_SKIP_MIGRATIONS=true`. The process that owns migrations (the `migrate` role, or a single `all` process without the skip flag) also synchronizes the instance configuration; replicas only read it. The initial migration creates frozen Cot models; the second adds scoped foreign keys, constraints, and indexes in one atomic PostgreSQL block. Migration 9 makes passwords optional and adds user email, groups, instance-administrator and bootstrap flags, workspace key and disabled timestamp, a unique `(issuer, subject)` index, `instance_state`, `instance_audit` and `oidc_flows`. A JSM destination can have only one local workspace binding. Never edit applied migrations. @@ -44,9 +44,15 @@ Replacement preview is a read-only service using the same engine, revision, part ## Provider contract +Operations collection reads use pages of at most 50 items and traverse every page for ownership recovery and drift checks. Team identifiers retain the optional `og-` prefix through validation, storage and HTTP paths. + +Readable provider names append the first 128 bits of SHA-256 over workspace and resource UUID bytes. Discovery matches this stable suffix or the legacy full technical name; duplicate matches fail. Checkpointed resources rename in place. Recovery without a checkpoint accepts the legacy name only when every other managed field matches the desired state. + +Publication includes the explicit compilation clock `as_of`. For plans with boundaries outside local half-hour marks, the adapter uses the currently effective assignee as a finite base rotation, or `noone` if coverage has not started. Future assignments use schedule overrides with millisecond precision; future gaps and the rounded base tail use `noone` overrides. Native base endpoints are rounded outward to local half-hour marks; current/future effective coverage remains exact. JSM rejects new overrides starting in the past, so publication guarantees coverage from `as_of` forward without rewriting local history. A transition overtaken by HTTP latency fails and is recomputed on retry. Coverage checkpoints contain parent schedule ID, expected payload and returned alias; an empty alias records a durable create intent. Recovery claims a matching remote override only against such an intent, rejects ambiguous matches, and verifies read-back before recording success. Stale owned intervals are removed after replacements are written; unknown/manual overrides still block unless overwrite is explicit. Expired owned intervals may disappear from the provider's ongoing/future collection without being drift. Rotation arrays are compared without order only for these independent generated intervals; escalation and participant order remains significant. + The `Provider` trait consumes canonical `Publication` data and a checkpoint store. Plugins are Rust modules linked at build time, without an unstable dynamic-library ABI. New providers add a typed integration variant, capability validation, dispatcher constructor, module, and contract tests. No engine changes should be needed. -JSM publishes finite single-person rotations with exact timestamps (maximum 500 per schedule). Escalations resolve local IDs to remote IDs; routing priorities become consecutive indices. Deterministic ownership names allow recovery when a create succeeds but its local checkpoint is lost. Read-back checks managed fields, arrays, timestamps, and external overrides. Only checkpointed owned resources may be deleted. Workspace-specific environment credentials never enter documents or audit. +JSM publishes finite single-person rotations for half-hour-aligned plans, and base rotations plus exact future overrides otherwise (maximum 500 canonical periods per schedule). Escalations resolve local IDs to remote IDs; routing priorities become consecutive indices. Deterministic ownership names allow recovery when a create succeeds but its local checkpoint is lost. Read-back checks managed fields, arrays, timestamps, and external overrides. Only checkpointed owned resources may be deleted. Workspace-specific environment credentials never enter documents or audit. Reconciliation covers configuration, not user notification preferences, forwarding rules, or delivered notifications. JSM has no cross-resource transaction here: partially published revisions stay failed until retries complete. A remote edit between GET and PATCH cannot be fenced with the local lease. A dedicated-team acceptance test must compare actual provider timelines as well as CRUD. @@ -121,10 +127,68 @@ Variable-tree indentation uses stylesheet depth classes, not inline custom prope Generation map opens from Generation rules as a read-only, draggable/zoomable Cytoscape canvas. Server preview returns actual iteration counts, input contexts, predicate outcomes and produced IDs; no scheduling evaluation is duplicated in JavaScript. Iterated outputs start collapsed into a group; inspecting a node or pass reveals source data and verified resources. Invalid drafts show diagnostics rather than stale resources. The map is a captured draft preview refreshed explicitly, and never applies changes. Bundled Cytoscape 3.33.1 is served locally with its MIT license; no CDN or new framework is required. Browser coverage checks groups, details, source navigation, pan/zoom and mobile layout; engine regression verifies traced pass results. -The generation map arranges numbered rules in saved execution order from left to right, joined by solid sequence arrows. Each rule owns a vertical column of iterations, conditions and outputs. Dashed secondary edges show data dependencies rather than execution order. Repeated output groups use offset background cards; decorative layers follow dragging and do not receive selection. Browser checks verify rule ordering, aligned output columns and stack movement. +The generation map arranges numbered rules in saved execution order from left to right, joined by solid sequence arrows. Variables occupy the upper band in first-consumer order, rules and their iterations and conditions occupy the middle band, and outputs occupy their producer columns in the lower resource band. Dashed edges show data dependencies. Repeated output groups expand into individual resources within the resource band. Browser checks verify rule ordering, band separation, output expansion and manual resources. Map input details display evaluated server values for each block and pass, never serialized bindings. Shared values are deduplicated across passes; differing values are explicitly counted. Generated references collapse to type/name cards, and people resolve by display name. Empty binding metadata and raw reference UUIDs are not presented. Condition operands and escalation recipients/delays come from the same resolver used during expansion. The map’s all-passes input overview lists deduplicated upstream sources, not expanded output settings: variables with used binding addresses and resource references grouped by producing block. References internal to the selected whole rule are omitted; selecting an individual output block shows its dependencies. Selecting a concrete pass still exposes its evaluated field values. Expanded map resources use type-specific labeled fields and the same compact reference cards as inputs, never JSON dumps. Priority schedules show ordered people, fallback and zoned handover; rotations add anchor/cycle; policies show recipient/timing/condition and repeat behavior; routes show conditions, working window and destination. Reference names resolve from the preview snapshot. Browser regression checks visible names, participant order and absence of UUID metadata. + +When a checkpointed resource GET returns 404, discard its obsolete checkpoint and schedule child checkpoints, then rediscover the stable ownership marker before creating. This also recovers a replacement whose create response was lost. Do not query child collections of a missing parent. Rebuild dependent references with the new remote ID. A missing collection or authorization failure remains an error; only resource absence authorizes recreation. + +## Explicit publication and shared drafts + +Saving workspace configuration, approving replacements, importing data and editing generator drafts never authorize provider writes. These changes persist in PostgreSQL and are visible to other authorized members. Ordinary unfinished modal forms remain local until Save. Generator drafts autosave separately, including invalid intermediate values, with their own optimistic revision and workspace scope. + +The top Review changes panel compares desired resources with the last successfully published document. Its Sync button is yellow when unpublished changes exist. Sync validates/applies the shared generator draft and explicitly authorizes a specific workspace revision; stale reviews fail. Provider status, errors and create/update/remove counts live in this panel. Failed authorized publications may retry the same snapshot; newer saved changes must never enter those retries. Successful jobs do not automatically refresh their horizon: the displayed coverage end requires a further explicit sync to extend it. + +The worker persists the authorized document and publishes that snapshot, not the newest workspace document. A click while a publication lease is active is rejected. Existing jobs are paused by the manual-publication migration until an explicit click. Published snapshots survive history pruning. Tests cover no writes before a click, pinned retries across later edits, no periodic writes after success, scoped shared-draft persistence, stale concurrent edits, resource review against the published baseline, and the browser workflow across sessions. + +## Compact recurring provider rotations + +A plain base rotation whose effective published coverage matches its ordered calendar-day cycle is exported to JSM as one finite recurring rotation named after the schedule. The engine supplies the current cycle phase, ordered participants and calendar-day shift length; the provider only translates these fields to daily/weekly API values. The remote schedule ID and ownership marker stay unchanged. A publication replaces the old per-shift rotations rather than accumulating new ones. Native boundary precision must be representable; irregular effective coverage and replacements retain the exact interval export. The canonical expanded timeline remains available for verification and preview. Tests cover mid-shift starts, pre-anchor phases, participant ordering, DST, replacements, migration of an existing owned schedule and idempotent repeated publication. + +When an owned JSM schedule has one current and one desired rotation, edits preserve the rotation ID through the dedicated rotation PATCH endpoint. Schedule metadata is patched without a rotations array. Verified intermediate writes are checkpointed; a durable pending-step snapshot recovers a lost response without mistaking that exact planned state for external drift. Tests model nested rotation replacement by schedule PATCH and require stable rotation identity across participant reorder, rename, retry and unchanged republish. Historical rows already retained by JSM are not deleted by this repair. + +Workspace state save/reload controls live beside Sync in the top Review changes panel. The localized hint distinguishes shared draft autosave, validated local application, and explicit publication; Sync includes application, so a separate save is optional. Reload retrieves the shared draft, not a historical rollback. Removing a variable or generation rule requires confirmation before mutating or autosaving the draft. Browser regression covers cancellation, confirmation, top-panel controls and narrow layout. + +JSM coverage rotation names use the schedule name, including priority coverage and the exact-time base rotation. Multiple finite rotations append the interval start in the schedule IANA timezone with date, time and UTC offset to distinguish DST folds. This is presentation only; ownership markers, coverage, checkpoints and explicit publication remain unchanged. Existing rotation names update on the next authorized synchronization. + +Manual and generated routing editors expose the existing greater-than and less-than operations. Priority follows alert severity (P1 highest); P3 and above is represented as less-than P3 with negate=true, including the boundary. The editors show comparison controls without an additional priority instruction; the provider wire contract is unchanged. + +Publication status exposes the lease expiry and a server-calculated publication_busy flag. Sync is disabled for an active lease, not merely the persisted publishing state. The review panel explains restart recovery and expired leases; normal polling refreshes eligibility. Restart never clears a live lease. PostgreSQL regression checks active-lease rejection and explicit reauthorization after expiry. + +Nested binding paths retain the raw input text while typing, including trailing spaces and slash separators. The parsed path still updates live for shared drafts and server previews. Reopening shows the normalized path; changing source resets it. Browser regression types a nested list path character by character and checks its resolved value, edits, reopening and source reset. + +JSM omits `repeat` when escalation repeats are disabled. Read-back and drift comparison accept an absent repeat field only for an expected escalation with `waitInterval: 0` (the provider disabled representation). Present repeat objects and enabled repeats remain strictly compared. Fake-server regressions reproduce omission, recovery without a checkpoint, idempotence and detection of externally enabled repeats. No overwrite policy is needed to recover an otherwise matching escalation. + +Sync immediately shows submitting feedback, then stays disabled for an authorized pending job as well as an active publication lease. Pending publication has a visible queue message. The UI polls every two seconds while queued or leased and keeps the twenty-second idle refresh; expired leases remain manually recoverable. A successful sync request updates local queue status before refreshing the workspace. Browser regression holds the queued response and verifies disabled state and automatic completion without reload. + +Generated escalation steps support Everyone on the team in addition to schedules and people. The optional team_all flag defaults to false for existing drafts; team recipients do not resolve an individual binding. They compile to Recipient::TeamAll and JSM uses the configured workspace integration team ID with notifyType=all. Delay and acknowledgment condition remain explicit. Generator, wire-contract and browser regressions cover the 30-minute team step and legacy recipients. + +## Layered generation map and value provenance + +The read-only map uses three separated horizontal bands: variables, generators, resources. Generator columns follow saved execution order; variables sort by first consumer, and generated resources occupy their producer column. Manual resources are included in the resource band with explicit manual attribution. Shapes distinguish bands; resource colors distinguish schedules, escalations and routes. Expanding output groups retains these bands. + +Server preview provides binding provenance using the same resolver as generation: source bindings, iteration sources, lookup keys, list items, intermediate path values, final values, node identities and errors. The browser presents this captured snapshot rather than re-evaluating scheduling logic. An inspector popover supports history, source navigation and per-pass examples. Dotted address segments are independently colored, with neutral separators; quoted keys containing dots remain intact. Generated names retain separate bound and literal affix segments, and resource fields link to their binding or explicit default/manual origin. Clicking the map backdrop closes it; clicks on the canvas only clear selection. Tests cover provenance, layout, manual resources, source exploration and safe text rendering. + +The value explorer renders source traversal as ordered monospace steps with neutral arrows and a selected-step indicator. Source navigation is a separate footer action; producer fields are independent choices rather than sequential path steps. Browser coverage checks stage values and separate source navigation. + +## Portable generation configuration + +[meguri/v2](portable-config.md) defines compact JSON import/export (with legacy v1 import support) for variables and generators only. Merge by name retains omitted inputs; nested identity keys preserve resource identity across editing and repeated imports. People transfer by email, manual resource references by exact name, and generated links by generator/block. The server provides a read-only parsed/expanded preview and revalidates before saving the shared draft with workspace and draft revisions. Provider writes require a later explicit Sync. Tests cover cross-workspace remapping, repeated imports, unknown fields, scoped permissions, stale previews, email contacts, parse errors and browser application. + +### Compact portable configuration + +`meguri/v2` is the default export: ordinary JSON variables, typed wrappers only for special scalar types, compact binding references, flattened output fields, readable output keys and omitted semantic defaults. The v1 importer remains supported. Inactive binding metadata must never be exported. Human output keys persist in shared drafts; list entries reconcile by unique name/value first and position for unmatched edits. The preview remains authoritative for identity-changing edits. Regression checks compare expanded resources, repeat imports and actual size reductions. + +The Import / Export dialog includes a localized Guide tab with a complete compact v2 reference, defaults, binding scope, operations, limits, merge semantics and a runnable example covering all output types. Copy guide copies every section as Markdown for hand editing or LLM use, without workspace data. Switching tabs preserves import text and does not apply it. Browser checks verify copying, sample preview, tab preservation and mobile layout. + +Generation steps preserve backward-compatible `team_all` and `team_random` flags, mutually exclusive with `user`. Team recipients ignore stale person/schedule bindings. The compiler emits `Recipient::TeamRandom`; JSM publishes it as a team recipient with `notifyType: random`. + +## Site control plane + +Approval calculation is shared by replacement and absence preview/save. Only actual workspace account participants other than the requester are approvers; unlinked email contacts never gain login, membership or privileges. Empty approval sets apply immediately through the same validated, atomic history/audit/staged-sync boundary, without forced_by. Requests involving a contact may be submitted on its behalf with EditSchedules; changes solely between other account holders retain the force requirement. Preview exposes the exact approval set for truthful UI status. + +Account identity keeps an optional, normalized, non-unique email in the user row. The domain User embeds its canonical Person (same ID); workspace Person records are snapshots refreshed from the account on every regular commit (`store::account_people`), not independently editable profiles. Directory, provider identity resolution and portable configuration use this email. Editing a linked person cannot change account identity; profile changes update current snapshots atomically while historical snapshots remain intact. SSO sign-in binds an unlinked contact with the same email to the account (see `identity::linked_document`); nothing is claimed by email alone at registration because there is none. diff --git a/docs/development.md b/docs/development.md index 00b98a5..ccc8c19 100644 --- a/docs/development.md +++ b/docs/development.md @@ -22,6 +22,12 @@ Browser checks: sign in with a demo account; inspect the calendar; create a requ ## Operational limitations +When configuring JSM, copy the entire team identifier between `/teams/` and `/on-call` in the Operations URL, including `og-` when present. Cloud ID and Meguri workspace ID are separate UUIDs. Operations rejects collection page sizes above 50; directory search has its own pagination contract. + +Provider display names use the resource name followed by a stable ownership marker. Do not remove the marker manually; it supports recovery after a lost checkpoint. Existing technical names migrate on a successful publication, subject to the ordinary drift policy. + +An exact-time JSM schedule can show a base rotation containing the current assignee (or `noone` before coverage begins) and generated future overrides in the provider UI. These are the exported effective assignments, not additional approval requests. A previously failed native-rotation write with no checkpoint is not automatically adopted: inspect it and explicitly overwrite the owned test schedule when repairing it. A successful remote create alone is never a synchronized publication. + For automated browser checks, run `npm ci`, `npx playwright install chromium`, then `npm run test:browser` against a freshly started demo (`MEGURI_SEED_DEMO=true`). The suites read the generated password from `/api/session` (`tests/support/browser.cjs`), so they need no configuration; set `MEGURI_BROWSER_URL` for a non-default address. By default the tests use installed Chrome; set `MEGURI_BROWSER_CHANNEL=chromium` for downloaded Chromium or `MEGURI_BROWSER_EXECUTABLE` for a specific binary. Workspaces cannot be created from the UI or API, so suites that need an isolated workspace create a fresh password user through the instance administrator API with groups from the demonstration configuration. `tests/browser-access.cjs` covers the login guide, absence of registration, the SSO error banner, the instance page and its read-only rules, employee permission editing (including force without approval), employee and outsider views, and workspace isolation. `tests/browser-calendars.cjs` checks email participants/absences, country defaults, reactive CSV import, workspace working hours, policy flows, and display-timezone isolation. `tests/browser-configuration.cjs` checks removal dependencies/redirects, native routing drag/drop, saved positions, insertion/removal renumbering, and mobile controls. Screenshots go under ignored `test-results/`. The suites modify demo data (some empty a demo workspace); restart the demo before repeating a run. Windows development needs complete PostgreSQL server binaries and their `share/` data files, not a client-only installation. Pass `-PostgresBin` to use a portable distribution. The loopback-only local database uses trust authentication and is not a production configuration. The script serves a copy of the executable so Cargo can rebuild while it runs. @@ -65,4 +71,58 @@ Generation map uses locally vendored Cytoscape.js 3.33.1 (`static/vendor/cytosca ## Browser-check sandboxes -Browser suites that change data cannot reuse the example workspaces: approved replacements are permanent history, so a schedule that has one can never be removed. Start the demo with `MEGURI_DEMO_SANDBOXES=12` to add empty `Sandbox 1..12` workspaces, each with its own administrator group `sandbox-N-leads` and employee group `sandbox-N-staff`. Every mutating suite creates a throwaway password user in its own sandbox through the instance-administrator API. Restart the demo before each full run; sandboxes are not reset. +Browser suites that change data cannot reuse the example workspaces: approved replacements are permanent history, so a schedule that has one can never be removed. Start the demo with `MEGURI_DEMO_SANDBOXES=14` to add empty `Sandbox 1..14` workspaces, each with its own administrator group `sandbox-N-leads` and employee group `sandbox-N-staff`. Every mutating suite creates a throwaway password user in its own sandbox through the instance-administrator API. Restart the demo before each full run; sandboxes are not reset. + +Deleting an exported resource in JSM does not remove the desired configuration in Meguri: the next publication recreates it and its managed coverage, without requiring overwrite. To remove it permanently, remove it in Meguri. Existing external edits still obey the drift policy. + +## Explicit publication and shared drafts + +Saving workspace configuration, approving replacements, importing data and editing generator drafts never authorize provider writes. These changes persist in PostgreSQL and are visible to other authorized members. Ordinary unfinished modal forms remain local until Save. Generator drafts autosave separately, including invalid intermediate values, with their own optimistic revision and workspace scope. + +The top Review changes panel compares desired resources with the last successfully published document. Its Sync button is yellow when unpublished changes exist. Sync validates/applies the shared generator draft and explicitly authorizes a specific workspace revision; stale reviews fail. Provider status, errors and create/update/remove counts live in this panel. Failed authorized publications may retry the same snapshot; newer saved changes must never enter those retries. Successful jobs do not automatically refresh their horizon: the displayed coverage end requires a further explicit sync to extend it. + +The worker persists the authorized document and publishes that snapshot, not the newest workspace document. A click while a publication lease is active is rejected. Existing jobs are paused by the manual-publication migration until an explicit click. Published snapshots survive history pruning. Tests cover no writes before a click, pinned retries across later edits, no periodic writes after success, scoped shared-draft persistence, stale concurrent edits, resource review against the published baseline, and the browser workflow across sessions. + +## Compact recurring provider rotations + +A plain base rotation whose effective published coverage matches its ordered calendar-day cycle is exported to JSM as one finite recurring rotation named after the schedule. The engine supplies the current cycle phase, ordered participants and calendar-day shift length; the provider only translates these fields to daily/weekly API values. The remote schedule ID and ownership marker stay unchanged. A publication replaces the old per-shift rotations rather than accumulating new ones. Native boundary precision must be representable; irregular effective coverage and replacements retain the exact interval export. The canonical expanded timeline remains available for verification and preview. Tests cover mid-shift starts, pre-anchor phases, participant ordering, DST, replacements, migration of an existing owned schedule and idempotent repeated publication. + +When an owned JSM schedule has one current and one desired rotation, edits preserve the rotation ID through the dedicated rotation PATCH endpoint. Schedule metadata is patched without a rotations array. Verified intermediate writes are checkpointed; a durable pending-step snapshot recovers a lost response without mistaking that exact planned state for external drift. Tests model nested rotation replacement by schedule PATCH and require stable rotation identity across participant reorder, rename, retry and unchanged republish. Historical rows already retained by JSM are not deleted by this repair. + +Workspace state save/reload controls live beside Sync in the top Review changes panel. The localized hint distinguishes shared draft autosave, validated local application, and explicit publication; Sync includes application, so a separate save is optional. Reload retrieves the shared draft, not a historical rollback. Removing a variable or generation rule requires confirmation before mutating or autosaving the draft. Browser regression covers cancellation, confirmation, top-panel controls and narrow layout. + +JSM coverage rotation names use the schedule name, including priority coverage and the exact-time base rotation. Multiple finite rotations append the interval start in the schedule IANA timezone with date, time and UTC offset to distinguish DST folds. This is presentation only; ownership markers, coverage, checkpoints and explicit publication remain unchanged. Existing rotation names update on the next authorized synchronization. + +Manual and generated routing editors expose the existing greater-than and less-than operations. Priority follows alert severity (P1 highest); P3 and above is represented as less-than P3 with negate=true, including the boundary. The editors show comparison controls without an additional priority instruction; the provider wire contract is unchanged. + +Publication status exposes the lease expiry and a server-calculated publication_busy flag. Sync is disabled for an active lease, not merely the persisted publishing state. The review panel explains restart recovery and expired leases; normal polling refreshes eligibility. Restart never clears a live lease. PostgreSQL regression checks active-lease rejection and explicit reauthorization after expiry. + +Nested binding paths retain the raw input text while typing, including trailing spaces and slash separators. The parsed path still updates live for shared drafts and server previews. Reopening shows the normalized path; changing source resets it. Browser regression types a nested list path character by character and checks its resolved value, edits, reopening and source reset. + +JSM omits `repeat` when escalation repeats are disabled. Read-back and drift comparison accept an absent repeat field only for an expected escalation with `waitInterval: 0` (the provider disabled representation). Present repeat objects and enabled repeats remain strictly compared. Fake-server regressions reproduce omission, recovery without a checkpoint, idempotence and detection of externally enabled repeats. No overwrite policy is needed to recover an otherwise matching escalation. + +Sync immediately shows submitting feedback, then stays disabled for an authorized pending job as well as an active publication lease. Pending publication has a visible queue message. The UI polls every two seconds while queued or leased and keeps the twenty-second idle refresh; expired leases remain manually recoverable. A successful sync request updates local queue status before refreshing the workspace. Browser regression holds the queued response and verifies disabled state and automatic completion without reload. + +Generated escalation steps support Everyone on the team in addition to schedules and people. The optional team_all flag defaults to false for existing drafts; team recipients do not resolve an individual binding. They compile to Recipient::TeamAll and JSM uses the configured workspace integration team ID with notifyType=all. Delay and acknowledgment condition remain explicit. Generator, wire-contract and browser regressions cover the 30-minute team step and legacy recipients. + +## Layered generation map and value provenance + +The read-only map uses three separated horizontal bands: variables, generators, resources. Generator columns follow saved execution order; variables sort by first consumer, and generated resources occupy their producer column. Manual resources are included in the resource band with explicit manual attribution. Shapes distinguish bands; resource colors distinguish schedules, escalations and routes. Expanding output groups retains these bands. + +Server preview provides binding provenance using the same resolver as generation: source bindings, iteration sources, lookup keys, list items, intermediate path values, final values, node identities and errors. The browser presents this captured snapshot rather than re-evaluating scheduling logic. An inspector popover supports history, source navigation and per-pass examples. Dotted address segments are independently colored, with neutral separators; quoted keys containing dots remain intact. Generated names retain separate bound and literal affix segments, and resource fields link to their binding or explicit default/manual origin. Clicking the map backdrop closes it; clicks on the canvas only clear selection. Tests cover provenance, layout, manual resources, source exploration and safe text rendering. + +The value explorer renders source traversal as ordered monospace steps with neutral arrows and a selected-step indicator. Source navigation is a separate footer action; producer fields are independent choices rather than sequential path steps. Browser coverage checks stage values and separate source navigation. + +## Portable generation configuration + +[meguri/v2](portable-config.md) defines compact JSON import/export (with legacy v1 import support) for variables and generators only. Merge by name retains omitted inputs; nested identity keys preserve resource identity across editing and repeated imports. People transfer by email, manual resource references by exact name, and generated links by generator/block. The server provides a read-only parsed/expanded preview and revalidates before saving the shared draft with workspace and draft revisions. Provider writes require a later explicit Sync. Tests cover cross-workspace remapping, repeated imports, unknown fields, scoped permissions, stale previews, email contacts, parse errors and browser application. + +### Compact portable configuration + +`meguri/v2` is the default export: ordinary JSON variables, typed wrappers only for special scalar types, compact binding references, flattened output fields, readable output keys and omitted semantic defaults. The v1 importer remains supported. Inactive binding metadata must never be exported. Human output keys persist in shared drafts; list entries reconcile by unique name/value first and position for unmatched edits. The preview remains authoritative for identity-changing edits. Regression checks compare expanded resources, repeat imports and actual size reductions. + +The Import / Export dialog includes a localized Guide tab with a complete compact v2 reference, defaults, binding scope, operations, limits, merge semantics and a runnable example covering all output types. Copy guide copies every section as Markdown for hand editing or LLM use, without workspace data. Switching tabs preserves import text and does not apply it. Browser checks verify copying, sample preview, tab preservation and mobile layout. + +To exercise random team escalation, select A random team member in a generator or import `{"after":30,"team":"random"}` in meguri/v2. No person binding is required. Use the local fake provider for verification; notification selection belongs to the external provider. + +Email-only contacts do not approve replacements. Test ordinary requests, swaps and absence groups with account/contact combinations, including immediate application with no approvers and retained approval for real members. OIDC/email account linking remains deferred; never infer membership from an email match. diff --git a/docs/jsm-acceptance.md b/docs/jsm-acceptance.md index 252c75e..3f87f44 100644 --- a/docs/jsm-acceptance.md +++ b/docs/jsm-acceptance.md @@ -30,3 +30,13 @@ Do not assume an organization's sandbox copies its on-call configuration: Atlass 6. Repeat unchanged, simulate partial publication recovery, change a managed remote resource, verify drift blocking and explicit overwrite, then remove only resources created by this test. Record redacted requests/responses, API dates, plan and observed limits. Keep ordinary CI on the local fake; live acceptance must be explicit and restricted to the dedicated test destination. See [testing.md](testing.md) for the provider acceptance contract. + +## Live compatibility observations, 2026-09-28 + +User-authorized checks confirmed that this destination requires the `og-` prefix on its team ID (prefixed routing GET: 200; bare UUID: 404). Schedule listing rejects `size=100` with `Field: size, error: must be less than or equal to 50`; pages of 50 and 20 succeed. + +A temporary disabled schedule, unconnected to routing and deleted after inspection, exposed the native-rotation precision limitation. Rotation starts submitted as `10:01:05.053868Z` and `10:17:05.053Z` returned as `10:00:05.053Z` and `10:30:05.053Z`. Starts at `10:30:00Z` and `10:00:00Z` were preserved. The returned rotation array also differed in order. Do not treat arbitrary exact intervals as verified or relax drift checks to accept these shifted boundaries. A subsequent disabled-schedule probe confirmed arbitrary future override boundaries and millisecond precision in the final timeline over a `noone` base. Creating an override with a past start returned HTTP 400. The adapter therefore uses the current assignee as the base and future overrides for exact transitions. A further disabled-schedule probe verified the current-user base followed by a future `noone` override in the final timeline at the exact millisecond boundary. A roughly 90-day override and an overlapping replacement were accepted; the older override retained its boundaries and could be deleted after replacement. All temporary probes were deleted. Full application-level acceptance, including ongoing override replacement, remains necessary on the dedicated test team; individual endpoint probes do not certify production reconciliation. + +Readable resource names and legacy-name recovery are covered locally. They do not resolve this time-boundary mismatch: a resource left behind by a failed read-back remains protected by drift checks. + +Compact recurring rotations use the documented daily/weekly rotation API with ordered participants and a finite end date. Local regression coverage includes calendar-day phase selection across both DST changes and in-place conversion from per-shift rotations. Compare the real provider timeline across a handover and DST boundary during the next explicitly requested live publication; no live provider writes were made for this optimization. diff --git a/docs/portable-config-v1.md b/docs/portable-config-v1.md new file mode 100644 index 0000000..820c76e --- /dev/null +++ b/docs/portable-config-v1.md @@ -0,0 +1,49 @@ +# Portable configuration: meguri/v1 + +The Import / Export action in Review changes transfers only variables and generators from the shared draft. It excludes credentials, provider settings, manual resources, memberships and calendar history. Export is JSON; the textarea supports copying and downloading. Import parses and previews on the server after typing stops, without saving. Applying saves the reviewed shared draft, with both workspace and shared-draft revision checks. Saving checked changes and explicit provider synchronization retain their existing meanings. + +## Merge and identity + +The top-level object requires `format: "meguri/v1"`, `variables`, and `generators`. Unknown fields are errors. Arrays preserve order. Match variable and generator names case-insensitively after trimming, update matching inputs in place, append new inputs, and retain omitted inputs. Names must be unique. Renaming creates a new input; removing inputs remains an explicit UI action. Existing generator order and routing insertion position remain unchanged; new generators append in document order. A generator entry replaces that generator's complete iterations, condition and blocks, so the preview can include removed generated resources. + +Nested variable nodes and output blocks have optional `id` strings. Export includes them. These are portable identity keys, not workspace access identifiers. Keep them when changing values or reordering entries. Import into another workspace remaps them deterministically within the owning variable/generator; repeat import preserves generated identity. For hand-written documents, use descriptive block IDs (for example `daytime`). If omitted, IDs fall back to existing positions or deterministic positions; moving ID-less list entries does not preserve logical item identity. Iterations are referenced by their names within a generator. Generated output bindings identify `[generator-name, block-id]`. + +## Types and bindings + +A variable has `name` and `node`. Nodes use `kind`, optional scalar `value`, and optional `entries`. Kinds are `text`, `person`, `number`, `boolean`, `date`, `time`, `duration`, `list`, and `dictionary`. Dictionary entries also have a `key`. Dates are ISO dates; times are local scheduling times; duration units follow the generator field contract. Every `person` node and literal person binding uses an email address. Existing unique workspace contacts resolve exactly by email. A new email becomes an email-only scheduling contact during generation, never a login or workspace member. A person with no stored scheduling email blocks export until it is supplied. + +A generator has `name`, `enabled` (default true), `iterations`, optional `when`, and `blocks`. Blocks use `id`, `kind`, `fields`, and optionally `steps` and `conditions`, matching the visual editor. Binding objects use `kind` (`literal`, `variable`, `item`, `key`, `lookup`, `list`, `output`) with the appropriate `value`, `variable` name, `iteration` name, `output` pair, nested lookup `key`, or binding `items`. `path` is an array of dictionary keys or one-based list indexes written as strings; `prefix` and `suffix` modify text. A person used inside a generic literal (for example a predicate comparison) uses `{"$person":"alice@example.com"}` to preserve its type without a workspace UUID. No expressions or executable scripts are evaluated. + +Literal fallback schedules and escalation references use exact names in the target workspace. They must resolve uniquely; import never creates manual resources. All generation, permission, coverage and provider-capability validation runs before Apply becomes available. External resource links should normally use output bindings for portability. + +```json +{ + "format": "meguri/v1", + "variables": [ + {"name": "owners", "node": {"kind": "list", "entries": [ + {"id": "alice", "kind": "person", "value": "alice@example.com"}, + {"id": "bob", "kind": "person", "value": "bob@example.com"} + ]}} + ], + "generators": [ + { + "name": "Weekly coverage", + "blocks": [{ + "id": "weekly", + "kind": "rotation", + "fields": { + "name": {"kind": "literal", "value": "Platform on-call"}, + "participants": {"kind": "variable", "variable": "owners"}, + "anchor": {"kind": "literal", "value": "2026-09-28"}, + "handover": {"kind": "literal", "value": "11:00"}, + "shift_days": {"kind": "literal", "value": 7} + } + }] + } + ] +} +``` + +Export an existing visual rule for examples of conditions, escalation steps, repeats, nested lookups and route filters. Import is limited to 1 MB of text and the existing bounded generation limits. Parse errors retain the input and disable application. The preview lists creates, updates and unchanged inputs separately from generated resource changes; it also lists new email-only contacts and displays validation errors and pending-request cancellations. + +Escalation steps accept `team_random: true` to notify one random member of the workspace provider team, or `team_all: true` for everyone. Both default to false and are mutually exclusive with each other and `user: true`. Team modes ignore the recipient binding; export writes a null literal for it. diff --git a/docs/portable-config.md b/docs/portable-config.md new file mode 100644 index 0000000..a0f13c4 --- /dev/null +++ b/docs/portable-config.md @@ -0,0 +1,102 @@ +# Compact portable configuration: meguri/v2 + +Import / Export in Review changes now exports compact JSON. It transfers variables and generators only. Existing [v1 files](portable-config-v1.md) remain importable. No credentials, provider settings, membership, manual resources or history are exported. Import previews on the server while typing and saves only the reviewed shared draft; Sync remains a separate action. Workspace and shared-draft revision checks prevent stale application. + +## Everyday structure + +Variables are an object of names and ordinary JSON values. Lists are arrays; dictionaries are objects; strings, numbers and booleans need no type declarations. An email string denotes a person. Generators are an ordered array. Their outputs have readable, stable `key` names and flattened fields. Literal values need no `kind` or `value` wrapper. + +```json +{ + "format": "meguri/v2", + "variables": { + "on_call": ["alice@example.com", "bob@example.com"], + "projects": [ + {"name":"alpha", "primary":["alice@example.com"], "secondary":["bob@example.com"]}, + {"name":"beta", "primary":["bob@example.com"], "secondary":["alice@example.com"]} + ] + }, + "generators": [ + { + "name": "Time off", + "outputs": [{ + "key": "rotation", + "type": "rotation", + "name": "Time off default", + "participants": {"$var":"on_call"}, + "anchor": "2026-09-28" + }] + }, + { + "name": "Project owners", + "for": [{"as":"Project", "in":{"$var":"projects"}}], + "outputs": [{ + "key": "primary", + "type": "priority", + "name": {"$item":["Project","name"], "suffix":" daytime primary"}, + "participants": {"$item":["Project","primary"]}, + "fallback": {"$output":["Time off","rotation"]} + }] + } + ] +} +``` + +Defaults: generators are enabled; handover is 11:00 in the workspace IANA zone; rotation length is seven calendar days; repeat count/wait are zero; routes do not restrict working hours unless `working: true`. Explicit nondefaults remain in the file. Inactive editor metadata and cached values never appear. Small references and values are formatted on one line. + +## Bindings + +| Value | Meaning | +| --- | --- | +| `"Platform"`, `7`, `true`, `["alice@example.com"]` | Literal value | +| `{"$var":"projects"}` | Entire variable | +| `{"$var":["settings","owners","1"]}` | Variable with a nested path | +| `{"$item":["Project","primary"]}` | Field of the current iteration item | +| `{"$key":"Project"}` | Current dictionary iteration key | +| `{"$output":["Project owners","primary"]}` | Generated resource from the named output | +| `{"$lookup":["tiers",{"$item":["Project","tier"]}]}` | Dictionary lookup with a dynamic key | +| `{"$list":[{"$item":["Project","primary","1"]},"alice@example.com"]}` | List of separately evaluated bindings | +| `{"$literal":{"custom":"object"}}` | Literal object, including objects containing `$` keys | + +References may add `prefix`, `suffix`, or a `path` array. Variable/item/key shorthand arrays already contain the path; do not also specify `path`. List positions are one-based strings. Dots, slashes and spaces in names remain literal: each array element is one exact path segment. There is no expression evaluation or executable code. + +A generator's optional condition is `"when": [left-binding, operation, right-binding]`. `for` is an ordered array of `{"as":alias,"in":binding}` objects; nesting preserves that order. + +## Escalations and routes + +An escalation output uses `type: "escalation"`, `name`, optional `repeat_count`/`repeat_wait`, and `steps`: + +```json +"steps": [ + {"schedule":{"$output":["Project owners","primary"]}}, + {"after":10, "schedule":{"$output":["Project owners","secondary"]}}, + {"after":30, "team":"all"} +] +``` + +Each step chooses exactly one `schedule`, `person`, or `team: "all"` / `team: "random"`. `after` is minutes after alert creation (default zero). `if` defaults to `"not-acked"`; `"not-closed"` is also supported. Delays and person/schedule recipients may use any binding. + +Route outputs use `type: "route"`, `name`, `escalation`, optional `working`, and `conditions`. Each condition is `[field, operation, value-binding]`; an optional final `"not"` negates it: + +```json +"conditions": [ + ["tags", "contains", {"$item":["Project","tags"]}], + ["priority", "less-than", "P3", "not"] +] +``` + +## Special variable types and exact dictionaries + +Use `{"$date":"2026-10-01"}`, `{"$time":"09:00"}`, and `{"$duration":30}` to retain typed variable values. Use `{"$text":"literal@example.com"}` for text that looks like an email. `{"$person":"alice@example.com"}` is an explicit alternative to an email string. Unknown emails become scheduling contacts without login access; exports require a stored email for referenced people. + +Ordinary JSON objects represent dictionaries in key order. When existing dictionary entry order differs from alphabetical order, or a key begins with `$`, export uses the unambiguous ordered form `{"$object":[["z",1],["a",2]]}`. This preserves iteration order and supports all existing dictionary keys. Bindings with literal person-ID comparisons use the explicit `$person` marker inside `$literal`; import resolves the email within the target workspace. + +## Merge, identity and limits + +Import updates matching variable/generator names (case-insensitive, trimmed), appends new inputs and retains omitted inputs. Existing generator order and route insertion position stay unchanged. An included generator replaces its full definition: removed output blocks can remove generated resources, shown before Apply. + +Keep output `key` values stable when changing display names or order. Keys persist in the shared draft and do not depend on resource names. UUIDs remain internal. Variable root and dictionary-field IDs are preserved; list entries are matched first by unique value or record `name`, then by position for unmatched edits of equal-length lists. Named project reordering and field edits therefore preserve resource identity. Simultaneous renames, reorderings or edits of indistinguishable list entries can be ambiguous; inspect create/remove changes before applying. New items receive internal IDs without requiring users to author UUIDs. + +Literal references to manual schedules and escalation policies still use exact target-workspace names. Import never creates manual resources. All fields, references, types, existing generation limits and provider capabilities are validated on the server. Invalid JSON, unknown fields, malformed references or stale reviews block application. Import remains limited to 1 MB of text. The preview separates input changes, generated resource changes and new email-only contacts. + +`{"after":30,"team":"random"}` notifies one randomly selected member of the workspace provider team 30 minutes after alert creation if the condition still holds. The provider chooses the member; this does not sequentially notify all members and does not create a random schedule. diff --git a/docs/product.md b/docs/product.md index 7255e13..091cb37 100644 --- a/docs/product.md +++ b/docs/product.md @@ -29,17 +29,23 @@ Meguri is a multi-tenant on-call configuration service. Workspace is the single - Country holidays do not interrupt ordinary 24/7 rotations. Absence makes an employee unavailable in every schedule, including single-participant rotations and approved replacement assignments. Uncovered assignments remain visible as diagnostics and block future publication until an available replacement is arranged; the provider must never receive a new plan assigning an absent employee. - Absence dates are inclusive and refer to duty days beginning at each schedule's handover, not midnight. An absent general on-call user is reported as a coverage problem; there is no invented automatic replacement. - Absence planning accepts a small list of half-open date/time ranges and replacement people, scoped to one schedule or all affected schedules. Legacy API duty-day ranges remain supported. Each range replaces only the absent employee's actual affected shifts across rotations. Project coverage uses its existing secondary/fallback automatically; an optional range setting uses the selected replacement for affected project coverage too. Unrelated owners and base rotation anchors remain unchanged. The server previews actual before/after intervals, automatic changes, proposed replacements, gaps, and direct/team escalation warnings before saving. -- Absence is recorded immediately. Explicit replacements are grouped into requests per range, with one confirmation covering all affected schedules; they become effective only after all required approvals. Leads can force them with an optional comment. Saving absence, contacts, pending/applied requests, audit, history, and outbox is atomic and revision-checked. Approval revalidates ownership, availability, and overlaps. Pending replacements cannot make an incomplete plan publishable; existing external configuration remains until successful publication. Direct user/team escalation recipients are outside schedule substitution and are highlighted explicitly. +- Absence is recorded immediately. Explicit replacements are grouped into requests per range, with one request covering all affected schedules; they become effective after all required account-holder approvals, or immediately when none are required. Leads can force them with an optional comment. Saving absence, contacts, pending/applied requests, audit, history, and outbox is atomic and revision-checked. Approval revalidates ownership, availability, and overlaps. Pending replacements cannot make an incomplete plan publishable; existing external configuration remains until successful publication. Direct user/team escalation recipients are outside schedule substitution and are highlighted explicitly. - Replacements may cover any future interval, or the remaining part of a current shift. A reciprocal swap is one request containing two legs and applies atomically. Its approval must come from the other affected employee. A lead may apply without confirmation using the force grant. The action comment is optional, including forced application. - An interval ends exactly when the next starts. An override does not alter the rotation sequence. Overlapping approved overrides are rejected. - Local wall-clock handovers follow IANA timezone rules. Ambiguous or nonexistent handovers are validation errors. Historical assignments are retained through dated configuration revisions and approved override records. -A member may give their own shift or volunteer to cover a colleague; every non-forced leg must involve the actor. All other affected people approve. Current-interval save and approval clip to the server clock without rewriting history. Fully expired ordinary replacement/swap legs prevent the entire application. Approval-based requests require workspace access for both participants; contacts need a lead's explicit forced action. +A member may give their own shift or volunteer to cover a colleague; every non-forced leg created by an ordinary member must involve the actor. All other affected participants with workspace accounts approve. Email-only planning contacts never approve or receive access. Current-interval save and approval clip to the server clock without rewriting history. Fully expired ordinary replacement/swap legs prevent the entire application. Contacts are excluded from required approvers. A validated request with no remaining approvers applies immediately without being marked forced. Schedule editors may plan changes involving contacts on their behalf; ordinary members still may only give their own shifts or volunteer. Existing login accounts lacking workspace access are not treated as contacts. Every action has an optional comment (for example, a task number or vacation). It appears on the approval request and in history, not as a direct message. Empty comments stay empty. ## Provider boundary +JSM team IDs accept either a UUID or `og-` followed by a UUID. Preserve the full ID from the Operations team URL, including its prefix; the cloud ID remains a UUID. + +JSM resource names start with the manual or generated resource name and end with a stable `[meguri:]` marker. Renaming retains remote identity. Existing technical names are recognized during recovery; name migration does not bypass drift checks. + +JSM publication preserves arbitrary minute/second boundaries from the publication clock forward through managed schedule overrides when native rotations cannot represent the plan. The current assignee is exported as the base rotation (or `noone` before coverage starts); future assignments and uncovered gaps use exact overrides. JSM rejects new overrides starting in the past. Local history remains unchanged. The provider has millisecond precision; finer historical timestamps are truncated only in its wire representation, with shared boundaries transformed identically. Intervals that collapse at this precision are rejected before writes. Native rotations remain available for half-hour-aligned plans. Provider-managed overrides are distinct from manual external overrides and do not alter approval state in Meguri. + Meguri computes the assignments and manages escalation and routing configuration. Providers execute alerts, notifications, escalation delays, and acknowledgment. Meguri has no incident processing loop. The JSM adapter must support schedule identity mapping, generated coverage, escalation steps and repeats, and routing rules. Advanced escalation options depend on the external plan; unsupported combinations must fail visibly. No credentials are stored in workspace documents. @@ -87,7 +93,7 @@ Alice, Bob, and Carol rotate weekly from Monday at 11:00. Payments primary is Al Project escalation: primary at minute 0, secondary at minute 10, all team members at minute 30, if unacknowledged. General escalation: on-call at minute 0, random team member at minute 30, named lead at minute 45. Both may repeat 10 times, reset recipient states, and leave the alert open. Working-hours routes select project policies on weekdays 11:00–19:00; a catch-all route selects the general policy. Development alerts may be routed to no notification. ## People, calendars, and working intervals -Scheduling people are separate from login accounts. A workspace can select its members (people who reached it through groups) or add an email-only contact without creating an account or granting access. Their name, email, and ISO country belong to the versioned workspace state. Country defaults to Cyprus (CY); the UI offers all 249 ISO country codes. Existing login IDs remain stable and an email never grants access; at SSO sign-in it can bind a contact to the signing-in user (see Access and administration). Only authenticated, authorized users can approve requests; an email-only recipient requires an administrator's explicit forced application until that person signs in and the contact is linked. Selecting a workspace user does not grant them workspace access. +A login account owns its canonical Person; planning-only people have no login account. A workspace can select its members (people who reached it through groups) or add an email-only contact without creating an account or granting access. Workspace state retains versioned snapshots of name, email, and ISO country; for account-owned people these fields come from the account profile. Country defaults to Cyprus (CY); the UI offers all 249 ISO country codes. Existing login IDs remain stable and an email never grants access; at SSO sign-in it can bind a contact to the signing-in user (see Access and administration). Only authenticated, authorized users can approve requests; email-only contacts are planning entities and require no approval of their own. Selecting a workspace user does not grant them workspace access. Each schedule has its own local handover time (11:00 by default) in the workspace's IANA timezone. Duration is still measured in calendar days. Dependencies resolve their own handover boundaries. Workspace working days, start/end time, and IANA timezone are shared by all routing rules with “Only during working hours” enabled. Changing this workspace interval atomically updates workspace snapshots/revisions and queues publication. Old migrations preserve historical handovers and timezone values. @@ -190,10 +196,66 @@ Variable editing uses a bounded, progressively disclosed tree: only the root sta Generation map opens from Generation rules as a read-only, draggable/zoomable Cytoscape canvas. Server preview returns actual iteration counts, input contexts, predicate outcomes and produced IDs; no scheduling evaluation is duplicated in JavaScript. Iterated outputs start collapsed into a group; inspecting a node or pass reveals source data and verified resources. Invalid drafts show diagnostics rather than stale resources. The map is a captured draft preview refreshed explicitly, and never applies changes. Bundled Cytoscape 3.33.1 is served locally with its MIT license; no CDN or new framework is required. Browser coverage checks groups, details, source navigation, pan/zoom and mobile layout; engine regression verifies traced pass results. -The generation map arranges numbered rules in saved execution order from left to right, joined by solid sequence arrows. Each rule owns a vertical column of iterations, conditions and outputs. Dashed secondary edges show data dependencies rather than execution order. Repeated output groups use offset background cards; decorative layers follow dragging and do not receive selection. Browser checks verify rule ordering, aligned output columns and stack movement. +The generation map arranges numbered rules in saved execution order from left to right, joined by solid sequence arrows. Variables occupy the upper band in first-consumer order, rules and their iterations and conditions occupy the middle band, and outputs occupy their producer columns in the lower resource band. Dashed edges show data dependencies. Repeated output groups expand into individual resources within the resource band. Browser checks verify rule ordering, band separation, output expansion and manual resources. Map input details display evaluated server values for each block and pass, never serialized bindings. Shared values are deduplicated across passes; differing values are explicitly counted. Generated references collapse to type/name cards, and people resolve by display name. Empty binding metadata and raw reference UUIDs are not presented. Condition operands and escalation recipients/delays come from the same resolver used during expansion. The map’s all-passes input overview lists deduplicated upstream sources, not expanded output settings: variables with used binding addresses and resource references grouped by producing block. References internal to the selected whole rule are omitted; selecting an individual output block shows its dependencies. Selecting a concrete pass still exposes its evaluated field values. Expanded map resources use type-specific labeled fields and the same compact reference cards as inputs, never JSON dumps. Priority schedules show ordered people, fallback and zoned handover; rotations add anchor/cycle; policies show recipient/timing/condition and repeat behavior; routes show conditions, working window and destination. Reference names resolve from the preview snapshot. Browser regression checks visible names, participant order and absence of UUID metadata. + +A desired JSM resource deleted externally is recreated even with drift blocking enabled. Existing resources modified externally still require the configured drift policy. Missing managed overrides are restored; unmanaged resources are never adopted by ID guessing. + +## Explicit publication and shared drafts + +Saving workspace configuration, approving replacements, importing data and editing generator drafts never authorize provider writes. These changes persist in PostgreSQL and are visible to other authorized members. Ordinary unfinished modal forms remain local until Save. Generator drafts autosave separately, including invalid intermediate values, with their own optimistic revision and workspace scope. + +The top Review changes panel compares desired resources with the last successfully published document. Its Sync button is yellow when unpublished changes exist. Sync validates/applies the shared generator draft and explicitly authorizes a specific workspace revision; stale reviews fail. Provider status, errors and create/update/remove counts live in this panel. Failed authorized publications may retry the same snapshot; newer saved changes must never enter those retries. Successful jobs do not automatically refresh their horizon: the displayed coverage end requires a further explicit sync to extend it. + +The worker persists the authorized document and publishes that snapshot, not the newest workspace document. A click while a publication lease is active is rejected. Existing jobs are paused by the manual-publication migration until an explicit click. Published snapshots survive history pruning. Tests cover no writes before a click, pinned retries across later edits, no periodic writes after success, scoped shared-draft persistence, stale concurrent edits, resource review against the published baseline, and the browser workflow across sessions. + +## Compact recurring provider rotations + +A plain base rotation whose effective published coverage matches its ordered calendar-day cycle is exported to JSM as one finite recurring rotation named after the schedule. The engine supplies the current cycle phase, ordered participants and calendar-day shift length; the provider only translates these fields to daily/weekly API values. The remote schedule ID and ownership marker stay unchanged. A publication replaces the old per-shift rotations rather than accumulating new ones. Native boundary precision must be representable; irregular effective coverage and replacements retain the exact interval export. The canonical expanded timeline remains available for verification and preview. Tests cover mid-shift starts, pre-anchor phases, participant ordering, DST, replacements, migration of an existing owned schedule and idempotent repeated publication. + +When an owned JSM schedule has one current and one desired rotation, edits preserve the rotation ID through the dedicated rotation PATCH endpoint. Schedule metadata is patched without a rotations array. Verified intermediate writes are checkpointed; a durable pending-step snapshot recovers a lost response without mistaking that exact planned state for external drift. Tests model nested rotation replacement by schedule PATCH and require stable rotation identity across participant reorder, rename, retry and unchanged republish. Historical rows already retained by JSM are not deleted by this repair. + +Workspace state save/reload controls live beside Sync in the top Review changes panel. The localized hint distinguishes shared draft autosave, validated local application, and explicit publication; Sync includes application, so a separate save is optional. Reload retrieves the shared draft, not a historical rollback. Removing a variable or generation rule requires confirmation before mutating or autosaving the draft. Browser regression covers cancellation, confirmation, top-panel controls and narrow layout. + +JSM coverage rotation names use the schedule name, including priority coverage and the exact-time base rotation. Multiple finite rotations append the interval start in the schedule IANA timezone with date, time and UTC offset to distinguish DST folds. This is presentation only; ownership markers, coverage, checkpoints and explicit publication remain unchanged. Existing rotation names update on the next authorized synchronization. + +Manual and generated routing editors expose the existing greater-than and less-than operations. Priority follows alert severity (P1 highest); P3 and above is represented as less-than P3 with negate=true, including the boundary. The editors show comparison controls without an additional priority instruction; the provider wire contract is unchanged. + +Publication status exposes the lease expiry and a server-calculated publication_busy flag. Sync is disabled for an active lease, not merely the persisted publishing state. The review panel explains restart recovery and expired leases; normal polling refreshes eligibility. Restart never clears a live lease. PostgreSQL regression checks active-lease rejection and explicit reauthorization after expiry. + +Nested binding paths retain the raw input text while typing, including trailing spaces and slash separators. The parsed path still updates live for shared drafts and server previews. Reopening shows the normalized path; changing source resets it. Browser regression types a nested list path character by character and checks its resolved value, edits, reopening and source reset. + +JSM omits `repeat` when escalation repeats are disabled. Read-back and drift comparison accept an absent repeat field only for an expected escalation with `waitInterval: 0` (the provider disabled representation). Present repeat objects and enabled repeats remain strictly compared. Fake-server regressions reproduce omission, recovery without a checkpoint, idempotence and detection of externally enabled repeats. No overwrite policy is needed to recover an otherwise matching escalation. + +Sync immediately shows submitting feedback, then stays disabled for an authorized pending job as well as an active publication lease. Pending publication has a visible queue message. The UI polls every two seconds while queued or leased and keeps the twenty-second idle refresh; expired leases remain manually recoverable. A successful sync request updates local queue status before refreshing the workspace. Browser regression holds the queued response and verifies disabled state and automatic completion without reload. + +Generated escalation steps support Everyone on the team in addition to schedules and people. The optional team_all flag defaults to false for existing drafts; team recipients do not resolve an individual binding. They compile to Recipient::TeamAll and JSM uses the configured workspace integration team ID with notifyType=all. Delay and acknowledgment condition remain explicit. Generator, wire-contract and browser regressions cover the 30-minute team step and legacy recipients. + +## Layered generation map and value provenance + +The read-only map uses three separated horizontal bands: variables, generators, resources. Generator columns follow saved execution order; variables sort by first consumer, and generated resources occupy their producer column. Manual resources are included in the resource band with explicit manual attribution. Shapes distinguish bands; resource colors distinguish schedules, escalations and routes. Expanding output groups retains these bands. + +Server preview provides binding provenance using the same resolver as generation: source bindings, iteration sources, lookup keys, list items, intermediate path values, final values, node identities and errors. The browser presents this captured snapshot rather than re-evaluating scheduling logic. An inspector popover supports history, source navigation and per-pass examples. Dotted address segments are independently colored, with neutral separators; quoted keys containing dots remain intact. Generated names retain separate bound and literal affix segments, and resource fields link to their binding or explicit default/manual origin. Clicking the map backdrop closes it; clicks on the canvas only clear selection. Tests cover provenance, layout, manual resources, source exploration and safe text rendering. + +The value explorer renders source traversal as ordered monospace steps with neutral arrows and a selected-step indicator. Source navigation is a separate footer action; producer fields are independent choices rather than sequential path steps. Browser coverage checks stage values and separate source navigation. + +## Portable generation configuration + +[meguri/v2](portable-config.md) defines compact JSON import/export (with legacy v1 import support) for variables and generators only. Merge by name retains omitted inputs; nested identity keys preserve resource identity across editing and repeated imports. People transfer by email, manual resource references by exact name, and generated links by generator/block. The server provides a read-only parsed/expanded preview and revalidates before saving the shared draft with workspace and draft revisions. Provider writes require a later explicit Sync. Tests cover cross-workspace remapping, repeated imports, unknown fields, scoped permissions, stale previews, email contacts, parse errors and browser application. + +### Compact portable configuration + +`meguri/v2` is the default export: ordinary JSON variables, typed wrappers only for special scalar types, compact binding references, flattened output fields, readable output keys and omitted semantic defaults. The v1 importer remains supported. Inactive binding metadata must never be exported. Human output keys persist in shared drafts; list entries reconcile by unique name/value first and position for unmatched edits. The preview remains authoritative for identity-changing edits. Regression checks compare expanded resources, repeat imports and actual size reductions. + +The Import / Export dialog includes a localized Guide tab with a complete compact v2 reference, defaults, binding scope, operations, limits, merge semantics and a runnable example covering all output types. Copy guide copies every section as Markdown for hand editing or LLM use, without workspace data. Switching tabs preserves import text and does not apply it. Browser checks verify copying, sample preview, tab preservation and mobile layout. + +Generation escalation steps can notify a random member of the workspace provider team. The provider chooses the recipient when executing the step; this is neither a random schedule nor sequential notification of all colleagues. + +## Account-owned person + +Every login account owns a Person with the same stable ID. An account is the authentication/access extension of that person, not a second scheduling identity. Email-only people remain contacts without login credentials or membership. Workspace documents contain versioned Person snapshots; the account's name, email and country are authoritative and cannot be edited through a workspace contact form, and every regular workspace save refreshes the snapshots of the members. An account's email is optional (password users may have none; SSO accounts take it from the provider) and is not unique: an email shared with a contact links only when exactly one unlinked contact matches (see Access and administration), otherwise nothing is merged. An account that loses access (its groups changed or its workspace was disabled) keeps its person and the link, so schedules and history stay valid; it simply stops being a member and can no longer approve. An unverified or ambiguous email never grants access or merges login accounts. diff --git a/docs/testing.md b/docs/testing.md index d8cef2a..3e4964c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -35,6 +35,14 @@ Scheduling errors are product failures. A change to ownership, time boundaries, ## Running and extending +JSM regressions validate both plain and `og-` team UUIDs, reject malformed/path-like identifiers, and publish/recover against a paginated fake containing more than 50 unmanaged schedules. The fake rejects oversized Operations pages and preserves the prefixed team path. + +Verify readable names for schedules, escalations and routing, rename without duplicate creation, and legacy-name recovery without a checkpoint. A changed legacy resource must still trigger drift protection. + +Exact-time JSM coverage tests model half-hour rounding for rotations and millisecond precision for overrides. Verify arbitrary future boundaries, an ongoing shift exported without a past-start override, exact termination of its base coverage, recovery after an override create succeeds but its response fails, manual override conflicts, replacement/removal, expired coverage and owned parent deletion. Compare the live final timeline on an isolated temporary disabled schedule, then remove it. + +Before an immediate worker tick, integration fixtures wait until their latest database-generated history timestamp is effective on the worker clock. This tolerates bounded container/host clock skew without treating a future revision as already active; skew of two seconds or more fails the fixture explicitly. + `cargo test` runs pure tests and explicitly reports database tests as ignored. With `MEGURI_TEST_DATABASE_URL` pointing at a disposable PostgreSQL 17 database whose user has CREATEDB, CI and developers run everything with `cargo test --locked --tests -- --ignored --test-threads=1`. Scheduling fixtures create uniquely keyed workspaces and users through the real sync and group path and never truncate shared tables; tests whose subject is whole-instance state (sync, bootstrap, administration, SSO) use `IsolatedDatabase`, a uniquely named database dropped at the end, because sync disables every unconfigured workspace. Run serially because worker tests inspect the global queue. Worker tests explicitly prioritize only their own job so a bounded batch cannot skip it because of jobs left by a prior run. `tests/oidc.rs` installs process-wide configuration and therefore holds one test function. The fake models JSM responses and is not a substitute for live acceptance. New endpoints need verification against official documentation, a wire assertion, and a failure/recovery test. Do not make the fake accept an invented contract to get tests green. @@ -121,7 +129,7 @@ Variable editing uses a bounded, progressively disclosed tree: only the root sta Generation map opens from Generation rules as a read-only, draggable/zoomable Cytoscape canvas. Server preview returns actual iteration counts, input contexts, predicate outcomes and produced IDs; no scheduling evaluation is duplicated in JavaScript. Iterated outputs start collapsed into a group; inspecting a node or pass reveals source data and verified resources. Invalid drafts show diagnostics rather than stale resources. The map is a captured draft preview refreshed explicitly, and never applies changes. Bundled Cytoscape 3.33.1 is served locally with its MIT license; no CDN or new framework is required. Browser coverage checks groups, details, source navigation, pan/zoom and mobile layout; engine regression verifies traced pass results. -The generation map arranges numbered rules in saved execution order from left to right, joined by solid sequence arrows. Each rule owns a vertical column of iterations, conditions and outputs. Dashed secondary edges show data dependencies rather than execution order. Repeated output groups use offset background cards; decorative layers follow dragging and do not receive selection. Browser checks verify rule ordering, aligned output columns and stack movement. +The generation map arranges numbered rules in saved execution order from left to right, joined by solid sequence arrows. Variables occupy the upper band in first-consumer order, rules and their iterations and conditions occupy the middle band, and outputs occupy their producer columns in the lower resource band. Dashed edges show data dependencies. Repeated output groups expand into individual resources within the resource band. Browser checks verify rule ordering, band separation, output expansion and manual resources. Map input details display evaluated server values for each block and pass, never serialized bindings. Shared values are deduplicated across passes; differing values are explicitly counted. Generated references collapse to type/name cards, and people resolve by display name. Empty binding metadata and raw reference UUIDs are not presented. Condition operands and escalation recipients/delays come from the same resolver used during expansion. @@ -134,3 +142,59 @@ Expanded map resources use type-specific labeled fields and the same compact ref Authorization boundaries are product failures like scheduling ones. A change to configuration sync, group mapping, roles, SSO, bootstrap or administration needs a regression in `tests/access.rs`, `tests/oidc.rs` or the pure tests. Existing cases pin: exact (not prefix/case-insensitive) group matching; membership removal when a group or workspace disappears, including for open sessions; instance administrators acting on workspaces without membership and being audited under their own ID; employees unable to administer integrations or members; the employee permission set applying to existing and future employees at once, with `view` mandatory and administrator-only permissions refused; refusal of a configuration that would disable everything and of ambiguous adoption before any write; bootstrap passwords equal for replicas in one UTC hour, rotated (ending sessions) in the next, and locked out when the block is removed; no SSO account takeover by a bootstrap name; SSO accounts created only when groups map, revoked at the next sign-in, never given a password; administrators unable to edit their own groups or SSO groups; deletion only of disabled, unconfigured workspaces with the typed key; contact linking rewriting the current document while history keeps the old ID and audit still resolves it, skipping ambiguous or conflicting data and never granting access. The browser access suite also runs against the real demo. No test may weaken these checks to make a failing sign-in pass; fix the cause or record the limitation. + +JSM deletion regressions remove checkpointed schedules (including coverage), escalations, routes and individual managed overrides, then republish with drift blocking enabled. Verify replacement IDs, refreshed dependent references, lost replacement-response recovery, no child reads against missing parents, and an unchanged subsequent publication without duplicate writes. + +## Explicit publication and shared drafts + +Saving workspace configuration, approving replacements, importing data and editing generator drafts never authorize provider writes. These changes persist in PostgreSQL and are visible to other authorized members. Ordinary unfinished modal forms remain local until Save. Generator drafts autosave separately, including invalid intermediate values, with their own optimistic revision and workspace scope. + +The top Review changes panel compares desired resources with the last successfully published document. Its Sync button is yellow when unpublished changes exist. Sync validates/applies the shared generator draft and explicitly authorizes a specific workspace revision; stale reviews fail. Provider status, errors and create/update/remove counts live in this panel. Failed authorized publications may retry the same snapshot; newer saved changes must never enter those retries. Successful jobs do not automatically refresh their horizon: the displayed coverage end requires a further explicit sync to extend it. + +The worker persists the authorized document and publishes that snapshot, not the newest workspace document. A click while a publication lease is active is rejected. Existing jobs are paused by the manual-publication migration until an explicit click. Published snapshots survive history pruning. Tests cover no writes before a click, pinned retries across later edits, no periodic writes after success, scoped shared-draft persistence, stale concurrent edits, resource review against the published baseline, and the browser workflow across sessions. + +`browser-manual-sync.cjs` uses isolated accounts and workspaces with the mock provider. It checks that saved manual resources and generated resources appear together in the publication review, remain unpublished across worker ticks, survive logout/login, and can be published by another authorized member. Desktop/mobile screenshots cover the yellow pending button and its cleared state after verification. + +## Compact recurring provider rotations + +A plain base rotation whose effective published coverage matches its ordered calendar-day cycle is exported to JSM as one finite recurring rotation named after the schedule. The engine supplies the current cycle phase, ordered participants and calendar-day shift length; the provider only translates these fields to daily/weekly API values. The remote schedule ID and ownership marker stay unchanged. A publication replaces the old per-shift rotations rather than accumulating new ones. Native boundary precision must be representable; irregular effective coverage and replacements retain the exact interval export. The canonical expanded timeline remains available for verification and preview. Tests cover mid-shift starts, pre-anchor phases, participant ordering, DST, replacements, migration of an existing owned schedule and idempotent repeated publication. + +When an owned JSM schedule has one current and one desired rotation, edits preserve the rotation ID through the dedicated rotation PATCH endpoint. Schedule metadata is patched without a rotations array. Verified intermediate writes are checkpointed; a durable pending-step snapshot recovers a lost response without mistaking that exact planned state for external drift. Tests model nested rotation replacement by schedule PATCH and require stable rotation identity across participant reorder, rename, retry and unchanged republish. Historical rows already retained by JSM are not deleted by this repair. + +Workspace state save/reload controls live beside Sync in the top Review changes panel. The localized hint distinguishes shared draft autosave, validated local application, and explicit publication; Sync includes application, so a separate save is optional. Reload retrieves the shared draft, not a historical rollback. Removing a variable or generation rule requires confirmation before mutating or autosaving the draft. Browser regression covers cancellation, confirmation, top-panel controls and narrow layout. + +JSM coverage rotation names use the schedule name, including priority coverage and the exact-time base rotation. Multiple finite rotations append the interval start in the schedule IANA timezone with date, time and UTC offset to distinguish DST folds. This is presentation only; ownership markers, coverage, checkpoints and explicit publication remain unchanged. Existing rotation names update on the next authorized synchronization. + +Manual and generated routing editors expose the existing greater-than and less-than operations. Priority follows alert severity (P1 highest); P3 and above is represented as less-than P3 with negate=true, including the boundary. The editors show comparison controls without an additional priority instruction; the provider wire contract is unchanged. + +Publication status exposes the lease expiry and a server-calculated publication_busy flag. Sync is disabled for an active lease, not merely the persisted publishing state. The review panel explains restart recovery and expired leases; normal polling refreshes eligibility. Restart never clears a live lease. PostgreSQL regression checks active-lease rejection and explicit reauthorization after expiry. + +Nested binding paths retain the raw input text while typing, including trailing spaces and slash separators. The parsed path still updates live for shared drafts and server previews. Reopening shows the normalized path; changing source resets it. Browser regression types a nested list path character by character and checks its resolved value, edits, reopening and source reset. + +JSM omits `repeat` when escalation repeats are disabled. Read-back and drift comparison accept an absent repeat field only for an expected escalation with `waitInterval: 0` (the provider disabled representation). Present repeat objects and enabled repeats remain strictly compared. Fake-server regressions reproduce omission, recovery without a checkpoint, idempotence and detection of externally enabled repeats. No overwrite policy is needed to recover an otherwise matching escalation. + +Sync immediately shows submitting feedback, then stays disabled for an authorized pending job as well as an active publication lease. Pending publication has a visible queue message. The UI polls every two seconds while queued or leased and keeps the twenty-second idle refresh; expired leases remain manually recoverable. A successful sync request updates local queue status before refreshing the workspace. Browser regression holds the queued response and verifies disabled state and automatic completion without reload. + +Generated escalation steps support Everyone on the team in addition to schedules and people. The optional team_all flag defaults to false for existing drafts; team recipients do not resolve an individual binding. They compile to Recipient::TeamAll and JSM uses the configured workspace integration team ID with notifyType=all. Delay and acknowledgment condition remain explicit. Generator, wire-contract and browser regressions cover the 30-minute team step and legacy recipients. + +## Layered generation map and value provenance + +The read-only map uses three separated horizontal bands: variables, generators, resources. Generator columns follow saved execution order; variables sort by first consumer, and generated resources occupy their producer column. Manual resources are included in the resource band with explicit manual attribution. Shapes distinguish bands; resource colors distinguish schedules, escalations and routes. Expanding output groups retains these bands. + +Server preview provides binding provenance using the same resolver as generation: source bindings, iteration sources, lookup keys, list items, intermediate path values, final values, node identities and errors. The browser presents this captured snapshot rather than re-evaluating scheduling logic. An inspector popover supports history, source navigation and per-pass examples. Dotted address segments are independently colored, with neutral separators; quoted keys containing dots remain intact. Generated names retain separate bound and literal affix segments, and resource fields link to their binding or explicit default/manual origin. Clicking the map backdrop closes it; clicks on the canvas only clear selection. Tests cover provenance, layout, manual resources, source exploration and safe text rendering. + +The value explorer renders source traversal as ordered monospace steps with neutral arrows and a selected-step indicator. Source navigation is a separate footer action; producer fields are independent choices rather than sequential path steps. Browser coverage checks stage values and separate source navigation. + +## Portable generation configuration + +[meguri/v2](portable-config.md) defines compact JSON import/export (with legacy v1 import support) for variables and generators only. Merge by name retains omitted inputs; nested identity keys preserve resource identity across editing and repeated imports. People transfer by email, manual resource references by exact name, and generated links by generator/block. The server provides a read-only parsed/expanded preview and revalidates before saving the shared draft with workspace and draft revisions. Provider writes require a later explicit Sync. Tests cover cross-workspace remapping, repeated imports, unknown fields, scoped permissions, stale previews, email contacts, parse errors and browser application. + +### Compact portable configuration + +`meguri/v2` is the default export: ordinary JSON variables, typed wrappers only for special scalar types, compact binding references, flattened output fields, readable output keys and omitted semantic defaults. The v1 importer remains supported. Inactive binding metadata must never be exported. Human output keys persist in shared drafts; list entries reconcile by unique name/value first and position for unmatched edits. The preview remains authoritative for identity-changing edits. Regression checks compare expanded resources, repeat imports and actual size reductions. + +The Import / Export dialog includes a localized Guide tab with a complete compact v2 reference, defaults, binding scope, operations, limits, merge semantics and a runnable example covering all output types. Copy guide copies every section as Markdown for hand editing or LLM use, without workspace data. Switching tabs preserves import text and does not apply it. Browser checks verify copying, sample preview, tab preservation and mobile layout. + +Random team escalation regression coverage includes generator compilation with an unused invalid binding, conflicting recipient flags, portable v1/v2 round trips, fake-JSM `notifyType: random`, and browser selection/shared-draft reload. + +Contact approval regressions cover account-to-contact immediate application, contact-to-account pending approval, contact-only absence planning by an editor, actor permissions, and unchanged account/membership counts. Existing regressions retain account-holder approval, swap and workspace isolation checks. `tests/browser-contact-approval.cjs` verifies immediate application without a force flag, readable preview, and calendar persistence. diff --git a/package.json b/package.json index 1b8b22a..149e248 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "meguri-browser-tests", "private": true, "scripts": { - "test:browser": "node tests/browser-generation-map.cjs && node tests/browser-variable-editor.cjs && node tests/browser-rule-result.cjs && node tests/browser-repeat-defaults.cjs && node tests/browser-binding-popover.cjs && node tests/browser-resource-details.cjs && node tests/browser-coverage-status.cjs && node tests/browser-diagnostics.cjs && node tests/browser-advanced-demos.cjs && node tests/browser.cjs && node tests/browser-calendars.cjs && node tests/browser-configuration.cjs && node tests/browser-absence.cjs && node tests/browser-generation.cjs && node tests/browser-cover.cjs && node tests/browser-navigation.cjs && node tests/browser-binding-preview.cjs && node tests/browser-generation-scroll.cjs && node tests/browser-setup-layout.cjs && node tests/browser-access.cjs" + "test:browser": "node tests/browser-account-person.cjs && node tests/browser-contact-approval.cjs && node tests/browser-portable-config.cjs && node tests/browser-generation-team.cjs && node tests/browser-binding-path.cjs && node tests/browser-manual-sync.cjs && node tests/browser-generation-map.cjs && node tests/browser-variable-editor.cjs && node tests/browser-rule-result.cjs && node tests/browser-repeat-defaults.cjs && node tests/browser-binding-popover.cjs && node tests/browser-resource-details.cjs && node tests/browser-coverage-status.cjs && node tests/browser-diagnostics.cjs && node tests/browser-advanced-demos.cjs && node tests/browser.cjs && node tests/browser-calendars.cjs && node tests/browser-configuration.cjs && node tests/browser-absence.cjs && node tests/browser-generation.cjs && node tests/browser-cover.cjs && node tests/browser-navigation.cjs && node tests/browser-binding-preview.cjs && node tests/browser-generation-scroll.cjs && node tests/browser-setup-layout.cjs && node tests/browser-access.cjs" }, "devDependencies": { "playwright": "1.62.1" diff --git a/src/admin.rs b/src/admin.rs index 0fe0da3..d3de162 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -185,6 +185,9 @@ pub async fn create_password_user( } let id = store::uuid(&row.id)?; apply_access(db, config, id, Some(groups.clone())).await?; + if let Err(error) = crate::identity::link_contacts(db, id).await { + tracing::warn!(user = %id, %error, "could not add the new account to its workspaces"); + } db.instance_event( actor, "user.created", @@ -239,6 +242,9 @@ pub async fn set_user_groups( let before = editable_target(db, actor, target).await?; let groups = checked_groups(config, groups)?; apply_access(db, config, target, Some(groups.clone())).await?; + if let Err(error) = crate::identity::link_contacts(db, target).await { + tracing::warn!(user = %target, %error, "could not add the account to its workspaces"); + } db.instance_event( Some(actor), "user.groups_changed", diff --git a/src/demo.rs b/src/demo.rs index 99ea33f..23683d4 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -334,7 +334,7 @@ pub async fn seed(db: &Store) -> Result<()> { username: username.into(), display_name: display.into(), password: notice.password.clone(), - email: None, + email: Some(format!("{username}@example.com")), groups: groups.iter().map(|g| (*g).to_owned()).collect(), country: Some(country.into()), }, @@ -470,6 +470,7 @@ async fn seed_workspace( _ => service_tiers(monday, alice, carol), }; let mut draft = Draft { + shared_revision: None, revision: workspace.revision, variables, rules, diff --git a/src/demo_runtime.rs b/src/demo_runtime.rs index f1308c9..45d8f49 100644 --- a/src/demo_runtime.rs +++ b/src/demo_runtime.rs @@ -200,7 +200,7 @@ pub async fn reset(db: &Database) -> Result<()> { LOCK TABLE public.meguri_runtime_guard IN EXCLUSIVE MODE; IF NOT EXISTS(SELECT 1 FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo') THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF; IF EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND backend_type='client backend' AND pid<>pg_backend_pid() AND application_name<>current_setting('application_name')) THEN RAISE EXCEPTION 'Demo database has another active client; stop the other process before restarting'; END IF; - SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins','meguri__instance_audit','meguri__instance_state','meguri__oidc_flows']) name WHERE to_regclass('public.'||name) IS NOT NULL; + SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__workspace_drafts','meguri__workspaces','meguri__workspace_members','meguri__workspace_history','meguri__sessions','meguri__external_identities','meguri__team_members','meguri__tenant_members','meguri__team_history','meguri__audit_events','meguri__sync_jobs','meguri__provider_objects','meguri__teams','meguri__tenants','meguri__users','meguri__demo_pins','meguri__instance_audit','meguri__instance_state','meguri__oidc_flows']) name WHERE to_regclass('public.'||name) IS NOT NULL; IF tables IS NOT NULL THEN EXECUTE 'TRUNCATE '||tables||' RESTART IDENTITY'; END IF; UPDATE public.meguri_runtime_guard SET started_at=clock_timestamp() WHERE id; END $$;").await?; @@ -248,7 +248,7 @@ requests AS ( UPDATE meguri__workspaces SET document=jsonb_set(document::jsonb,'{requests}',COALESCE((SELECT jsonb_agg(r) FROM jsonb_array_elements(document::jsonb->'requests') r WHERE (r->>'created_at')::timestamptz>$1-make_interval(secs=>$4::bigint::double precision)),'[]'::jsonb))::text,revision=revision+1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND id NOT IN(SELECT id FROM expired) AND EXISTS(SELECT 1 FROM jsonb_array_elements(document::jsonb->'requests') r WHERE (r->>'created_at')::timestamptz<=$1-make_interval(secs=>$4::bigint::double precision)) RETURNING * ), history AS (INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at) SELECT id||':'||revision::text,id,revision,document,$1 FROM requests), -request_jobs AS (INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'pending',0,$1 FROM requests ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',next_attempt=$1) +request_jobs AS (INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'draft',0,$1 FROM requests ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision) INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT id,'demo.requests_expired','{}',$1 FROM requests; "#; const PURGE: &str = r#" diff --git a/src/domain.rs b/src/domain.rs index 7e3e946..842e275 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -45,20 +45,25 @@ impl Permission { pub struct User { #[serde(default)] pub language: Option, - pub id: Uuid, + /// Canonical account-owned person; email is required by account persistence. + #[serde(flatten)] + pub person: Person, pub username: String, - pub display_name: String, - pub country: String, /// None follows the browser timezone; this never changes scheduling rules. pub timezone: Option, #[serde(default)] - pub email: Option, - #[serde(default)] pub instance_admin: bool, #[serde(default)] pub has_password: bool, } +impl std::ops::Deref for User { + type Target = Person; + fn deref(&self) -> &Person { + &self.person + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Member { pub user: User, @@ -191,9 +196,7 @@ impl WorkspaceDocument { )?; validate_name(&person.display_name)?; ensure( - person - .user_id - .is_none_or(|id| id == person.id && members.contains(&id)), + person.user_id.is_none_or(|id| id == person.id), crate::i18n::text("err_choose_a_user_who_has_access_to_this_workspace_41cf15"), )?; ensure( @@ -450,7 +453,8 @@ impl WorkspaceDocument { } = &self.integration { ensure( - Uuid::parse_str(cloud_id).is_ok() && Uuid::parse_str(team_id).is_ok(), + Uuid::parse_str(cloud_id).is_ok() + && Uuid::parse_str(team_id.strip_prefix("og-").unwrap_or(team_id)).is_ok(), crate::i18n::text("err_jsm_cloud_and_team_ids_must_be_uuids_f75b5f"), )?; ensure( @@ -726,3 +730,12 @@ pub struct Timeline { pub to: DateTime, pub assignments: Vec, } + +/// A compact representation of a verified calendar-day cycle, independent of providers. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecurringRotation { + pub start: DateTime, + pub end: DateTime, + pub shift_days: u16, + pub participants: Vec, +} diff --git a/src/engine.rs b/src/engine.rs index 2dec5f0..4f54a5b 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -170,6 +170,63 @@ pub fn timeline( }) } +/// Compress only coverage identical to the base cycle. History or approved +/// replacements that change any effective span must retain their exact timeline. +pub fn recurring_rotation( + doc: &WorkspaceDocument, + schedule: &Schedule, + periods: &[Assignment], +) -> Result> { + let ScheduleKind::Rotation { + participants, + anchor, + shift_days, + } = &schedule.kind + else { + return Ok(None); + }; + let (Some(first), Some(last)) = (periods.first(), periods.last()) else { + return Ok(None); + }; + let mut base = doc.clone(); + base.schedules = vec![schedule.clone()]; + base.requests.clear(); + let expected = timeline(&base, first.start, last.end)?; + // Compare coverage at every interval boundary; reason labels and redundant + // adjacent spans do not change who is on call. + if periods.windows(2).any(|p| p[0].end != p[1].start) + || periods.iter().any(|p| p.start >= p.end || p.unavailable) + { + return Ok(None); + } + for p in &expected.assignments { + if periods + .iter() + .filter(|a| a.start < p.end && p.start < a.end) + .any(|a| a.user_id != p.user_id || a.unavailable != p.unavailable) + { + return Ok(None); + } + } + let day = schedule_duty_day(doc, schedule.id, first.start)?; + let shift = (day - *anchor) + .num_days() + .div_euclid(i64::from(*shift_days)); + let start = local_time( + doc, + *anchor + Duration::days(shift * i64::from(*shift_days)), + schedule.handover, + )?; + let mut ordered = participants.clone(); + ordered.rotate_left(shift.rem_euclid(participants.len() as i64) as usize); + Ok(Some(RecurringRotation { + start, + end: last.end, + shift_days: *shift_days, + participants: ordered, + })) +} + fn resolve( doc: &WorkspaceDocument, id: Uuid, @@ -834,4 +891,60 @@ mod tests { assert!(restored.effective_legs().is_empty()); assert_eq!(restored.legs.len(), 2); } + #[test] + fn compact_cycle_preserves_phase_calendar_days_and_rejects_changed_coverage() { + let (mut doc, people, id, _) = fixture(); + doc.timezone = "Europe/Bucharest".into(); + doc.schedules.truncate(1); + for (start, end, expected_start, first_person) in [ + ( + "2026-03-10T12:00:00Z", + "2026-04-20T08:00:00Z", + "2026-03-09T09:00:00Z", + people[2], + ), + ( + "2026-10-20T12:00:00Z", + "2026-11-23T09:00:00Z", + "2026-10-19T08:00:00Z", + people[1], + ), + ] { + let from = start.parse().unwrap(); + let to = end.parse().unwrap(); + let periods = timeline(&doc, from, to).unwrap().assignments; + let compact = recurring_rotation(&doc, &doc.schedules[0], &periods) + .unwrap() + .unwrap(); + assert_eq!( + compact.start, + expected_start.parse::>().unwrap() + ); + assert_eq!(compact.participants[0], first_person); + let zone: chrono_tz::Tz = doc.timezone.parse().unwrap(); + for p in &periods { + let days = (p.start.with_timezone(&zone).date_naive() + - compact.start.with_timezone(&zone).date_naive()) + .num_days(); + assert_eq!(p.user_id, compact.participants[(days / 7) as usize % 3]); + } + assert!( + periods + .windows(2) + .any(|p| (p[0].end - p[0].start).num_hours() != 168) + ); + let mut changed = periods.clone(); + changed[1].user_id = people + .iter() + .copied() + .find(|p| *p != changed[1].user_id) + .unwrap(); + assert!( + recurring_rotation(&doc, &doc.schedules[0], &changed) + .unwrap() + .is_none() + ); + assert_eq!(periods[0].schedule_id, id); + } + } } diff --git a/src/generation.rs b/src/generation.rs index 9737750..e41b9ab 100644 --- a/src/generation.rs +++ b/src/generation.rs @@ -84,6 +84,10 @@ pub struct Step { #[serde(default)] pub user: bool, #[serde(default)] + pub team_all: bool, + #[serde(default)] + pub team_random: bool, + #[serde(default)] pub condition: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -97,6 +101,8 @@ pub struct Condition { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(from = "BlockWire")] pub struct Block { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub portable_key: Option, pub id: Uuid, pub kind: String, pub fields: BTreeMap, @@ -107,6 +113,8 @@ pub struct Block { } #[derive(Deserialize)] struct BlockWire { + #[serde(default)] + pub portable_key: Option, pub id: Uuid, pub kind: String, pub fields: BTreeMap, @@ -133,6 +141,7 @@ impl From for Block { ); } Self { + portable_key: value.portable_key, id: value.id, kind: value.kind, fields: value.fields, @@ -506,6 +515,38 @@ impl Resolver<'_> { } Ok(node) } + fn provenance(&self, b: &Binding, iterations: &[Iteration], depth: usize) -> Value { + if depth > 16 { + return Value::Null; + } + let mut source = b.clone(); + source.path.clear(); + source.prefix.clear(); + source.suffix.clear(); + let snapshot = |binding: &Binding| match self.eval(binding, 0) { + Ok(node) => json!({"value":node.json(),"node_id":node.id}), + Err(error) => json!({"error":error.to_string()}), + }; + let mut stages = vec![json!({"path":[],"result":snapshot(&source)})]; + for segment in &b.path { + source.path.push(segment.clone()); + stages.push(json!({"path":source.path,"result":snapshot(&source)})); + } + let mut parents = vec![]; + if matches!(b.kind.as_str(), "item" | "key") + && let Some(iteration) = iterations.iter().find(|i| Some(i.id) == b.iteration) + { + parents.push(self.provenance(&iteration.source, iterations, depth + 1)); + } + if let Some(key) = &b.key { + parents.push(self.provenance(key, iterations, depth + 1)); + } + for item in &b.items { + parents.push(self.provenance(item, iterations, depth + 1)); + } + json!({"binding":b,"stages":stages,"result":snapshot(b),"parents":parents}) + } + fn value(&self, b: &Binding) -> Result { Ok(self.eval(b, 0)?.json()) } @@ -730,7 +771,14 @@ fn render( &[("p0", (i + 1).to_string())], )) })?; - let target = resolver.value(&step.recipient)?; + ensure( + [step.user, step.team_all, step.team_random] + .into_iter() + .filter(|v| *v) + .count() + <= 1, + crate::i18n::text("generation_recipient_conflict"), + )?; steps.push(EscalationStep { delay_minutes: delay as u16, condition: if step.condition == "if-not-closed" { @@ -738,13 +786,17 @@ fn render( } else { EscalationCondition::IfNotAcked }, - recipient: if step.user { + recipient: if step.team_all { + Recipient::TeamAll + } else if step.team_random { + Recipient::TeamRandom + } else if step.user { Recipient::User { - id: recipient(&target, workspace, doc, members)?, + id: recipient(&resolver.value(&step.recipient)?, workspace, doc, members)?, } } else { Recipient::Schedule { - id: id_value(target)?, + id: id_value(resolver.value(&step.recipient)?)?, } }, }); @@ -1014,7 +1066,20 @@ pub fn expand( } else { format!("{path} / {}", context.labels.join(" / ")) }; - let mut row_trace = json!({"labels":context.labels,"inputs":context.items.iter().map(|(id,n)|(id.to_string(),n.json())).collect::>(),"status":"included","resources":[],"blocks":[]}); + let bindings: Vec<_> = rule + .iterations + .iter() + .map(|i| &i.source) + .chain(rule.when.iter().flat_map(|p| [&p.left, &p.right])) + .chain(rule.blocks.iter().flat_map(|b| { + b.fields + .values() + .chain(b.steps.iter().flat_map(|s| [&s.delay, &s.recipient])) + .chain(b.conditions.iter().map(|c| &c.value)) + })) + .map(|b| resolver.provenance(b, &rule.iterations, 0)) + .collect(); + let mut row_trace = json!({"bindings":bindings,"labels":context.labels,"inputs":context.items.iter().map(|(id,n)|(id.to_string(),n.json())).collect::>(),"status":"included","resources":[],"blocks":[]}); let managed_start = managed.len(); let row_issue_start = issues.len(); if let Some(predicate) = &rule.when { @@ -1051,7 +1116,7 @@ pub fn expand( row_trace["blocks"].as_array_mut().unwrap().push(json!({ "block":block.id, "fields":block.fields.iter().map(|(key,b)|(key.clone(),resolved(b))).collect::>(), - "steps":block.steps.iter().map(|s|json!({"delay":resolved(&s.delay),"recipient":resolved(&s.recipient),"condition":s.condition})).collect::>(), + "steps":block.steps.iter().map(|s|json!({"delay":resolved(&s.delay),"recipient":if s.team_all {json!(crate::i18n::text("everyone_on_the_team_235b55"))} else if s.team_random {json!(crate::i18n::text("a_random_team_member_8f35f3"))} else {resolved(&s.recipient)},"condition":s.condition})).collect::>(), "conditions":block.conditions.iter().map(|c|json!({"field":c.field,"operation":c.operation,"value":resolved(&c.value),"negate":c.negate})).collect::>() })); if manual_ids.contains(&id) { diff --git a/src/generation_service.rs b/src/generation_service.rs index 67d622f..ac70336 100644 --- a/src/generation_service.rs +++ b/src/generation_service.rs @@ -8,12 +8,14 @@ use crate::{ store::{Store, WorkspaceRecord}, }; use chrono::{Duration, Utc}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use uuid::Uuid; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Draft { pub revision: i64, + #[serde(default)] + pub shared_revision: Option, pub variables: Vec, pub rules: Vec, #[serde(default)] @@ -32,6 +34,7 @@ async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Res .prepare(workspace, draft.revision, actor, Permission::EditSchedules) .await?; let directory = db.directory(workspace).await?; + crate::store::account_people(&mut record.document, &directory)?; let members = directory.iter().map(|u| u.id).collect(); let now = Utc::now(); let mut expanded = generation::expand( @@ -61,14 +64,7 @@ async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Res .collect(); for user in &directory { if used.contains(&user.id) && expanded.document.person(user.id).is_none() { - expanded.document.people.push(Person { - id: user.id, - user_id: Some(user.id), - display_name: user.display_name.clone(), - email: None, - country: Some(user.country.clone()), - aliases: vec![], - }); + expanded.document.people.push(user.person.clone()); } } if expanded.issues.is_empty() { @@ -123,7 +119,11 @@ async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Res draft.route_position ])) .to_string(); - let report = json!({"review_id":review,"changes":expanded.changes,"cancelled_requests":cancelled,"result":result,"trace":expanded.trace,"issues":issues}); + let publication_changes = crate::store::publication_changes( + &expanded.document, + db.published_document(workspace).await?.as_ref(), + )?; + let report = json!({"shared_revision":draft.shared_revision,"publication_changes":publication_changes,"review_id":review,"changes":expanded.changes,"cancelled_requests":cancelled,"result":result,"trace":expanded.trace,"issues":issues}); record.document = expanded.document; Ok(Prepared { report, @@ -162,3 +162,104 @@ pub async fn apply(db: &Store, actor: Uuid, workspace: Uuid, draft: Draft) -> Re .await?; Ok(json!({"ok":true})) } + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Transfer { + pub revision: i64, + pub shared_revision: i64, + #[serde(default)] + pub text: String, + #[serde(default)] + pub review_id: Option, +} +async fn transfer_base( + db: &Store, + actor: Uuid, + workspace: Uuid, + request: &Transfer, +) -> Result<(WorkspaceRecord, Draft)> { + let record = db + .prepare( + workspace, + request.revision, + actor, + Permission::EditSchedules, + ) + .await?; + let shared = db.shared_draft(workspace).await?; + ensure( + shared["revision"].as_i64() == Some(request.shared_revision), + crate::i18n::text("err_shared_draft_stale"), + )?; + let mut draft: Draft = if shared["payload"].is_null() { + Draft { + revision: record.revision, + shared_revision: Some(request.shared_revision), + variables: record.document.generation.variables.clone(), + rules: record.document.generation.rules.clone(), + route_position: record.document.generation.route_position, + review_id: None, + } + } else { + serde_json::from_value(shared["payload"].clone())? + }; + draft.revision = record.revision; + draft.shared_revision = Some(request.shared_revision); + Ok((record, draft)) +} +pub async fn export_config( + db: &Store, + actor: Uuid, + workspace: Uuid, + request: Transfer, +) -> Result { + let (record, draft) = transfer_base(db, actor, workspace, &request).await?; + Ok( + json!({"text":crate::portable::format_config(&crate::portable::export(&record.document,&draft)?)}), + ) +} +pub async fn import_config( + db: &Store, + actor: Uuid, + workspace: Uuid, + request: Transfer, + apply: bool, +) -> Result { + let (record, base) = transfer_base(db, actor, workspace, &request).await?; + let (draft, inputs) = + crate::portable::import(workspace, &record.document, &base, &request.text)?; + let prepared = prepare(db, actor, workspace, &draft).await?; + let review = generation::stable_id(&json!([ + prepared.review, + request.shared_revision, + request.text + ])) + .to_string(); + if apply { + ensure( + prepared.issues.is_empty(), + crate::i18n::text("transfer_fix_errors"), + )?; + ensure( + request.review_id.as_deref() == Some(review.as_str()), + crate::i18n::text("err_the_preview_is_stale_check_the_updated_impact_be_85cacd"), + )?; + return db + .save_shared_draft( + workspace, + actor, + request.shared_revision, + serde_json::to_value(draft)?, + ) + .await; + } + let contacts: Vec<_> = prepared + .record + .document + .people + .iter() + .filter(|p| record.document.person(p.id).is_none() && p.user_id.is_none()) + .collect(); + Ok(json!({"review_id":review,"inputs":inputs,"contacts":contacts,"preview":prepared.report})) +} diff --git a/src/identity.rs b/src/identity.rs index 180d9e0..1269622 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -72,7 +72,7 @@ WITH workspace AS ( ), history AS ( INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':1',id,1,document,clock_timestamp(),NULL FROM workspace ), jobs AS ( - INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,1,'pending',0,now() FROM workspace + INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,1,'draft',0,now() FROM workspace ) INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.created',$4,now() FROM workspace "#; @@ -81,8 +81,8 @@ const ENABLE_WORKSPACE: &str = r#" WITH enabled AS ( UPDATE meguri__workspaces SET disabled_at=NULL WHERE id=$1 AND disabled_at IS NOT NULL AND demo_deleted_at IS NULL RETURNING id,revision ), jobs AS ( - INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'pending',0,now() FROM enabled - ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',attempts=0,next_attempt=now(),last_error=NULL + INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'draft',0,now() FROM enabled + ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision ) INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,NULL,'workspace.enabled','{}',now() FROM enabled "#; @@ -356,7 +356,11 @@ pub async fn reconcile_all(db: &Store, config: &Config) -> Result { if row.demo_deleted_at.is_some() { continue; } - apply_access(db, config, store::uuid(&row.id)?, None).await?; + let id = store::uuid(&row.id)?; + apply_access(db, config, id, None).await?; + if let Err(error) = link_contacts(db, id).await { + tracing::warn!(user = %id, %error, "could not refresh the account person in its workspaces"); + } count += 1; } Ok(count) @@ -735,9 +739,7 @@ pub async fn link_contacts(db: &Store, user: Uuid) -> Result<()> { .get(&db.db) .await? .ok_or(Error::NotFound)?; - let Some(email) = row.email.as_deref().and_then(normalize_email) else { - return Ok(()); - }; + let email = row.email.as_deref().and_then(normalize_email); for membership in query!(WorkspaceMemberRow,$user_id==id).all(&db.db).await? { let workspace = store::uuid(&membership.workspace_id)?; for _ in 0..3 { @@ -745,8 +747,11 @@ pub async fn link_contacts(db: &Store, user: Uuid) -> Result<()> { break; }; let members = db.member_ids(workspace).await?; - let Some((document, details)) = linked_document(&record.document, &row, user, &email) - else { + let changed = match &email { + Some(email) => linked_document(&record.document, &row, user, email), + None => account_person_added(&record.document, &row, user), + }; + let Some((document, details)) = changed else { break; }; record.document = document; @@ -767,6 +772,28 @@ pub async fn link_contacts(db: &Store, user: Uuid) -> Result<()> { Ok(()) } +/// Accounts without an email still own a Person in every workspace they reach, so planning +/// can use them at once; nothing else can be linked without an address. +fn account_person_added( + document: &WorkspaceDocument, + user: &UserRow, + user_id: Uuid, +) -> Option<(WorkspaceDocument, serde_json::Value)> { + if document.people.iter().any(|p| p.id == user_id) { + return None; + } + let mut linked = document.clone(); + linked.people.push(Person { + id: user_id, + user_id: Some(user_id), + display_name: user.display_name.clone(), + email: None, + country: Some(user.country.clone()), + aliases: vec![], + }); + Some((linked, json!({"user_id":user_id,"merged":false}))) +} + /// Pure transformation for [`link_contacts`]. `None` means nothing to change. pub fn linked_document( document: &WorkspaceDocument, @@ -789,13 +816,6 @@ pub fn linked_document( if contact.user_id.is_some() { return None; } - if document - .people - .iter() - .any(|p| p.id == user_id && p.email.as_deref().is_some_and(|e| e != email)) - { - return None; - } let old = contact.id; let mut working = document.clone(); working.people.retain(|p| p.id != user_id); @@ -816,10 +836,7 @@ pub fn linked_document( match document.people.iter().position(|p| p.id == user_id) { Some(index) => { let person = &document.people[index]; - if person.email.is_some() && person.user_id == Some(user_id) { - return None; - } - if person.email.as_deref().is_some_and(|e| e != email) { + if person.email.as_deref() == Some(email) && person.user_id == Some(user_id) { return None; } let mut linked = document.clone(); @@ -914,19 +931,17 @@ mod tests { taken.user_id = Some(Uuid::new_v4()); doc.people = vec![taken]; assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none()); - // The user's own person carries a different email: leave both alone. - doc.people = vec![ - Person { - id: user, - user_id: Some(user), - display_name: "Dana".into(), - email: Some("other@corp.test".into()), - country: None, - aliases: vec![], - }, - contact("dana@corp.test"), - ]; - assert!(linked_document(&doc, &row(), user, "dana@corp.test").is_none()); + // The account owns its person: when the sign-in email changed, the person follows it. + doc.people = vec![Person { + id: user, + user_id: Some(user), + display_name: "Dana".into(), + email: Some("old@corp.test".into()), + country: None, + aliases: vec![], + }]; + let (linked, _) = linked_document(&doc, &row(), user, "dana@corp.test").unwrap(); + assert_eq!(linked.people[0].email.as_deref(), Some("dana@corp.test")); } #[test] diff --git a/src/lib.rs b/src/lib.rs index ce0a8da..47e3f43 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,8 @@ pub mod identity; pub mod migrations; pub mod models; pub mod oidc; +pub mod portable; +mod portable_v2; pub mod providers; pub mod service; pub mod store; diff --git a/src/migrations.rs b/src/migrations.rs index 5affae6..353c6c6 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -7,6 +7,7 @@ pub mod m_0006_demo_lifecycle; pub mod m_0007_language; pub mod m_0008_workspaces; pub mod m_0009_access; +pub mod m_0010_manual_publication; pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[ &m_0001_initial::Migration, &m_0002_constraints::Migration, @@ -17,4 +18,5 @@ pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[ &m_0007_language::Migration, &m_0008_workspaces::Migration, &m_0009_access::Migration, + &m_0010_manual_publication::Migration, ]; diff --git a/src/migrations/m_0010_manual_publication.rs b/src/migrations/m_0010_manual_publication.rs new file mode 100644 index 0000000..3025215 --- /dev/null +++ b/src/migrations/m_0010_manual_publication.rs @@ -0,0 +1,22 @@ +use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op}; +#[derive(Debug, Copy, Clone)] +pub struct Migration; +impl cot::db::migrations::Migration for Migration { + const APP_NAME: &'static str = "meguri"; + const MIGRATION_NAME: &'static str = "m_0010_manual_publication"; + const DEPENDENCIES: &'static [MigrationDependency] = + &[MigrationDependency::migration("meguri", "m_0009_access")]; + const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()]; +} +#[migration_op] +async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> { + context.db.raw(r#" +DO $$ BEGIN +ALTER TABLE meguri__sync_jobs ADD COLUMN authorized_revision bigint, ADD COLUMN authorized_document text, ADD COLUMN published_document text; +UPDATE meguri__sync_jobs j SET published_document=h.document FROM meguri__workspace_history h WHERE h.workspace_id=j.workspace_id AND h.revision=j.published_revision; +UPDATE meguri__sync_jobs SET state=CASE WHEN published_revision=requested_revision THEN 'synced' ELSE 'draft' END,lease_token=NULL,lease_until=NULL; +CREATE TABLE meguri__workspace_drafts(workspace_id text PRIMARY KEY REFERENCES meguri__workspaces(id) ON DELETE CASCADE, revision bigint NOT NULL, payload text NOT NULL); +END $$; +"#).await?; + Ok(()) +} diff --git a/src/models.rs b/src/models.rs index f90622e..0d09d1d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -96,6 +96,9 @@ pub struct AuditRow { #[derive(Debug, Clone)] #[model(table_name = "sync_jobs")] pub struct SyncJobRow { + pub authorized_revision: Option, + pub authorized_document: Option, + pub published_document: Option, #[model(primary_key)] pub workspace_id: String, pub requested_revision: i64, @@ -140,6 +143,15 @@ pub struct InstanceStateRow { pub value: String, } +#[derive(Debug, Clone)] +#[model(table_name = "workspace_drafts")] +pub struct WorkspaceDraftRow { + #[model(primary_key)] + pub workspace_id: String, + pub revision: i64, + pub payload: String, +} + #[derive(Debug, Clone)] #[model(table_name = "instance_audit")] pub struct InstanceAuditRow { diff --git a/src/portable.rs b/src/portable.rs new file mode 100644 index 0000000..fcb077c --- /dev/null +++ b/src/portable.rs @@ -0,0 +1,738 @@ +//! Portable, non-executable generation documents. Import only prepares a shared draft. +use crate::{ + domain::*, + error::{Error, Result, ensure}, + generation::{self, Node, Rule, Variable}, + generation_service::Draft, +}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; +use uuid::Uuid; + +pub(super) fn fail(detail: impl ToString) -> Error { + Error::Validation(crate::i18n::message( + "transfer_invalid", + &[("detail", detail.to_string())], + )) +} +pub(super) fn object(v: &Value, allowed: &[&str]) -> Result<()> { + let o = v.as_object().ok_or_else(|| fail(v))?; + if let Some(k) = o.keys().find(|k| !allowed.contains(&k.as_str())) { + return Err(fail(k)); + } + Ok(()) +} +fn text(v: &Value) -> Result<&str> { + v.as_str() + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| fail(v)) +} +fn array(v: &Value) -> Result<&[Value]> { + v.as_array().map(Vec::as_slice).ok_or_else(|| fail(v)) +} +fn decode(v: Value) -> Result { + serde_json::from_value(v).map_err(fail) +} +fn normalized(s: &str) -> String { + s.trim().to_lowercase() +} +fn unique_names(values: impl Iterator) -> Result<()> { + let mut seen = BTreeSet::new(); + for name in values { + ensure( + !name.trim().is_empty() && seen.insert(normalized(&name)), + crate::i18n::message("transfer_duplicate", &[("name", name)]), + )?; + } + Ok(()) +} +fn email(doc: &WorkspaceDocument, value: &str) -> Result { + if email_address::EmailAddress::is_valid(value) { + return Ok(value.trim().to_lowercase()); + } + let found = Uuid::parse_str(value) + .ok() + .and_then(|id| doc.person(id)) + .and_then(|p| p.email.as_deref()); + found + .filter(|s| email_address::EmailAddress::is_valid(s)) + .map(|s| s.trim().to_lowercase()) + .ok_or_else(|| { + Error::Validation(crate::i18n::message( + "transfer_email_required", + &[( + "person", + doc.people + .iter() + .find(|p| p.id.to_string() == value) + .map(|p| p.display_name.clone()) + .unwrap_or_else(|| value.into()), + )], + )) + }) +} +fn resolve_email(doc: &WorkspaceDocument, value: &str) -> Result { + ensure( + email_address::EmailAddress::is_valid(value), + crate::i18n::message("transfer_email_required", &[("person", value.into())]), + )?; + let matches: Vec<_> = doc + .people + .iter() + .filter(|p| { + p.email + .as_ref() + .is_some_and(|e| e.eq_ignore_ascii_case(value.trim())) + }) + .collect(); + ensure( + matches.len() <= 1, + crate::i18n::message("transfer_duplicate", &[("name", value.into())]), + )?; + Ok(matches + .first() + .map(|p| json!(p.id)) + .unwrap_or_else(|| json!(value.trim().to_lowercase()))) +} +fn literal(doc: &WorkspaceDocument, value: &mut Value, role: &str, export: bool) -> Result<()> { + if let Some(a) = value.as_array_mut() { + for v in a { + literal(doc, v, role, export)?; + } + return Ok(()); + } + if value.is_null() || value.as_str() == Some("") { + return Ok(()); + } + if role.is_empty() { + if !export && value.get("$person").is_some() { + object(value, &["$person"])?; + *value = resolve_email(doc, text(&value["$person"])?)?; + } else if export + && value + .as_str() + .and_then(|s| Uuid::parse_str(s).ok()) + .is_some_and(|id| doc.person(id).is_some()) + { + *value = json!({"$person":email(doc,text(value)?)?}); + } else if let Some(fields) = value.as_object_mut() { + for field in fields.values_mut() { + literal(doc, field, "", export)?; + } + } + return Ok(()); + } + if role == "person" { + *value = if export { + json!(email(doc, text(value)?)?) + } else { + resolve_email(doc, text(value)?)? + }; + } else if matches!(role, "schedule" | "escalation") { + let resources: Vec<_> = if role == "schedule" { + doc.schedules + .iter() + .map(|s| (s.id, s.name.as_str())) + .collect() + } else { + doc.escalations + .iter() + .map(|s| (s.id, s.name.as_str())) + .collect() + }; + let input = text(value)?; + let found: Vec<_> = resources + .into_iter() + .filter(|(id, name)| { + if export { + id.to_string() == input + } else { + *name == input + } + }) + .collect(); + ensure( + found.len() == 1, + crate::i18n::message("transfer_reference", &[("name", input.into())]), + )?; + *value = if export { + json!(found[0].1) + } else { + json!(found[0].0) + }; + } + Ok(()) +} +fn compact(value: &mut Value) { + if let Some(o) = value.as_object_mut() { + o.retain(|k, v| { + k == "value" + || (!v.is_null() + && v.as_str() != Some("") + && !v.as_array().is_some_and(Vec::is_empty)) + }); + } +} +fn field_role(field: &str) -> &str { + match field { + "participants" | "primary" | "secondary" => "person", + "fallback" => "schedule", + "escalation" => "escalation", + _ => "", + } +} +struct Links { + variables: BTreeMap, + iterations: BTreeMap, + outputs: BTreeMap, +} +fn binding( + b: &mut Value, + links: &Links, + doc: &WorkspaceDocument, + role: &str, + export: bool, + depth: usize, +) -> Result<()> { + ensure(depth <= 16, crate::i18n::text("transfer_limit"))?; + object( + b, + &[ + "kind", + "value", + "variable", + "iteration", + "output", + "key", + "items", + "path", + "prefix", + "suffix", + ], + )?; + if export { + let kind = b["kind"].as_str().unwrap_or_default().to_owned(); + b.as_object_mut() + .unwrap() + .retain(|key, _| match key.as_str() { + "value" => kind == "literal", + "variable" => matches!(kind.as_str(), "variable" | "lookup"), + "iteration" => matches!(kind.as_str(), "item" | "key"), + "output" => kind == "output", + "items" => kind == "list", + "key" => kind == "lookup", + _ => true, + }); + } + for field in ["variable", "iteration", "output"] { + if b.get(field).is_none_or(Value::is_null) { + continue; + } + let key = if field == "output" && !export { + b[field].to_string() + } else { + text(&b[field])?.into() + }; + b[field] = if field == "output" { + links.outputs.get(&key).cloned() + } else { + (if field == "variable" { + &links.variables + } else { + &links.iterations + }) + .get(&key) + .map(|s| json!(s)) + } + .ok_or_else(|| { + Error::Validation(crate::i18n::message("transfer_reference", &[("name", key)])) + })?; + } + if b["kind"] == "literal" { + literal(doc, &mut b["value"], role, export)?; + } + if let Some(items) = b.get_mut("items") { + for item in items.as_array_mut().ok_or_else(|| fail("items"))? { + binding(item, links, doc, role, export, depth + 1)?; + } + } + if let Some(key) = b.get_mut("key").filter(|v| !v.is_null()) { + binding(key, links, doc, "", export, depth + 1)?; + } + if export { + compact(b); + } + Ok(()) +} +fn rule_bindings( + rule: &mut Value, + links: &Links, + doc: &WorkspaceDocument, + export: bool, +) -> Result<()> { + if let Some(iterations) = rule.get_mut("iterations") { + for i in iterations + .as_array_mut() + .ok_or_else(|| fail("iterations"))? + { + binding(&mut i["source"], links, doc, "", export, 0)?; + } + } + if let Some(p) = rule.get_mut("when").filter(|v| !v.is_null()) { + object(p, &["left", "operation", "right"])?; + binding(&mut p["left"], links, doc, "", export, 0)?; + if export && p["operation"] == "not_empty" { + p["right"] = json!({"kind":"literal","value":null}); + } + binding(&mut p["right"], links, doc, "", export, 0)?; + } + for block in rule["blocks"] + .as_array_mut() + .ok_or_else(|| fail("blocks"))? + { + object( + block, + &[ + "id", + "kind", + "fields", + "steps", + "conditions", + "portable_key", + ], + )?; + let allowed = match block["kind"].as_str() { + Some("rotation") => vec!["name", "participants", "anchor", "handover", "shift_days"], + Some("priority") => vec!["name", "participants", "fallback", "handover"], + Some("escalation") => vec!["name", "repeat_count", "repeat_wait"], + Some("route") => vec!["name", "escalation", "working"], + _ => return Err(fail(&block["kind"])), + }; + object(&block["fields"], &allowed)?; + if block["kind"] != "escalation" + && block + .get("steps") + .is_some_and(|v| v.as_array().is_none_or(|a| !a.is_empty())) + { + return Err(fail("steps")); + } + if block["kind"] != "route" + && block + .get("conditions") + .is_some_and(|v| v.as_array().is_none_or(|a| !a.is_empty())) + { + return Err(fail("conditions")); + } + + for (field, b) in block["fields"] + .as_object_mut() + .ok_or_else(|| fail("fields"))? + { + binding(b, links, doc, field_role(field), export, 0)?; + } + if let Some(steps) = block.get_mut("steps") { + for step in steps.as_array_mut().ok_or_else(|| fail("steps"))? { + object( + step, + &[ + "delay", + "recipient", + "user", + "team_all", + "team_random", + "condition", + ], + )?; + if step.get("condition").is_some_and(|v| { + !matches!(v.as_str(), Some("" | "if-not-acked" | "if-not-closed")) + }) { + return Err(fail(&step["condition"])); + } + binding(&mut step["delay"], links, doc, "", export, 0)?; + let role = if step["team_all"] == true || step["team_random"] == true { + "" + } else if step["user"] == true { + "person" + } else { + "schedule" + }; + if (step["team_all"] == true || step["team_random"] == true) && export { + step["recipient"] = json!({"kind":"literal","value":null}); + } + binding(&mut step["recipient"], links, doc, role, export, 0)?; + } + } + if let Some(conditions) = block.get_mut("conditions") { + for c in conditions + .as_array_mut() + .ok_or_else(|| fail("conditions"))? + { + object(c, &["field", "operation", "value", "negate"])?; + binding(&mut c["value"], links, doc, "", export, 0)?; + } + } + if export { + compact(block); + } + } + Ok(()) +} +pub(super) fn export_node(node: &Node, doc: &WorkspaceDocument) -> Result { + let mut v = serde_json::to_value(node)?; + if node.kind == "person" { + v["value"] = json!(email(doc, text(&node.value)?)?); + } + v["entries"] = Value::Array( + node.entries + .iter() + .map(|n| export_node(n, doc)) + .collect::>()?, + ); + compact(&mut v); + Ok(v) +} +/// IDs on nested entries are portable identity keys, never authority to access another workspace. +pub fn export_v1(doc: &WorkspaceDocument, draft: &Draft) -> Result { + unique_names(draft.variables.iter().map(|v| v.name.clone()))?; + unique_names(draft.rules.iter().map(|r| r.name.clone()))?; + let mut links = Links { + variables: draft + .variables + .iter() + .map(|v| (v.id.to_string(), v.name.clone())) + .collect(), + iterations: BTreeMap::new(), + outputs: BTreeMap::new(), + }; + for r in &draft.rules { + for b in &r.blocks { + links + .outputs + .insert(b.id.to_string(), json!([r.name, b.id.to_string()])); + } + } + let mut rules = vec![]; + for r in &draft.rules { + unique_names(r.iterations.iter().map(|i| i.name.clone()))?; + links.iterations = r + .iterations + .iter() + .map(|i| (i.id.to_string(), i.name.clone())) + .collect(); + let mut value = serde_json::to_value(r)?; + value.as_object_mut().unwrap().remove("id"); + rule_bindings(&mut value, &links, doc, true)?; + for i in value["iterations"].as_array_mut().unwrap() { + i.as_object_mut().unwrap().remove("id"); + } + compact(&mut value); + rules.push(value); + } + Ok( + json!({"format":"meguri/v1","variables":draft.variables.iter().map(|v| Ok(json!({"name":v.name,"node":export_node(&v.node,doc)?}))).collect::>>()?,"generators":rules}), + ) +} +fn identity( + scope: Uuid, + key: Option<&Value>, + fallback: &str, + existing: Option, + owned: &BTreeSet, +) -> Result { + if let Some(key) = key { + let key = text(key)?; + if let Ok(id) = Uuid::parse_str(key) + && owned.contains(&id) + { + return Ok(id); + } + Ok(generation::stable_id(&json!(["portable", scope, key]))) + } else { + Ok( + existing + .unwrap_or_else(|| generation::stable_id(&json!(["portable", scope, fallback]))), + ) + } +} +fn node_ids(n: &Node, into: &mut BTreeSet) { + into.insert(n.id); + for child in &n.entries { + node_ids(child, into); + } +} +fn import_node( + v: &Value, + old: Option<&Node>, + scope: Uuid, + path: &str, + owned: &BTreeSet, + doc: &WorkspaceDocument, + depth: usize, +) -> Result { + ensure(depth <= 8, crate::i18n::text("transfer_limit"))?; + object(v, &["id", "kind", "key", "value", "entries"])?; + if matches!(v["kind"].as_str(), Some("list" | "dictionary")) + && v.get("value") + .is_some_and(|v| !v.is_null() && v.as_str() != Some("")) + { + return Err(fail("entries")); + } + let id = identity(scope, v.get("id"), path, old.map(|n| n.id), owned)?; + let mut result = v.clone(); + result["id"] = json!(id); + if v["kind"] == "person" { + result["value"] = resolve_email(doc, text(&v["value"])?)?; + } + if let Some(entries) = v.get("entries") { + result["entries"] = Value::Array( + array(entries)? + .iter() + .enumerate() + .map(|(index, entry)| { + let child_old = old.and_then(|n| { + if n.kind == "dictionary" { + n.entries + .iter() + .find(|c| Some(c.key.as_str()) == entry["key"].as_str()) + } else { + n.entries.get(index) + } + }); + import_node( + entry, + child_old, + scope, + &format!("{path}/{index}"), + owned, + doc, + depth + 1, + ) + .and_then(|n| Ok(serde_json::to_value(n)?)) + }) + .collect::>()?, + ); + } + decode(result) +} +/// Merge only named inputs. Omitted inputs and all manual resources are retained. +pub fn import( + workspace: Uuid, + doc: &WorkspaceDocument, + base: &Draft, + source: &str, +) -> Result<(Draft, Value)> { + ensure( + source.len() <= 1_000_000, + crate::i18n::text("transfer_limit"), + )?; + let mut input: Value = serde_json::from_str(source).map_err(fail)?; + if input["format"] == "meguri/v2" { + input = crate::portable_v2::expand(&input, doc, base)?; + } + object(&input, &["format", "variables", "generators"])?; + ensure( + input["format"] == "meguri/v1", + crate::i18n::text("transfer_version"), + )?; + let vars = array(&input["variables"])?; + let rules = array(&input["generators"])?; + ensure( + vars.len() <= 100 && rules.len() <= 50, + crate::i18n::text("transfer_limit"), + )?; + unique_names( + vars.iter() + .map(|v| v["name"].as_str().unwrap_or_default().into()), + )?; + unique_names( + rules + .iter() + .map(|v| v["name"].as_str().unwrap_or_default().into()), + )?; + unique_names(base.variables.iter().map(|v| v.name.clone()))?; + unique_names(base.rules.iter().map(|v| v.name.clone()))?; + let mut draft = base.clone(); + let mut changes = vec![]; + for v in vars { + object(v, &["name", "node"])?; + let name = text(&v["name"])?; + let old = base + .variables + .iter() + .find(|v| normalized(&v.name) == normalized(name)); + let id = old.map(|v| v.id).unwrap_or_else(|| { + generation::stable_id(&json!([workspace, "variable", normalized(name)])) + }); + let mut owned = BTreeSet::new(); + if let Some(v) = old { + node_ids(&v.node, &mut owned); + } + let new = Variable { + id, + name: name.into(), + node: import_node(&v["node"], old.map(|v| &v.node), id, "root", &owned, doc, 0)?, + }; + let same = + old.is_some_and(|o| serde_json::to_value(o).ok() == serde_json::to_value(&new).ok()); + changes.push(json!({"kind":"variable","name":name,"action":if same {"unchanged"} else if old.is_some(){"update"}else{"create"}})); + if let Some(at) = draft.variables.iter().position(|v| v.id == id) { + draft.variables[at] = new; + } else { + draft.variables.push(new); + } + } + let mut prepared = vec![]; + let mut links = Links { + variables: draft + .variables + .iter() + .map(|v| (v.name.clone(), v.id.to_string())) + .collect(), + iterations: BTreeMap::new(), + outputs: BTreeMap::new(), + }; + for r in &base.rules { + for b in &r.blocks { + links + .outputs + .insert(json!([r.name, b.id.to_string()]).to_string(), json!(b.id)); + } + } + for r in rules { + object(r, &["name", "enabled", "iterations", "when", "blocks"])?; + let name = text(&r["name"])?; + let old = base + .rules + .iter() + .find(|r| normalized(&r.name) == normalized(name)); + let id = old.map(|r| r.id).unwrap_or_else(|| { + generation::stable_id(&json!([workspace, "rule", normalized(name)])) + }); + let mut new = r.clone(); + new["id"] = json!(id); + if new.get("enabled").is_none() { + new["enabled"] = json!(true); + } + if new.get("iterations").is_none() { + new["iterations"] = json!([]); + } + let owned: BTreeSet<_> = old + .into_iter() + .flat_map(|r| r.blocks.iter().map(|b| b.id)) + .collect(); + let blocks = new["blocks"].as_array_mut().ok_or_else(|| fail("blocks"))?; + let mut keys = BTreeSet::new(); + for (index, b) in blocks.iter_mut().enumerate() { + let key = b + .get("id") + .map(text) + .transpose()? + .map(str::to_owned) + .unwrap_or_else(|| format!("block-{}", index + 1)); + ensure( + keys.insert(key.clone()), + crate::i18n::message("transfer_duplicate", &[("name", key.clone())]), + )?; + let bid = identity( + id, + b.get("id"), + &key, + old.and_then(|r| r.blocks.get(index)).map(|b| b.id), + &owned, + )?; + links + .outputs + .insert(json!([name, key]).to_string(), json!(bid)); + b["id"] = json!(bid); + } + prepared.push(new); + } + for mut r in prepared { + let name = text(&r["name"])?.to_owned(); + let old = base + .rules + .iter() + .find(|r| normalized(&r.name) == normalized(&name)); + let id: Uuid = decode(r["id"].clone())?; + let iterations = r["iterations"].as_array_mut().unwrap(); + unique_names( + iterations + .iter() + .map(|i| i["name"].as_str().unwrap_or_default().into()), + )?; + links.iterations.clear(); + for i in iterations { + object(i, &["name", "source"])?; + let name = text(&i["name"])?; + let iid = old + .and_then(|r| r.iterations.iter().find(|i| i.name == name)) + .map(|i| i.id) + .unwrap_or_else(|| generation::stable_id(&json!([id, "iteration", name]))); + links.iterations.insert(name.into(), iid.to_string()); + i["id"] = json!(iid); + } + rule_bindings(&mut r, &links, doc, false)?; + let new: Rule = decode(r)?; + let same = + old.is_some_and(|o| serde_json::to_value(o).ok() == serde_json::to_value(&new).ok()); + changes.push(json!({"kind":"rule","name":name,"action":if same {"unchanged"} else if old.is_some(){"update"}else{"create"}})); + if let Some(at) = draft.rules.iter().position(|r| r.id == new.id) { + draft.rules[at] = new; + } else { + draft.rules.push(new); + } + } + Ok((draft, Value::Array(changes))) +} + +pub fn export(doc: &WorkspaceDocument, draft: &Draft) -> Result { + crate::portable_v2::compact(&export_v1(doc, draft)?, draft) +} + +/// Keep small references and scalar collections on one line for hand editing. +pub fn format_config(value: &Value) -> String { + fn render(v: &Value, depth: usize) -> String { + let short = v.to_string(); + if short.len() + depth * 2 <= 110 || (!v.is_array() && !v.is_object()) { + return short; + } + let indent = " ".repeat(depth + 1); + let end = " ".repeat(depth); + match v { + Value::Array(a) => format!( + "[\n{}\n{}]", + a.iter() + .map(|v| format!("{}{}", indent, render(v, depth + 1))) + .collect::>() + .join(",\n"), + end + ), + Value::Object(o) => { + let preferred: &[&str] = if o.contains_key("format") { + &["format", "variables", "generators"] + } else if o.contains_key("outputs") { + &["name", "enabled", "for", "when", "outputs"] + } else if o.contains_key("type") && o.contains_key("key") { + &["key", "type", "name"] + } else { + &[] + }; + let keys: Vec<_> = preferred + .iter() + .filter_map(|k| o.get_key_value(*k).map(|(k, _)| k)) + .chain(o.keys().filter(|k| !preferred.contains(&k.as_str()))) + .collect(); + format!( + "{{\n{}\n{}}}", + keys.iter() + .map(|k| format!("{}{}: {}", indent, json!(k), render(&o[*k], depth + 1))) + .collect::>() + .join(",\n"), + end + ) + } + _ => short, + } + } + render(value, 0) +} diff --git a/src/portable_v2.rs b/src/portable_v2.rs new file mode 100644 index 0000000..fdf2c3f --- /dev/null +++ b/src/portable_v2.rs @@ -0,0 +1,537 @@ +//! Compact JSON syntax translates to the existing, validated portable importer. +use crate::{ + domain::WorkspaceDocument, + error::{Result, ensure}, + generation, + generation_service::Draft, + portable::{self, fail, object}, +}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; +fn arr(v: &Value) -> Result<&Vec> { + v.as_array().ok_or_else(|| fail(v)) +} +fn txt(v: &Value) -> Result<&str> { + v.as_str().filter(|s| !s.is_empty()).ok_or_else(|| fail(v)) +} +fn norm(s: &str) -> String { + s.trim().to_lowercase() +} +fn aliases(draft: &Draft) -> Result> { + let mut result = BTreeMap::new(); + for r in &draft.rules { + let mut used: BTreeSet = r + .blocks + .iter() + .filter_map(|b| b.portable_key.clone()) + .collect(); + ensure( + used.len() == r.blocks.iter().filter(|b| b.portable_key.is_some()).count() + && used.iter().all(|k| !k.trim().is_empty() && k.len() <= 120), + crate::i18n::text("transfer_v2_binding"), + )?; + for b in &r.blocks { + let key = b.portable_key.clone().unwrap_or_else(|| { + let mut key = b.kind.clone(); + let mut n = 2; + while used.contains(&key) { + key = format!("{}-{n}", b.kind); + n += 1; + } + used.insert(key.clone()); + key + }); + result.insert(b.id.to_string(), key); + } + } + Ok(result) +} +fn compact_node(n: &Value) -> Result { + Ok(match txt(&n["kind"])? { + "list" => Value::Array( + n["entries"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .map(compact_node) + .collect::>()?, + ), + "dictionary" => { + let entries = n["entries"].as_array().cloned().unwrap_or_default(); + let keys: Vec<_> = entries + .iter() + .map(|e| e["key"].as_str().unwrap_or_default()) + .collect(); + if keys.iter().any(|k| k.starts_with('$')) || !keys.windows(2).all(|w| w[0] < w[1]) { + json!({"$object":entries.iter().map(|e|Ok(json!([e["key"],compact_node(e)?]))).collect::>>()?}) + } else { + let mut values = serde_json::Map::new(); + for e in entries { + values.insert(txt(&e["key"])?.into(), compact_node(&e)?); + } + Value::Object(values) + } + } + "person" => n["value"].clone(), + "text" + if n["value"] + .as_str() + .is_some_and(email_address::EmailAddress::is_valid) => + { + json!({"$text":n["value"]}) + } + "text" | "number" | "boolean" => n["value"].clone(), + kind @ ("date" | "time" | "duration") => { + let mut o = serde_json::Map::new(); + o.insert(format!("${kind}"), n["value"].clone()); + Value::Object(o) + } + _ => return Err(fail(&n["kind"])), + }) +} +fn compact_binding(b: &Value, names: &BTreeMap) -> Result { + let kind = txt(&b["kind"])?; + let mut out = match kind { + "literal" => { + if b["value"].is_object() { + json!({"$literal":b["value"]}) + } else { + b["value"].clone() + } + } + "list" => { + json!({"$list":arr(&b["items"] )?.iter().map(|v|compact_binding(v,names)).collect::>>()?}) + } + "variable" | "item" | "key" => { + let key = if kind == "variable" { + "$var" + } else if kind == "item" { + "$item" + } else { + "$key" + }; + let root = if kind == "variable" { + b["variable"].clone() + } else { + b["iteration"].clone() + }; + let mut path = vec![root.clone()]; + if let Some(a) = b["path"].as_array() { + path.extend(a.iter().cloned()); + } + let mut o = serde_json::Map::new(); + o.insert(key.into(), if path.len() == 1 { root } else { json!(path) }); + Value::Object(o) + } + "output" => { + let pair = arr(&b["output"])?; + let alias = names.get(txt(&pair[1])?).ok_or_else(|| fail(&pair[1]))?; + json!({"$output":[pair[0],alias]}) + } + "lookup" => json!({"$lookup":[b["variable"],compact_binding(&b["key"],names)?]}), + _ => return Err(fail(kind)), + }; + let mut modifiers = serde_json::Map::new(); + for k in ["prefix", "suffix", "path"] { + if k == "path" && matches!(kind, "variable" | "item" | "key") { + continue; + } + if let Some(v) = b.get(k).filter(|v| { + !v.is_null() && v.as_str() != Some("") && !v.as_array().is_some_and(Vec::is_empty) + }) { + modifiers.insert(k.into(), v.clone()); + } + } + if !modifiers.is_empty() { + if kind == "literal" || !out.is_object() { + out = json!({"$literal":b["value"]}); + } + out.as_object_mut().unwrap().extend(modifiers); + } + Ok(out) +} +fn expand_binding(v: &Value) -> Result { + if !v.is_object() { + return Ok(json!({"kind":"literal","value":v})); + } + let o = v.as_object().unwrap(); + let tags: Vec<_> = o.keys().filter(|k| k.starts_with('$')).collect(); + ensure(tags.len() == 1, crate::i18n::text("transfer_v2_binding"))?; + let tag = tags[0].as_str(); + object(v, &[tag, "path", "prefix", "suffix"])?; + let mut out = match tag { + "$literal" => json!({"kind":"literal","value":v[tag]}), + "$var" | "$item" | "$key" => { + let (root, path) = if let Some(s) = v[tag].as_str() { + (json!(s), vec![]) + } else { + let a = arr(&v[tag])?; + ensure(!a.is_empty(), crate::i18n::text("transfer_v2_binding"))?; + (a[0].clone(), a[1..].to_vec()) + }; + txt(&root)?; + if tag == "$var" { + json!({"kind":"variable","variable":root,"path":path}) + } else { + json!({"kind":if tag=="$key"{"key"}else{"item"},"iteration":root,"path":path}) + } + } + "$output" => { + let a = arr(&v[tag])?; + ensure(a.len() == 2, crate::i18n::text("transfer_v2_binding"))?; + txt(&a[0])?; + txt(&a[1])?; + json!({"kind":"output","output":a}) + } + "$lookup" => { + let a = arr(&v[tag])?; + ensure(a.len() == 2, crate::i18n::text("transfer_v2_binding"))?; + txt(&a[0])?; + json!({"kind":"lookup","variable":a[0],"key":expand_binding(&a[1])?}) + } + "$list" => { + json!({"kind":"list","items":arr(&v[tag])?.iter().map(expand_binding).collect::>>()?}) + } + _ => return Err(fail(tag)), + }; + for key in ["path", "prefix", "suffix"] { + if let Some(val) = v.get(key) { + if key == "path" && out[key].as_array().is_some_and(|a| !a.is_empty()) { + return Err(fail("path")); + } + out[key] = val.clone(); + } + } + Ok(out) +} +fn default_field(k: &str, v: &Value) -> bool { + match k { + "handover" => v == "11:00" || v == "11:00:00", + "shift_days" => v == 7, + "repeat_count" | "repeat_wait" => v == 0, + "working" => v == false, + _ => false, + } +} +pub(super) fn compact(v1: &Value, draft: &Draft) -> Result { + let names = aliases(draft)?; + let mut variables = serde_json::Map::new(); + for v in arr(&v1["variables"])? { + variables.insert(txt(&v["name"])?.into(), compact_node(&v["node"])?); + } + let mut generators = vec![]; + for r in arr(&v1["generators"])? { + let mut rule = json!({"name":r["name"],"outputs":[]}); + if r["enabled"] == false { + rule["enabled"] = json!(false); + } + if let Some(iterations) = r["iterations"].as_array().filter(|a| !a.is_empty()) { + rule["for"] = Value::Array( + iterations + .iter() + .map(|i| Ok(json!({"as":i["name"],"in":compact_binding(&i["source"],&names)?}))) + .collect::>()?, + ); + } + if let Some(p) = r.get("when").filter(|v| !v.is_null()) { + rule["when"] = json!([ + compact_binding(&p["left"], &names)?, + p["operation"], + compact_binding(&p["right"], &names)? + ]); + } + for b in arr(&r["blocks"])? { + let mut block = json!({"key":names.get(txt(&b["id"] )?).ok_or_else(||fail(&b["id"]))?,"type":b["kind"]}); + for (field, binding) in b["fields"].as_object().ok_or_else(|| fail("fields"))? { + let value = compact_binding(binding, &names)?; + if !default_field(field, &value) { + block[field] = value; + } + } + if let Some(steps) = b["steps"].as_array().filter(|a| !a.is_empty()) { + block["steps"] = Value::Array( + steps + .iter() + .map(|s| { + let mut step = if s["team_all"] == true { + json!({"team":"all"}) + } else if s["team_random"] == true { + json!({"team":"random"}) + } else { + let key = if s["user"] == true { + "person" + } else { + "schedule" + }; + let mut m = serde_json::Map::new(); + m.insert(key.into(), compact_binding(&s["recipient"], &names)?); + Value::Object(m) + }; + let after = compact_binding(&s["delay"], &names)?; + if after != 0 { + step["after"] = after; + } + if s["condition"] == "if-not-closed" { + step["if"] = json!("not-closed"); + } + Ok(step) + }) + .collect::>()?, + ); + } + if let Some(conditions) = b["conditions"].as_array().filter(|a| !a.is_empty()) { + block["conditions"] = Value::Array( + conditions + .iter() + .map(|c| { + let mut a = vec![ + c["field"].clone(), + c["operation"].clone(), + compact_binding(&c["value"], &names)?, + ]; + if c["negate"] == true { + a.push(json!("not")); + } + Ok(json!(a)) + }) + .collect::>()?, + ); + } + rule["outputs"].as_array_mut().unwrap().push(block); + } + generators.push(rule); + } + Ok(json!({"format":"meguri/v2","variables":variables,"generators":generators})) +} +fn node_key(v: &Value) -> Option { + if let Some(name) = v.get("name").and_then(Value::as_str) { + Some(format!("name:{name}")) + } else if !v.is_array() && !v.is_object() { + Some(format!("value:{v}")) + } else { + None + } +} +fn expand_node(v: &Value, old: Option<&Value>, path: &str, depth: usize) -> Result { + ensure(depth <= 8, crate::i18n::text("transfer_limit"))?; + let mut n = if let Some(a) = v.as_array() { + let prior = old + .and_then(|o| o["entries"].as_array()) + .cloned() + .unwrap_or_default(); + let values = prior.iter().map(compact_node).collect::>>()?; + let mut used = BTreeSet::new(); + let mut matches = vec![None; a.len()]; + for (i, item) in a.iter().enumerate() { + let candidates: Vec<_> = values + .iter() + .enumerate() + .filter(|(j, x)| { + !used.contains(j) + && (*x == item + || node_key(item).is_some_and(|key| node_key(x) == Some(key))) + }) + .map(|(j, _)| j) + .collect(); + if candidates.len() == 1 { + matches[i] = Some(candidates[0]); + used.insert(candidates[0]); + } + } + // Preserve a lone in-place edit, but never borrow an identity already matched elsewhere. + for (i, m) in matches.iter_mut().enumerate() { + if m.is_none() && prior.len() == a.len() && !used.contains(&i) { + *m = Some(i); + used.insert(i); + } + } + let entries = a + .iter() + .enumerate() + .map(|(i, item)| { + expand_node( + item, + matches[i].map(|j| &prior[j]), + &format!("{path}/{i}"), + depth + 1, + ) + }) + .collect::>>()?; + json!({"kind":"list","entries":entries,"value":old.map(|v|v["value"].clone()).unwrap_or(Value::Null)}) + } else if let Some(o) = v.as_object() { + if let Some((tag, value)) = o.iter().find(|(k, _)| k.starts_with('$')) { + object(v, &[tag])?; + match tag.as_str() { + "$text" | "$person" | "$date" | "$time" | "$duration" => { + json!({"kind":&tag[1..],"value":value}) + } + "$object" => dictionary( + arr(value)? + .iter() + .map(|pair| { + let pair = arr(pair)?; + ensure(pair.len() == 2, crate::i18n::text("transfer_v2_binding"))?; + Ok((txt(&pair[0])?.to_owned(), pair[1].clone())) + }) + .collect::>>()?, + old, + path, + depth, + )?, + _ => return Err(fail(tag)), + } + } else { + dictionary( + o.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + old, + path, + depth, + )? + } + } else if let Some(s) = v.as_str() { + json!({"kind":if email_address::EmailAddress::is_valid(s){"person"}else{"text"},"value":v}) + } else if v.is_boolean() { + json!({"kind":"boolean","value":v}) + } else if v.is_number() { + json!({"kind":"number","value":v}) + } else { + return Err(fail(v)); + }; + n["id"] = old + .map(|o| o["id"].clone()) + .unwrap_or_else(|| json!(generation::stable_id(&json!(["v2-entry", path, v])))); + Ok(n) +} +fn dictionary( + entries: Vec<(String, Value)>, + old: Option<&Value>, + path: &str, + depth: usize, +) -> Result { + let mut seen = BTreeSet::new(); + let mut nodes = vec![]; + for (key, value) in entries { + ensure( + seen.insert(key.clone()), + crate::i18n::message("transfer_duplicate", &[("name", key.clone())]), + )?; + let previous = old + .and_then(|o| o["entries"].as_array()) + .and_then(|a| a.iter().find(|n| n["key"] == key)); + let mut node = expand_node(&value, previous, &format!("{path}/{key}"), depth + 1)?; + node["key"] = json!(key); + nodes.push(node); + } + Ok( + json!({"kind":"dictionary","entries":nodes,"value":old.map(|v|v["value"].clone()).unwrap_or(Value::Null)}), + ) +} +pub(super) fn expand(v: &Value, doc: &WorkspaceDocument, base: &Draft) -> Result { + object(v, &["format", "variables", "generators"])?; + let old = json!({"variables":base.variables.iter().map(|v|json!({"name":v.name,"node":portable::export_node(&v.node,doc).unwrap_or_else(|_|serde_json::to_value(&v.node).expect("typed node"))})).collect::>()}); + let names = aliases(base)?; + let mut vars = vec![]; + for (name, value) in v["variables"] + .as_object() + .ok_or_else(|| fail("variables"))? + { + let previous = old["variables"] + .as_array() + .unwrap() + .iter() + .find(|p| norm(p["name"].as_str().unwrap_or_default()) == norm(name)); + vars.push( + json!({"name":name,"node":expand_node(value,previous.map(|p|&p["node"]),name,0)?}), + ); + } + let mut outputs = BTreeMap::new(); + for r in &base.rules { + for b in &r.blocks { + outputs.insert( + json!([norm(&r.name), names[&b.id.to_string()]]).to_string(), + json!(b.id), + ); + } + } + for r in arr(&v["generators"])? { + for b in arr(&r["outputs"])? { + let key = txt(&b["key"])?; + outputs + .entry(json!([norm(txt(&r["name"])?), key]).to_string()) + .or_insert_with(|| json!(key)); + } + } + let mut rules = vec![]; + for r in arr(&v["generators"])? { + object(r, &["name", "enabled", "for", "when", "outputs"])?; + let name = txt(&r["name"])?; + let mut rule = json!({"name":name,"enabled":r.get("enabled").cloned().unwrap_or(json!(true)),"blocks":[]}); + if let Some(iterations) = r.get("for") { + rule["iterations"] = Value::Array( + arr(iterations)? + .iter() + .map(|i| { + object(i, &["as", "in"])?; + Ok(json!({"name":i["as"],"source":expand_binding(&i["in"])?})) + }) + .collect::>()?, + ); + } + if let Some(p) = r.get("when") { + let p = arr(p)?; + ensure(p.len() == 3, crate::i18n::text("transfer_v2_binding"))?; + rule["when"] = json!({"left":expand_binding(&p[0])?,"operation":p[1],"right":expand_binding(&p[2])?}); + } + for b in arr(&r["outputs"])? { + let key = txt(&b["key"])?; + let mut block = json!({"id":outputs.get(&json!([norm(name),key]).to_string()).ok_or_else(||fail(key))?,"portable_key":key,"kind":b["type"],"fields":{}}); + for (field, value) in b.as_object().ok_or_else(|| fail(b))? { + if !["key", "type", "steps", "conditions"].contains(&field.as_str()) { + block["fields"][field] = expand_binding(value)?; + } + } + if let Some(steps) = b.get("steps") { + block["steps"]=Value::Array(arr(steps)?.iter().map(|s|{ + object(s,&["after","if","person","schedule","team"])?; + let recipients:Vec<_>=["person","schedule","team"].into_iter().filter(|k|s.get(k).is_some()).collect();ensure(recipients.len()==1,crate::i18n::text("transfer_v2_binding"))?; + let condition=match s.get("if").and_then(Value::as_str){None|Some("not-acked")=>"if-not-acked",Some("not-closed")=>"if-not-closed",_=>return Err(fail(&s["if"]))}; + let recipient=if recipients[0]=="team"{ensure(s["team"]=="all" || s["team"]=="random",crate::i18n::text("transfer_v2_binding"))?;json!({"kind":"literal","value":null})}else{expand_binding(&s[recipients[0]])?}; + Ok(json!({"delay":expand_binding(s.get("after").unwrap_or(&json!(0)))?,"condition":condition,"user":recipients[0]=="person","team_all":s["team"]=="all","team_random":s["team"]=="random","recipient":recipient})) + }).collect::>()?); + } + if let Some(conditions) = b.get("conditions") { + block["conditions"]=Value::Array(arr(conditions)?.iter().map(|c|{let a=arr(c)?;ensure(a.len()==3||(a.len()==4&&a[3]=="not"),crate::i18n::text("transfer_v2_binding"))?;Ok(json!({"field":a[0],"operation":a[1],"value":expand_binding(&a[2])?,"negate":a.len()==4}))}).collect::>()?); + } + rule["blocks"].as_array_mut().unwrap().push(block); + } + remap_outputs(&mut rule, &outputs)?; + rules.push(rule); + } + Ok(json!({"format":"meguri/v1","variables":vars,"generators":rules})) +} +fn remap_outputs(v: &mut Value, outputs: &BTreeMap) -> Result<()> { + if v["kind"] == "literal" { + return Ok(()); + } + if v["kind"] == "output" { + let old = v["output"].clone(); + let id = outputs + .get(&json!([norm(txt(&old[0])?), old[1]]).to_string()) + .ok_or_else(|| fail(&old))?; + v["output"] = json!([old[0], id]); + } + match v { + Value::Object(o) => { + for v in o.values_mut() { + remap_outputs(v, outputs)?; + } + } + Value::Array(a) => { + for v in a { + remap_outputs(v, outputs)?; + } + } + _ => (), + }; + Ok(()) +} diff --git a/src/providers/jsm.rs b/src/providers/jsm.rs index 53ff3d0..b51e01b 100644 --- a/src/providers/jsm.rs +++ b/src/providers/jsm.rs @@ -8,6 +8,7 @@ use crate::{ use chrono::{DateTime, Timelike, Utc}; use reqwest::{Client, Method, StatusCode}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use uuid::Uuid; @@ -168,7 +169,13 @@ impl Jsm { let mut ids: BTreeSet<_> = plan .schedules .iter() - .flat_map(|s| s.periods.iter().map(|p| p.user_id)) + .flat_map(|s| { + s.periods.iter().map(|p| p.user_id).chain( + s.recurring + .iter() + .flat_map(|r| r.participants.iter().copied()), + ) + }) .collect(); ids.extend( plan.escalations @@ -241,6 +248,7 @@ impl Jsm { })?)) } async fn list(&self, path: &str) -> Result> { + const PAGE_SIZE: usize = 50; let mut all = vec![]; let mut offset = 0; loop { @@ -248,7 +256,7 @@ impl Jsm { let page = self .request( Method::GET, - &format!("{path}{sep}offset={offset}&size=100"), + &format!("{path}{sep}offset={offset}&size={PAGE_SIZE}"), None, ) .await? @@ -269,7 +277,7 @@ impl Jsm { .pointer("/links/next") .and_then(Value::as_str) .is_none_or(str::is_empty) - && count < 100 + && count < PAGE_SIZE { break; } @@ -326,8 +334,36 @@ impl Jsm { desired: &Value, previous: Option<&ProviderObjectRow>, overwrite: bool, + as_of: DateTime, ) -> Result { - let name = desired["name"].as_str().unwrap_or_default(); + let local_id = key + .split_once(':') + .and_then(|(_, id)| Uuid::parse_str(id).ok()) + .expect("provider object keys contain a resource UUID"); + // A deleted resource is absent desired state, not an edit to an existing + // resource. Clear stale identity before discovery so a lost replacement + // create response can be recovered by its stable ownership marker. + let mut previous = previous; + if let Some(old) = previous + && self.read(kind, &old.remote_id).await?.is_none() + { + db.forget(record.id, key).await?; + db.forget(record.id, &format!("pending:{key}")).await?; + if kind == "schedule" { + let prefix = format!("coverage:{local_id}:"); + for child in db + .objects(record.id) + .await? + .iter() + .filter(|r| r.object_key.starts_with(&prefix)) + { + db.forget(record.id, &child.object_key).await?; + } + } + previous = None; + } + let marker = ownership_marker(record.id, local_id); + let legacy_name = format!("meguri-{}-{}", record.id.simple(), local_id.simple()); let existing_id = if let Some(previous) = previous { Some(previous.remote_id.clone()) } else { @@ -335,7 +371,11 @@ impl Jsm { .list(&self.collection(kind)) .await? .into_iter() - .filter(|v| v["name"].as_str() == Some(name)) + .filter(|v| { + v["name"] + .as_str() + .is_some_and(|name| name.ends_with(&marker) || name == legacy_name) + }) .collect(); ensure( found.len() <= 1, @@ -354,18 +394,32 @@ impl Jsm { let expected = previous .map(|p| serde_json::from_str::(&p.content)) .transpose()?; - if previous.is_some() && current.is_none() && !overwrite { - return Err(Error::Conflict(crate::i18n::message( - "err_jsm_kind_was_deleted_externally_publication_paus_88543d", - &[("kind", (kind).to_string())], - ))); - } + let pending_key = format!("pending:{key}"); + let pending = + db.objects(record.id).await?.into_iter().find(|r| { + r.object_key == pending_key && Some(&r.remote_id) == existing_id.as_ref() + }); + let pending_expected = pending + .as_ref() + .map(|r| serde_json::from_str::(&r.content)) + .transpose()?; let matches_desired = current.as_ref().is_some_and(|c| equivalent(c, desired)); if let Some(current) = ¤t { let known = expected .as_ref() - .is_some_and(|last| equivalent(current, last)); - if !matches_desired && !known && !overwrite { + .is_some_and(|last| equivalent(current, last)) + || pending_expected + .as_ref() + .is_some_and(|next| equivalent(current, next)); + let legacy_matches = + if previous.is_none() && current["name"].as_str() == Some(&legacy_name) { + let mut legacy = desired.clone(); + legacy["name"] = json!(legacy_name); + equivalent(current, &legacy) + } else { + false + }; + if !matches_desired && !known && !legacy_matches && !overwrite { return Err(Error::Conflict(crate::i18n::message( "err_jsm_kind_changed_outside_meguri_publication_paus_ac8220", &[("kind", (kind).to_string())], @@ -373,48 +427,75 @@ impl Jsm { } } if kind == "schedule" + && current.is_some() && let Some(id) = &existing_id { - let overrides = self.list(&format!("/schedules/{id}/overrides")).await?; - if !overrides.is_empty() && !overwrite { - return Err(Error::Conflict( - crate::i18n::text( - "err_jsm_schedule_contains_external_overrides_publica_ff6a5d", - ) - .into(), - )); - } - for item in overrides { - let alias = item["alias"].as_str().ok_or_else(|| { - Error::Provider(crate::i18n::text("err_override_has_no_alias_0bab5d").into()) - })?; - ensure( - safe_id(alias), - crate::i18n::text("err_unexpected_jsm_override_identifier_896f12"), - )?; - self.request( - Method::DELETE, - &format!("/schedules/{id}/overrides/{alias}"), - None, - ) + self.check_coverage(db, record.id, local_id, id, overwrite, as_of) .await?; - } } let id = if matches_desired { existing_id.unwrap() } else if current.is_some() { let id = existing_id.unwrap(); - self.request( - Method::PATCH, - &format!("{}/{id}", self.collection(kind)), - Some(desired), - ) - .await? - .ok_or_else(|| { - Error::Conflict( - crate::i18n::text("err_jsm_resource_disappeared_during_publication").into(), + let current = current.as_ref().unwrap(); + if kind == "schedule" + && current["rotations"] + .as_array() + .is_some_and(|r| r.len() == 1) + && desired["rotations"] + .as_array() + .is_some_and(|r| r.len() == 1) + { + let rotation_id = current["rotations"][0]["id"] + .as_str() + .filter(|v| safe_id(v)) + .ok_or_else(|| { + Error::Provider( + crate::i18n::text("err_unexpected_jsm_resource_identifier_dedd70") + .into(), + ) + })?; + let mut metadata = desired.clone(); + metadata.as_object_mut().unwrap().remove("rotations"); + if !equivalent(current, &metadata) { + let mut intermediate = metadata.clone(); + intermediate["rotations"] = current["rotations"].clone(); + self.schedule_step( + db, + record.id, + key, + &id, + &format!("/schedules/{id}"), + &metadata, + &intermediate, + ) + .await?; + } + if !equivalent(¤t["rotations"][0], &desired["rotations"][0]) { + self.schedule_step( + db, + record.id, + key, + &id, + &format!("/schedules/{id}/rotations/{rotation_id}"), + &desired["rotations"][0], + desired, + ) + .await?; + } + } else { + self.request( + Method::PATCH, + &format!("{}/{id}", self.collection(kind)), + Some(desired), ) - })?; + .await? + .ok_or_else(|| { + Error::Conflict( + crate::i18n::text("err_jsm_resource_disappeared_during_publication").into(), + ) + })?; + } id } else { let response = self @@ -450,8 +531,303 @@ impl Jsm { ))); } db.checkpoint(record.id, key, &id, desired).await?; + db.forget(record.id, &pending_key).await?; Ok(id) } + #[allow(clippy::too_many_arguments)] + async fn schedule_step( + &self, + db: &Store, + workspace: Uuid, + key: &str, + id: &str, + path: &str, + body: &Value, + expected: &Value, + ) -> Result<()> { + let pending = format!("pending:{key}"); + db.checkpoint(workspace, &pending, id, expected).await?; + self.request(Method::PATCH, path, Some(body)) + .await? + .ok_or_else(|| { + Error::Conflict( + crate::i18n::text("err_jsm_resource_disappeared_during_publication").into(), + ) + })?; + let actual = self.read("schedule", id).await?.ok_or_else(|| { + Error::Provider( + crate::i18n::text("err_jsm_resource_is_missing_after_publication_3448ad").into(), + ) + })?; + ensure( + equivalent(&actual, expected), + crate::i18n::message( + "err_jsm_kind_read_back_does_not_match_the_requested_63440e", + &[("kind", "schedule".into())], + ), + )?; + db.checkpoint(workspace, key, id, expected).await?; + db.forget(workspace, &pending).await?; + Ok(()) + } + fn schedule_payloads( + &self, + schedule: &super::PublishedSchedule, + as_of: DateTime, + ) -> Result<(Vec, Vec)> { + if let Some(rotation) = &schedule.recurring { + let zone = schedule.timezone.parse::().map_err(|_| { + Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into()) + })?; + if half_hour_floor(rotation.start, zone) == rotation.start + && half_hour_floor(rotation.end, zone) == rotation.end + { + let participants = rotation + .participants + .iter() + .map(|id| self.mapped(*id).map(|id| json!({"type":"user","id":id}))) + .collect::>>()?; + let (kind, length) = if rotation.shift_days.is_multiple_of(7) { + ("weekly", rotation.shift_days / 7) + } else { + ("daily", rotation.shift_days) + }; + return Ok(( + vec![ + json!({"name":schedule.name,"startDate":rotation.start,"endDate":rotation.end,"type":kind,"length":length,"participants":participants}), + ], + vec![], + )); + } + } + if !needs_precise_coverage(schedule)? { + let zone = schedule.timezone.parse::().map_err(|_| { + Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into()) + })?; + let rotations = schedule.periods.iter().map(|p| Ok(json!({ + "name":if schedule.periods.len() == 1 { schedule.name.clone() } else { + format!("{} · {}", schedule.name, p.start.with_timezone(&zone).format("%d-%m-%Y %H:%M %:z")) + }, "startDate":p.start, "endDate":p.end, + "type":"daily", "length":1, "participants":[{"type":"user","id":self.mapped(p.user_id)?}] + }))).collect::>>()?; + return Ok((rotations, vec![])); + } + let zone = schedule.timezone.parse::().map_err(|_| { + Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into()) + })?; + let current = schedule + .periods + .iter() + .find(|p| p.start <= as_of && as_of < p.end); + let base_person = current.map(|p| self.mapped(p.user_id)).transpose()?; + let first = current.unwrap_or(&schedule.periods[0]); + let last_end = schedule.periods.last().unwrap().end; + let start = half_hour_floor(first.start, zone); + let floor_end = half_hour_floor(last_end, zone); + let end = if floor_end == last_end { + floor_end + } else { + floor_end + chrono::Duration::minutes(30) + }; + let participant = base_person.as_ref().map_or_else( + || json!({"type":"noone"}), + |id| json!({"type":"user","id":id}), + ); + let rotations = vec![ + json!({"name":schedule.name, "startDate":start, "endDate":end, + "type":"daily", "length":1, "participants":[participant]}), + ]; + let mut coverage = vec![]; + // JSM rejects new overrides starting in the past. The already effective + // assignee is the base; overrides describe only future transitions. + for p in schedule.periods.iter().filter(|p| p.start > as_of) { + coverage.push( + json!({"responder":{"type":"user","id":self.mapped(p.user_id)?}, + "startDate":milliseconds(p.start), "endDate":milliseconds(p.end)}), + ); + } + if base_person.is_some() { + for pair in schedule.periods.windows(2) { + if pair[0].end > as_of && pair[0].end < pair[1].start { + coverage.push(json!({"responder":{"type":"noone"}, + "startDate":milliseconds(pair[0].end), "endDate":milliseconds(pair[1].start)})); + } + } + if last_end > as_of && last_end < end { + coverage.push(json!({"responder":{"type":"noone"}, + "startDate":milliseconds(last_end), "endDate":end})); + } + } + Ok((rotations, coverage)) + } + + // Empty remote IDs are durable create intents, never verified publications. + async fn check_coverage( + &self, + db: &Store, + workspace: Uuid, + schedule: Uuid, + remote: &str, + overwrite: bool, + _as_of: DateTime, + ) -> Result<()> { + let prefix = format!("coverage:{schedule}:"); + let rows = db.objects(workspace).await?; + let mut owned = vec![]; + for row in rows.iter().filter(|r| r.object_key.starts_with(&prefix)) { + let content: Value = serde_json::from_str(&row.content)?; + if content["scheduleId"].as_str() == Some(remote) { + owned.push((row, content)); + } + } + let items = self.list(&format!("/schedules/{remote}/overrides")).await?; + for item in &items { + let alias = coverage_alias(item)?; + if let Some((_, content)) = owned.iter().find(|(row, _)| row.remote_id == alias) { + if !overwrite && !equivalent(item, &content["payload"]) { + return Err(Error::Conflict( + crate::i18n::text("jsm_coverage_conflict").into(), + )); + } + continue; + } + let pending: Vec<_> = owned + .iter() + .filter(|(row, content)| { + row.remote_id.is_empty() && equivalent(item, &content["payload"]) + }) + .collect(); + ensure( + pending.len() <= 1, + crate::i18n::text("jsm_coverage_conflict"), + )?; + if let Some((row, content)) = pending.first() { + ensure( + items + .iter() + .filter(|candidate| equivalent(candidate, &content["payload"])) + .count() + == 1, + crate::i18n::text("jsm_coverage_conflict"), + )?; + db.checkpoint(workspace, &row.object_key, alias, content) + .await?; + } else if overwrite { + self.request( + Method::DELETE, + &format!("/schedules/{remote}/overrides/{alias}"), + None, + ) + .await?; + } else { + return Err(Error::Conflict( + crate::i18n::text("jsm_coverage_conflict").into(), + )); + } + } + Ok(()) + } + + async fn publish_coverage( + &self, + db: &Store, + workspace: Uuid, + schedule: Uuid, + remote: &str, + desired: &[Value], + ) -> Result<()> { + let prefix = format!("coverage:{schedule}:"); + let rows = db.objects(workspace).await?; + let items = self.list(&format!("/schedules/{remote}/overrides")).await?; + let mut used = BTreeSet::new(); + for payload in desired { + let digest = Sha256::digest(serde_json::to_vec(payload)?); + let key = format!("{prefix}{digest:x}"); + used.insert(key.clone()); + let content = json!({"scheduleId":remote,"payload":payload}); + let previous = rows.iter().find(|r| r.object_key == key); + let alias = previous + .filter(|r| !r.remote_id.is_empty()) + .filter(|r| { + serde_json::from_str::(&r.content) + .ok() + .is_some_and(|v| v["scheduleId"].as_str() == Some(remote)) + }) + .map(|r| r.remote_id.as_str()); + let existing = alias.and_then(|alias| { + items + .iter() + .find(|item| item["alias"].as_str() == Some(alias)) + }); + let alias = if let (Some(alias), Some(existing)) = (alias, existing) { + if !equivalent(existing, payload) { + self.request( + Method::PUT, + &format!("/schedules/{remote}/overrides/{alias}"), + Some(payload), + ) + .await?; + } + alias.to_owned() + } else { + db.checkpoint(workspace, &key, "", &content).await?; + let created = self + .request( + Method::POST, + &format!("/schedules/{remote}/overrides"), + Some(payload), + ) + .await? + .ok_or_else(|| { + Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into()) + })?; + coverage_alias(&created)?.to_owned() + }; + let actual = self + .request( + Method::GET, + &format!("/schedules/{remote}/overrides/{alias}"), + None, + ) + .await? + .ok_or_else(|| { + Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into()) + })?; + ensure( + equivalent(&actual, payload), + crate::i18n::text("jsm_coverage_response_invalid"), + )?; + db.checkpoint(workspace, &key, &alias, &content).await?; + } + // Keep previous effective spans until all replacement writes are verified. + for row in rows + .iter() + .filter(|r| r.object_key.starts_with(&prefix) && !used.contains(&r.object_key)) + { + let content: Value = serde_json::from_str(&row.content)?; + if !row.remote_id.is_empty() { + let parent = content["scheduleId"] + .as_str() + .filter(|v| safe_id(v)) + .ok_or_else(|| { + Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into()) + })?; + ensure( + safe_id(&row.remote_id), + crate::i18n::text("jsm_coverage_response_invalid"), + )?; + self.request( + Method::DELETE, + &format!("/schedules/{parent}/overrides/{}", row.remote_id), + None, + ) + .await?; + } + db.forget(workspace, &row.object_key).await?; + } + Ok(()) + } + pub async fn publish( &self, db: &Store, @@ -459,6 +835,9 @@ impl Jsm { plan: &Publication, overwrite: bool, ) -> Result<()> { + for schedule in &plan.schedules { + validate_precise_intervals(schedule)?; + } // Preflight every recipient before creating or modifying any external object. self.resolve_people(record, plan) .await? @@ -481,8 +860,8 @@ impl Jsm { for s in &plan.schedules { let key = format!("schedule:{}", s.id); used.insert(key.clone()); - let rotations=s.periods.iter().map(|p|Ok(json!({"name":format!("shift-{}",p.start.timestamp()),"startDate":p.start,"endDate":p.end,"type":"daily","length":1,"participants":[{"type":"user","id":self.mapped(p.user_id)?}]}))).collect::>>()?; - let body = json!({"name":owned_name(record.id,s.id),"description":crate::i18n::message("err_managed_by_meguri_p0_e8ef37", &[("p0", (s.name).to_string())]),"teamId":self.team,"timezone":s.timezone,"enabled":true,"rotations":rotations}); + let (rotations, coverage) = self.schedule_payloads(s, plan.as_of)?; + let body = json!({"name":owned_name(record.id,s.id,&s.name),"description":crate::i18n::message("err_managed_by_meguri_p0_e8ef37", &[("p0", (s.name).to_string())]),"teamId":self.team,"timezone":s.timezone,"enabled":true,"rotations":rotations}); let remote = self .synchronize( db, @@ -492,8 +871,11 @@ impl Jsm { &body, previous.get(key.as_str()).copied(), overwrite, + plan.as_of, ) .await?; + self.publish_coverage(db, record.id, s.id, &remote, &coverage) + .await?; schedules.insert(s.id, remote); } for e in &plan.escalations { @@ -520,7 +902,7 @@ impl Jsm { }; rules.push(json!({"condition":step.condition,"notifyType":notify,"delay":step.delay_minutes,"recipient":{"type":kind,"id":id}})); } - let body = json!({"name":owned_name(record.id,e.id),"description":crate::i18n::message("err_managed_by_meguri_p0_e8ef37", &[("p0", (e.name).to_string())]),"enabled":true,"rules":rules,"repeat":{"count":e.repeat.count.max(1),"waitInterval":if e.repeat.count==0 {0}else{e.repeat.wait_minutes},"resetRecipientStates":e.repeat.reset_recipient_states,"closeAlertAfterAll":e.repeat.close_alert_after_all}}); + let body = json!({"name":owned_name(record.id,e.id,&e.name),"description":crate::i18n::message("err_managed_by_meguri_p0_e8ef37", &[("p0", (e.name).to_string())]),"enabled":true,"rules":rules,"repeat":{"count":e.repeat.count.max(1),"waitInterval":if e.repeat.count==0 {0}else{e.repeat.wait_minutes},"resetRecipientStates":e.repeat.reset_recipient_states,"closeAlertAfterAll":e.repeat.close_alert_after_all}}); let remote = self .synchronize( db, @@ -530,6 +912,7 @@ impl Jsm { &body, previous.get(key.as_str()).copied(), overwrite, + plan.as_of, ) .await?; policies.insert(e.id, remote); @@ -551,7 +934,7 @@ impl Jsm { } else { json!({"type":"match-all-conditions","conditions":conditions}) }; - let mut body = json!({"name":owned_name(record.id,route.id),"order":order,"timezone":record.document.timezone,"criteria":criteria,"notify":notify}); + let mut body = json!({"name":owned_name(record.id,route.id,&route.name),"order":order,"timezone":record.document.timezone,"criteria":criteria,"notify":notify}); if let Some(w) = &route.window { body["timezone"] = json!(w.timezone); let days = [ @@ -577,6 +960,7 @@ impl Jsm { &body, previous.get(key.as_str()).copied(), overwrite, + plan.as_of, ) .await?; } @@ -595,19 +979,21 @@ impl Jsm { .into(), )); } - if kind == "schedule" - && !overwrite - && !self - .list(&format!("/schedules/{}/overrides", old.remote_id)) - .await? - .is_empty() - { - return Err(Error::Conflict( - crate::i18n::text( - "err_an_obsolete_jsm_schedule_has_external_overrides_c6de63", - ) - .into(), - )); + if kind == "schedule" { + let local_id = old + .object_key + .split_once(':') + .and_then(|(_, id)| Uuid::parse_str(id).ok()) + .unwrap(); + self.check_coverage( + db, + record.id, + local_id, + &old.remote_id, + overwrite, + plan.as_of, + ) + .await?; } self.request( Method::DELETE, @@ -617,14 +1003,84 @@ impl Jsm { .await?; } db.forget(record.id, &old.object_key).await?; + db.forget(record.id, &format!("pending:{}", old.object_key)) + .await?; + if kind == "schedule" { + let prefix = format!( + "coverage:{}:", + old.object_key.strip_prefix("schedule:").unwrap() + ); + for child in db + .objects(record.id) + .await? + .iter() + .filter(|o| o.object_key.starts_with(&prefix)) + { + db.forget(record.id, &child.object_key).await?; + } + } } } Ok(()) } } -fn owned_name(team: Uuid, id: Uuid) -> String { - format!("meguri-{}-{}", team.simple(), id.simple()) +fn milliseconds(value: DateTime) -> DateTime { + DateTime::from_timestamp_millis(value.timestamp_millis()).unwrap() +} + +fn half_hour_floor(value: DateTime, zone: chrono_tz::Tz) -> DateTime { + let local = value.with_timezone(&zone); + value + - chrono::Duration::minutes(i64::from(local.minute() % 30)) + - chrono::Duration::seconds(i64::from(local.second())) + - chrono::Duration::nanoseconds(i64::from(local.nanosecond())) +} + +fn needs_precise_coverage(schedule: &super::PublishedSchedule) -> Result { + let zone = schedule + .timezone + .parse::() + .map_err(|_| Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into()))?; + Ok(schedule.periods.iter().any(|p| { + [p.start, p.end].iter().any(|date| { + let local = date.with_timezone(&zone); + local.minute() % 30 != 0 || local.second() != 0 || local.nanosecond() != 0 + }) + })) +} + +fn validate_precise_intervals(schedule: &super::PublishedSchedule) -> Result<()> { + ensure( + !schedule.periods.is_empty() + && schedule + .periods + .iter() + .all(|p| milliseconds(p.start) < milliseconds(p.end)), + crate::i18n::text("jsm_invalid_coverage_interval"), + ) +} + +fn coverage_alias(value: &Value) -> Result<&str> { + value["alias"] + .as_str() + .filter(|v| safe_id(v)) + .ok_or_else(|| Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into())) +} + +fn ownership_marker(workspace: Uuid, id: Uuid) -> String { + let mut digest = Sha256::new(); + digest.update(workspace.as_bytes()); + digest.update(id.as_bytes()); + let digest = digest.finalize(); + format!( + "[meguri:{:032x}]", + u128::from_be_bytes(digest[..16].try_into().unwrap()) + ) +} + +fn owned_name(workspace: Uuid, id: Uuid, name: &str) -> String { + format!("{name} {}", ownership_marker(workspace, id)) } fn safe_id(id: &str) -> bool { !id.is_empty() @@ -646,9 +1102,45 @@ pub fn equivalent(actual: &Value, expected: &Value) -> bool { { return false; } - expected - .iter() - .all(|(k, v)| equivalent(actual.get(k).unwrap_or(&Value::Null), v)) + if expected.contains_key("responder") + && expected.contains_key("startDate") + && !expected.contains_key("rotationIds") + && actual.get("rotationIds").is_some_and(|ids| { + !ids.is_null() && ids.as_array().is_none_or(|ids| !ids.is_empty()) + }) + { + return false; + } + expected.iter().all(|(k, v)| { + if k == "repeat" + && expected.contains_key("rules") + && actual.get(k).is_none() + && v.get("waitInterval").and_then(Value::as_u64) == Some(0) + { + // JSM omits disabled repeats in escalation GET responses. + true + } else if k == "rotations" { + let Some(actual) = actual.get(k).and_then(Value::as_array) else { + return false; + }; + let Some(expected) = v.as_array() else { + return false; + }; + let mut remaining: Vec<_> = actual.iter().collect(); + for expected in expected { + let Some(index) = remaining + .iter() + .position(|actual| equivalent(actual, expected)) + else { + return false; + }; + remaining.remove(index); + } + remaining.is_empty() + } else { + equivalent(actual.get(k).unwrap_or(&Value::Null), v) + } + }) } Value::Array(expected) => actual.as_array().is_some_and(|a| { a.len() == expected.len() && a.iter().zip(expected).all(|(a, e)| equivalent(a, e)) @@ -670,6 +1162,34 @@ pub fn equivalent(actual: &Value, expected: &Value) -> bool { #[cfg(test)] mod tests { use super::*; + #[test] + fn omitted_escalation_repeat_only_matches_disabled_repeats() { + let mut expected = json!({"rules":[{"delay":0}],"repeat":{"count":1,"waitInterval":0,"resetRecipientStates":false,"closeAlertAfterAll":false}}); + let mut remote = json!({"rules":[{"delay":0}]}); + assert!(equivalent(&remote, &expected)); + remote["rules"][0]["delay"] = json!(10); + assert!(!equivalent(&remote, &expected)); + remote["rules"][0]["delay"] = json!(0); + expected["repeat"]["waitInterval"] = json!(5); + assert!(!equivalent(&remote, &expected)); + remote["repeat"] = expected["repeat"].clone(); + assert!(equivalent(&remote, &expected)); + for (field, changed) in [ + ("count", json!(2)), + ("waitInterval", json!(10)), + ("resetRecipientStates", json!(true)), + ("closeAlertAfterAll", json!(true)), + ] { + let mut changed_remote = remote.clone(); + changed_remote["repeat"][field] = changed; + assert!(!equivalent(&changed_remote, &expected)); + } + expected["repeat"]["waitInterval"] = json!(0); + assert!(!equivalent(&remote, &expected)); + remote["repeat"] = Value::Null; + assert!(!equivalent(&remote, &expected)); + } + #[test] fn normalization_ignores_ids_but_detects_extra_rotations_and_changed_owners() { let expected = json!({"rotations":[{"startDate":"2026-09-23T08:00:00Z","participants":[{"id":"alice","type":"user"}]}]}); diff --git a/src/providers/mod.rs b/src/providers/mod.rs index a3758a1..96dd02d 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -15,11 +15,14 @@ pub struct PublishedSchedule { pub name: String, pub timezone: String, pub periods: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recurring: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Publication { pub workspace_id: Uuid, pub revision: i64, + pub as_of: DateTime, pub from: DateTime, pub to: DateTime, pub schedules: Vec, @@ -149,6 +152,7 @@ pub fn compile( id: schedule.id, name: schedule.name.clone(), timezone: record.document.timezone.clone(), + recurring: crate::engine::recurring_rotation(&record.document, schedule, &periods)?, periods, }); } @@ -156,6 +160,7 @@ pub fn compile( Ok(Publication { workspace_id: record.id, revision: record.revision, + as_of: now, from: timeline.from, to: timeline.to, schedules, @@ -230,6 +235,45 @@ pub async fn publish( mod tests { use super::*; #[test] + fn jsm_team_identifier_preserves_operations_prefix_and_rejects_invalid_paths() { + let cloud = Uuid::new_v4().to_string(); + let team = Uuid::new_v4().to_string(); + for valid_team in [team.clone(), format!("og-{team}")] { + let mut doc = WorkspaceDocument::new("Platform".into()); + doc.integration = Integration::Jsm { + cloud_id: cloud.clone(), + team_id: valid_team.clone(), + advanced_escalations: false, + users: Default::default(), + }; + doc.validate(&Default::default()).unwrap(); + let saved: WorkspaceDocument = + serde_json::from_value(serde_json::to_value(&doc).unwrap()).unwrap(); + assert!( + matches!(saved.integration, Integration::Jsm { team_id, .. } if team_id == valid_team) + ); + for invalid in [ + "og-".to_owned(), + format!("og-og-{team}"), + format!("og-{team}/routing-rules"), + format!("og-{team}?x=1"), + ] { + if let Integration::Jsm { team_id, .. } = &mut doc.integration { + *team_id = invalid; + } + assert!(doc.validate(&Default::default()).is_err()); + } + if let Integration::Jsm { + cloud_id, team_id, .. + } = &mut doc.integration + { + *team_id = valid_team; + *cloud_id = format!("og-{cloud}"); + } + assert!(doc.validate(&Default::default()).is_err()); + } + } + #[test] fn unsupported_escalation_is_not_silently_dropped() { let mut doc = WorkspaceDocument::new("Platform".into()); doc.integration = Integration::Jsm { diff --git a/src/service.rs b/src/service.rs index f787f2b..f7645e7 100644 --- a/src/service.rs +++ b/src/service.rs @@ -192,8 +192,15 @@ pub async fn update_document( !query!(UserRow,$id==id).exists(&db.db).await?, crate::i18n::text("err_select_the_workspace_user_from_the_directory_725423"), )?; + } else if let Some(id) = person.user_id { + // A new person may only link to an account that currently has access here. + ensure( + members.contains(&id), + crate::i18n::text("err_choose_a_user_who_has_access_to_this_workspace_41cf15"), + )?; } } + store::account_people(&mut document, &db.directory(workspace).await?)?; let interval = db.working_interval(workspace).await?; document.timezone = interval.timezone.clone(); for route in &mut document.routes { @@ -479,27 +486,21 @@ async fn prepare_absence( !query!(UserRow,$id==key).exists(&db.db).await?, crate::i18n::text("err_select_an_existing_workspace_user_from_the_direc_a09a03"), )?; + } else if let Some(id) = person.user_id { + ensure( + participants.contains(&id), + crate::i18n::text("err_choose_a_user_who_has_access_to_this_workspace_41cf15"), + )?; } record.document.people.push(person); } + store::account_people(&mut record.document, &db.directory(workspace).await?)?; record.document.validate(&participants)?; let mut plan = crate::absence::plan(&record.document, &input.draft, &participants, now)?; if !input.force { - let members = db.member_ids(workspace).await?; - for id in plan - .groups - .iter() - .flatten() - .map(|l| l.to_user) - .collect::>() - { - if !members.contains(&id) { - plan.errors.push( - crate::i18n::text( - "err_a_selected_replacement_has_no_team_access_to_con_dd538a", - ) - .into(), - ); + for legs in &plan.groups { + if let Err(error) = validate_approval_access(&record.document, legs, &participants) { + plan.errors.push(error.to_string()); } } } @@ -513,7 +514,9 @@ pub async fn preview_absence( input: NewAbsence, ) -> Result { let (record, _, plan) = prepare_absence(db, actor, workspace, &input, Utc::now()).await?; - Ok(json!({"revision":record.revision,"plan":plan})) + let members = db.member_ids(workspace).await?; + let approval_groups:Vec<_>=plan.groups.iter().map(|legs|json!({"legs":legs,"required_approvers":if input.force {BTreeSet::new()} else {required_approvers(actor,legs,&members)}})).collect(); + Ok(json!({"revision":record.revision,"plan":plan,"approval_groups":approval_groups})) } pub async fn create_absence( @@ -533,6 +536,7 @@ pub async fn create_absence( record.document.requests.len() + plan.groups.len() <= 5000, crate::i18n::text("err_replacement_history_limit_reached_3b1168"), )?; + let members = db.member_ids(workspace).await?; record.document.absences.push(input.draft.absence.clone()); for legs in &plan.groups { let mut request = @@ -540,20 +544,18 @@ pub async fn create_absence( request.id = Uuid::new_v4(); request.requested_by = actor; request.reason = input.reason.trim().into(); - request.required_approvers = legs - .iter() - .flat_map(|l| [l.from_user, l.to_user]) - .filter(|id| *id != actor) - .collect(); - request.status = if input.force { + request.required_approvers = required_approvers(actor, legs, &members); + let apply = input.force || request.required_approvers.is_empty(); + request.status = if apply { RequestStatus::Applied } else { RequestStatus::Pending }; - request.applied_at = input.force.then_some(now); + request.applied_at = apply.then_some(now); request.forced_by = input.force.then_some(actor); request.force_reason = input.force.then_some(input.reason.clone()); - if input.force { + if apply { + crate::absence::validate_request(&record.document, &request, &members, now)?; engine::supersede_overrides(&mut record.document, &request.legs); } record.document.requests.push(request); @@ -572,11 +574,7 @@ pub async fn request_replacement( input.reason.len() <= 1000, crate::i18n::text("err_comment_must_be_at_most_1000_characters_e5d5ef"), )?; - let permission = if input.force { - Permission::ForceOverrides - } else { - Permission::RequestOverrides - }; + let permission = replacement_permission(actor, &input); let mut record = db .prepare(workspace, input.revision, actor, permission) .await?; @@ -588,14 +586,12 @@ pub async fn request_replacement( record.document.requests.len() < 5000, crate::i18n::text("err_replacement_history_limit_reached_3b1168"), )?; - validate_request_participation(actor, &input)?; - validate_request_access(&input, &members)?; - let required_approvers = input - .legs - .iter() - .flat_map(|l| [l.from_user, l.to_user]) - .filter(|id| *id != actor) - .collect(); + validate_request_participation(actor, &input, &record.document)?; + if !input.force { + validate_approval_access(&record.document, &input.legs, &members)?; + } + let required_approvers = required_approvers(actor, &input.legs, &members); + let apply = input.force || required_approvers.is_empty(); let request = ReplacementRequest { effective_legs: None, id: Uuid::new_v4(), @@ -606,17 +602,17 @@ pub async fn request_replacement( legs: input.legs, required_approvers, approvals: BTreeSet::new(), - status: if input.force { + status: if apply { RequestStatus::Applied } else { RequestStatus::Pending }, - applied_at: input.force.then_some(now), + applied_at: apply.then_some(now), forced_by: input.force.then_some(actor), force_reason: input.force.then_some(input.reason), }; let details = serde_json::to_value(&request)?; - if input.force { + if apply { engine::supersede_overrides(&mut record.document, &request.legs); } record.document.requests.push(request); @@ -636,25 +632,58 @@ pub async fn request_replacement( Ok(record) } -fn validate_request_participation(actor: Uuid, input: &NewRequest) -> Result<()> { +fn planning_contact(doc: &WorkspaceDocument, id: Uuid) -> bool { + doc.person(id).is_some_and(|p| p.user_id.is_none()) +} +fn required_approvers( + actor: Uuid, + legs: &[ReplacementLeg], + members: &BTreeSet, +) -> BTreeSet { + legs.iter() + .flat_map(|l| [l.from_user, l.to_user]) + .filter(|id| *id != actor && members.contains(id)) + .collect() +} +fn replacement_permission(actor: Uuid, input: &NewRequest) -> Permission { + if input.force { + Permission::ForceOverrides + } else if input + .legs + .iter() + .any(|l| l.from_user != actor && l.to_user != actor) + { + Permission::EditSchedules + } else { + Permission::RequestOverrides + } +} +fn validate_request_participation( + actor: Uuid, + input: &NewRequest, + doc: &WorkspaceDocument, +) -> Result<()> { ensure( input.force - || input - .legs - .iter() - .all(|leg| leg.from_user == actor || leg.to_user == actor), + || input.legs.iter().all(|leg| { + leg.from_user == actor + || leg.to_user == actor + || planning_contact(doc, leg.from_user) + || planning_contact(doc, leg.to_user) + }), crate::i18n::text("err_you_can_give_your_own_shift_or_offer_to_cover_a_5e6f57"), ) } - -fn validate_request_access(input: &NewRequest, members: &BTreeSet) -> Result<()> { +fn validate_approval_access( + doc: &WorkspaceDocument, + legs: &[ReplacementLeg], + members: &BTreeSet, +) -> Result<()> { ensure( - input.force - || input - .legs - .iter() - .all(|l| members.contains(&l.from_user) && members.contains(&l.to_user)), - crate::i18n::text("err_both_people_need_team_access_to_approve_ask_a_le_7b0fc5"), + legs.iter() + .flat_map(|l| [l.from_user, l.to_user]) + .all(|id| members.contains(&id) || planning_contact(doc, id)), + crate::i18n::text("replacement_account_access"), ) } @@ -673,17 +702,15 @@ pub async fn preview_replacement( workspace: Uuid, mut input: NewRequest, ) -> Result { - let permission = if input.force { - Permission::ForceOverrides - } else { - Permission::RequestOverrides - }; + let permission = replacement_permission(actor, &input); let record = db .prepare(workspace, input.revision, actor, permission) .await?; - validate_request_participation(actor, &input)?; + validate_request_participation(actor, &input, &record.document)?; let members = db.member_ids(workspace).await?; - validate_request_access(&input, &members)?; + if !input.force { + validate_approval_access(&record.document, &input.legs, &members)?; + } ensure( input.reason.len() <= 1000, crate::i18n::text("err_comment_must_be_at_most_1000_characters_e5d5ef"), @@ -691,7 +718,14 @@ pub async fn preview_replacement( let now = Utc::now(); clip_requested_interval(&mut input, now); engine::validate_legs(&record.document, &input.legs, &members, now)?; - Ok(serde_json::json!({"legs": input.legs})) + let approvers = if input.force { + BTreeSet::new() + } else { + required_approvers(actor, &input.legs, &members) + }; + Ok( + serde_json::json!({"legs": input.legs,"required_approvers":approvers,"applies_immediately":approvers.is_empty()}), + ) } #[derive(Debug, Deserialize)] @@ -958,16 +992,16 @@ WITH previous AS MATERIALIZED (SELECT * FROM meguri__users WHERE id=$1 FOR NO KE UPDATE meguri__users SET display_name=$2,country=$3,timezone=$4,password_hash=$5,language=$6 WHERE id IN(SELECT id FROM previous) RETURNING * ), changed_workspaces AS ( UPDATE meguri__workspaces t SET revision=t.revision+1,document=jsonb_set(t.document::jsonb,'{people}', - COALESCE((SELECT jsonb_agg(CASE WHEN p->>'user_id'=$1 THEN p||jsonb_build_object('country',$3::text) ELSE p END) FROM jsonb_array_elements(COALESCE(t.document::jsonb->'people','[]'::jsonb)) p),'[]'::jsonb) - || CASE WHEN EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(t.document::jsonb->'people','[]'::jsonb)) p WHERE p->>'id'=$1) THEN '[]'::jsonb ELSE jsonb_build_array(jsonb_build_object('id',$1::text,'user_id',$1::text,'display_name',$2::text,'email',null,'country',$3::text)) END)::text - WHERE EXISTS(SELECT 1 FROM previous JOIN changed USING(id) WHERE previous.country<>changed.country) + COALESCE((SELECT jsonb_agg(CASE WHEN p->>'user_id'=$1 THEN p||jsonb_build_object('country',$3::text,'display_name',$2::text,'email',(SELECT email FROM changed)) ELSE p END) FROM jsonb_array_elements(COALESCE(t.document::jsonb->'people','[]'::jsonb)) p),'[]'::jsonb) + || CASE WHEN EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(t.document::jsonb->'people','[]'::jsonb)) p WHERE p->>'id'=$1) THEN '[]'::jsonb ELSE jsonb_build_array(jsonb_build_object('id',$1::text,'user_id',$1::text,'display_name',$2::text,'email',(SELECT email FROM changed),'country',$3::text)) END)::text + WHERE EXISTS(SELECT 1 FROM previous JOIN changed USING(id) WHERE previous.country<>changed.country OR previous.display_name<>changed.display_name) AND (EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=t.id AND user_id=$1) OR EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(t.document::jsonb->'people','[]'::jsonb)) p WHERE p->>'user_id'=$1)) RETURNING t.* ), history AS ( INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$1 FROM changed_workspaces RETURNING workspace_id,effective_at ), jobs AS ( - INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'pending',0,now() FROM changed_workspaces - ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',attempts=0,next_attempt=now(),last_error=NULL + INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'draft',0,now() FROM changed_workspaces + ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision ) INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) -SELECT id,$1,'person.country_changed',jsonb_build_object('user_id',$1::text,'country',$3::text)::text,(SELECT effective_at FROM history WHERE workspace_id=changed_workspaces.id) FROM changed_workspaces +SELECT id,$1,'person.profile_changed',jsonb_build_object('user_id',$1::text,'display_name',$2::text,'country',$3::text)::text,(SELECT effective_at FROM history WHERE workspace_id=changed_workspaces.id) FROM changed_workspaces "#; diff --git a/src/store.rs b/src/store.rs index 46f1055..e0bd83b 100644 --- a/src/store.rs +++ b/src/store.rs @@ -30,16 +30,43 @@ pub fn uuid(value: &str) -> Result { pub fn user(row: UserRow) -> Result { Ok(User { language: row.language, - id: uuid(&row.id)?, + person: Person { + id: uuid(&row.id)?, + user_id: Some(uuid(&row.id)?), + display_name: row.display_name, + email: row.email, + country: Some(row.country), + aliases: vec![], + }, username: row.username, - display_name: row.display_name, - country: row.country, timezone: row.timezone, - email: row.email, instance_admin: row.instance_admin, has_password: row.password_hash.is_some(), }) } +/// Workspace copies of account people are immutable identity snapshots. +pub fn account_people(document: &mut WorkspaceDocument, users: &[User]) -> Result<()> { + for user in users { + if let Some(person) = document.people.iter_mut().find(|p| p.id == user.id) { + crate::error::ensure( + person.user_id == Some(user.id) + && person + .email + .as_ref() + .is_none_or(|e| Some(e) == user.email.as_ref()), + crate::i18n::text("account_person_authoritative"), + )?; + // The account owns the profile; merged contact IDs stay as aliases for history. + let aliases = std::mem::take(&mut person.aliases); + *person = user.person.clone(); + person.aliases = aliases; + } else { + document.people.push(user.person.clone()); + } + } + Ok(()) +} + pub fn record(row: WorkspaceRow) -> Result { if row.demo_deleted_at.is_some() || row.disabled_at.is_some() { return Err(Error::NotFound); @@ -200,6 +227,11 @@ impl Store { details: Value, role_permissions: Option>, ) -> Result<()> { + let users = self.directory(record.id).await?; + account_people(&mut record.document, &users)?; + record + .document + .validate(&users.iter().map(|u| u.id).collect())?; let workspace = record.id.to_string(); let actor = actor.to_string(); let document = serde_json::to_string(&record.document)?; @@ -295,7 +327,7 @@ impl Store { pub async fn audit(&self, workspace: Uuid) -> Result> { let mut names = crate::audit::Names::new(); for person in self.directory(workspace).await? { - names.insert(person.id.to_string(), person.display_name); + names.insert(person.id.to_string(), person.display_name.clone()); } let current = self.workspace(workspace).await?; crate::audit::remember(&mut names, ¤t.document); @@ -342,20 +374,79 @@ impl Store { output.sort_by_key(|e| std::cmp::Reverse(e["id"].as_i64().unwrap_or_default())); Ok(output) } + pub async fn published_document(&self, workspace: Uuid) -> Result> { + let id = workspace.to_string(); + query!(SyncJobRow, $workspace_id == id) + .get(&self.db) + .await? + .and_then(|r| r.published_document) + .map(|s| serde_json::from_str(&s).map_err(Into::into)) + .transpose() + } pub async fn sync_status(&self, workspace: Uuid) -> Result { - let workspace = workspace.to_string(); + let record = self.workspace(workspace).await?; + let baseline = self.published_document(workspace).await?; + let changed = serde_json::to_value(&record.document)? != serde_json::to_value(&baseline)?; + let id = workspace.to_string(); + let mut status = match query!(SyncJobRow, $workspace_id == id) + .get(&self.db) + .await? + { + None => json!({"state":"draft"}), + Some(r) => { + let publication_busy = r.lease_until.is_some_and(|until| until > Utc::now()); + json!({"publication_busy":publication_busy,"lease_until":r.lease_until,"requested_revision":r.requested_revision,"authorized_revision":r.authorized_revision,"published_revision":r.published_revision,"state":r.state,"attempts":r.attempts,"last_error":r.last_error,"last_success":r.last_success,"window_end":r.window_end}) + } + }; + status["has_changes"] = json!(changed); + status["changes"] = publication_changes(&record.document, baseline.as_ref())?; + Ok(status) + } + pub async fn shared_draft(&self, workspace: Uuid) -> Result { + let id = workspace.to_string(); Ok( - match query!(SyncJobRow,$workspace_id==workspace) + match query!(WorkspaceDraftRow, $workspace_id == id) .get(&self.db) .await? { - None => json!({"state":"pending"}), - Some(r) => { - json!({"requested_revision":r.requested_revision,"published_revision":r.published_revision,"state":r.state,"attempts":r.attempts,"last_error":r.last_error,"last_success":r.last_success,"window_end":r.window_end}) + Some(row) => { + json!({"revision":row.revision,"payload":serde_json::from_str::(&row.payload)?}) } + None => json!({"revision":0,"payload":null}), }, ) } + pub async fn save_shared_draft( + &self, + workspace: Uuid, + actor: Uuid, + revision: i64, + payload: Value, + ) -> Result { + self.require(workspace, actor, Permission::EditSchedules) + .await?; + // Deserialize into the bounded typed input; never store arbitrary integration secrets. + let draft: crate::generation_service::Draft = serde_json::from_value(payload)?; + self.prepare(workspace, draft.revision, actor, Permission::EditSchedules) + .await?; + crate::error::ensure( + draft.variables.len() <= 100 && draft.rules.len() <= 50, + crate::i18n::text("err_shared_draft_limit"), + )?; + let payload = serde_json::to_string(&draft)?; + let changed = self.db.raw_with(r#" +WITH allowed AS (SELECT id FROM meguri__workspaces WHERE id=$1 AND revision=$5 AND demo_deleted_at IS NULL AND disabled_at IS NULL AND (EXISTS(SELECT 1 FROM meguri__users WHERE id=$4 AND instance_admin) OR EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> '["edit_schedules"]'::jsonb)))) + , saved AS (INSERT INTO meguri__workspace_drafts(workspace_id,revision,payload) SELECT id,1,$3 FROM allowed WHERE $2=0 OR EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=$1) +ON CONFLICT(workspace_id) DO UPDATE SET revision=meguri__workspace_drafts.revision+1,payload=EXCLUDED.payload WHERE meguri__workspace_drafts.revision=$2 RETURNING workspace_id,revision) +INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT workspace_id,$4,'generation.draft_saved',jsonb_build_object('draft_revision',revision)::text,now() FROM saved +"#, &[&workspace.to_string(), &revision, &payload, &actor.to_string(), &draft.revision]).await?; + if changed.rows_affected().0 == 0 { + return Err(Error::Conflict( + crate::i18n::text("err_shared_draft_stale").into(), + )); + } + Ok(json!({"revision":revision+1,"payload":draft})) + } pub async fn historical_timeline( &self, workspace: &WorkspaceRecord, @@ -442,19 +533,20 @@ impl Store { query!(ProviderObjectRow,$id==id).delete(&self.db).await?; Ok(()) } - pub async fn retry(&self, workspace: Uuid, actor: Uuid) -> Result<()> { - self.require(workspace, actor, Permission::ManageIntegration) + pub async fn retry(&self, workspace: Uuid, actor: Uuid, revision: i64) -> Result<()> { + self.prepare(workspace, revision, actor, Permission::ManageIntegration) .await?; - let mut record = self.workspace(workspace).await?; - self.commit( - &mut record, - actor, - Permission::ManageIntegration, - "publication.requested", - json!({}), - None, - ) - .await + let result = self.db.raw_with(r#" +WITH selected AS (SELECT w.* FROM meguri__workspaces w WHERE id=$1 AND revision=$3 AND demo_deleted_at IS NULL AND disabled_at IS NULL AND (EXISTS(SELECT 1 FROM meguri__users WHERE id=$2 AND instance_admin) OR EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$2 AND (is_owner OR permissions::jsonb @> '["manage_integration"]'::jsonb))) FOR NO KEY UPDATE), +queued AS (UPDATE meguri__sync_jobs j SET authorized_revision=w.revision,authorized_document=w.document,state='pending',attempts=0,next_attempt=now(),last_error=NULL FROM selected w WHERE j.workspace_id=w.id AND (lease_until IS NULL OR lease_until 'generation.applied' OR ($6::jsonb->>'shared_revision') IS NULL OR EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=$1 AND revision=($6::jsonb->>'shared_revision')::bigint)) AND (EXISTS(SELECT 1 FROM meguri__users WHERE id=$4 AND instance_admin) OR EXISTS(SELECT 1 FROM meguri__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> $7::jsonb))) RETURNING * ), roles AS ( @@ -471,8 +564,8 @@ WITH updated AS ( SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$4 FROM updated RETURNING effective_at ), jobs AS ( INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) - SELECT id,revision,'pending',0,now() FROM updated - ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',attempts=0,next_attempt=now(),last_error=NULL + SELECT id,revision,'draft',0,now() FROM updated + ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision ) INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$4,$5,$6,(SELECT effective_at FROM history) FROM updated @@ -487,9 +580,64 @@ WITH updated AS ( SELECT id||':'||revision::text,id,revision,document,clock_timestamp(),$4::text FROM updated RETURNING effective_at ), jobs AS ( INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) - SELECT id,revision,'pending',0,now() FROM updated - ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision,state='pending',attempts=0,next_attempt=now(),last_error=NULL + SELECT id,revision,'draft',0,now() FROM updated + ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision ) INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$4::text,$5,$6,(SELECT effective_at FROM history) FROM updated "#; + +/// Compare desired configuration with the last verified publication, never provider GETs. +pub fn publication_changes( + document: &WorkspaceDocument, + baseline: Option<&WorkspaceDocument>, +) -> Result { + let desired = serde_json::to_value(document)?; + let old = serde_json::to_value(baseline)?; + let mut changes = vec![]; + let schedule_context_changed = [ + "timezone", + "people", + "holidays", + "absences", + "requests", + "horizon_days", + ] + .iter() + .any(|key| desired[key] != old[key]); + for (collection, kind) in [ + ("schedules", "schedule"), + ("escalations", "escalation"), + ("routes", "route"), + ] { + let empty = vec![]; + let before = old[collection].as_array().unwrap_or(&empty); + let after = desired[collection].as_array().unwrap_or(&empty); + for resource in after { + let previous = before.iter().find(|r| r["id"] == resource["id"]); + let action = match previous { + None => "create", + Some(prior) + if prior != resource + || (kind == "schedule" && schedule_context_changed) + || desired["integration"] != old["integration"] => + { + "update" + } + _ => "unchanged", + }; + changes.push( + json!({"id":resource["id"],"name":resource["name"],"kind":kind,"action":action}), + ); + } + for resource in before + .iter() + .filter(|r| !after.iter().any(|n| n["id"] == r["id"])) + { + changes.push( + json!({"id":resource["id"],"name":resource["name"],"kind":kind,"action":"remove"}), + ); + } + } + Ok(json!(changes)) +} diff --git a/src/web.rs b/src/web.rs index f151747..746b900 100644 --- a/src/web.rs +++ b/src/web.rs @@ -69,9 +69,13 @@ impl App for MeguriApp { "/api/workspaces/{workspace}/routes/order", "/api/workspaces/{workspace}/absence-preview", "/api/workspaces/{workspace}/absences", + "/api/workspaces/{workspace}/generation-export", + "/api/workspaces/{workspace}/generation-import-preview", + "/api/workspaces/{workspace}/generation-import", "/api/workspaces/{workspace}/generation-preview", "/api/workspaces/{workspace}/generation", "/api/workspaces/{workspace}/member-permissions", + "/api/workspaces/{workspace}/generation-draft", "/api/workspaces/{workspace}/requests", "/api/workspaces/{workspace}/request-preview", "/api/workspaces/{workspace}/requests/{request}", @@ -484,10 +488,36 @@ async fn dispatch(request: Request) -> Result { None }; return response( - json!({"coverage":coverage,"workspace":record,"is_owner":db.owner(workspace,actor).await.is_ok(),"instance_admin":identity.user.instance_admin,"access":access,"permissions":grants,"members":db.members(workspace).await?,"directory":db.directory(workspace).await?,"sync":db.sync_status(workspace).await?,"audit":audit}), + json!({"generation_draft":db.shared_draft(workspace).await?,"coverage":coverage,"workspace":record,"is_owner":db.owner(workspace,actor).await.is_ok(),"instance_admin":identity.user.instance_admin,"access":access,"permissions":grants,"members":db.members(workspace).await?,"directory":db.directory(workspace).await?,"sync":db.sync_status(workspace).await?,"audit":audit}), ); } let action = parts.get(3).copied().unwrap_or_default(); + if parts.len() == 4 && method == Method::PUT && action == "generation-draft" { + return response( + db.save_shared_draft(workspace, actor, revision(&body)?, body["payload"].clone()) + .await?, + ); + } + if parts.len() == 4 && method == Method::POST && action == "generation-export" { + return response( + crate::generation_service::export_config(&db, actor, workspace, decode(body)?).await?, + ); + } + if parts.len() == 4 + && method == Method::POST + && matches!(action, "generation-import-preview" | "generation-import") + { + return response( + crate::generation_service::import_config( + &db, + actor, + workspace, + decode(body)?, + action == "generation-import", + ) + .await?, + ); + } if parts.len() == 4 && method == Method::POST && action == "generation-preview" { return response( crate::generation_service::preview(&db, actor, workspace, decode(body)?).await?, @@ -718,7 +748,7 @@ async fn dispatch(request: Request) -> Result { )?); } if action == "sync" && method == Method::POST { - db.retry(workspace, actor).await?; + db.retry(workspace, actor, revision(&body)?).await?; return response(json!({"ok":true})); } Err(Error::NotFound) diff --git a/src/worker.rs b/src/worker.rs index 4bb1571..4efa119 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -23,12 +23,16 @@ pub async fn run(db: Store) { pub async fn tick(db: &Store) -> Result<()> { let now = Utc::now(); let mut jobs = query!(SyncJobRow,$next_attempt<=now).all(&db.db).await?; - jobs.retain(|job| job.lease_until.is_none_or(|until| until <= now)); + jobs.retain(|job| { + job.authorized_revision.is_some() + && ["pending", "publishing", "failed", "conflict"].contains(&job.state.as_str()) + && job.lease_until.is_none_or(|until| until <= now) + }); jobs.sort_by_key(|job| job.next_attempt); for job in jobs.into_iter().take(20) { let token = Uuid::new_v4().to_string(); // Publication is bounded to four minutes; a five-minute lease survives a lost worker. - let claimed=db.db.raw_with("UPDATE meguri__sync_jobs SET lease_token=$1,lease_until=now()+interval '5 minutes',state='publishing' WHERE workspace_id=$2 AND (lease_until IS NULL OR lease_until Result<()> { }); match result { Ok((revision, end)) => { - db.db.raw_with("WITH published AS (UPDATE meguri__sync_jobs SET published_revision=$1,state=CASE WHEN requested_revision=$1 THEN 'synced' ELSE 'pending' END,attempts=0,last_error=NULL,last_success=now(),window_end=$2,next_attempt=CASE WHEN requested_revision=$1 THEN now()+interval '15 minutes' ELSE now() END,lease_token=NULL,lease_until=NULL WHERE workspace_id=$3 AND lease_token=$4 RETURNING *) INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT workspace_id,'publication.succeeded',$5,now() FROM published",&[&revision,&end,&job.workspace_id,&token,&json!({"revision":revision,"window_end":end}).to_string()]).await?; + db.db.raw_with("WITH published AS (UPDATE meguri__sync_jobs SET published_revision=$1,published_document=authorized_document,state='synced',attempts=0,last_error=NULL,last_success=now(),window_end=$2,next_attempt=now(),lease_token=NULL,lease_until=NULL WHERE workspace_id=$3 AND lease_token=$4 RETURNING *) INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT workspace_id,'publication.succeeded',$5,now() FROM published",&[&revision,&end,&job.workspace_id,&token,&json!({"revision":revision,"window_end":end}).to_string()]).await?; } Err(error) => { let conflict = matches!(error, Error::Conflict(_)); @@ -57,7 +61,7 @@ pub async fn tick(db: &Store) -> Result<()> { (5_i64 * 2_i64.pow(job.attempts.clamp(0, 9) as u32)).min(1800) }; let retry = Utc::now() + Duration::seconds(delay); - db.db.raw_with("UPDATE meguri__sync_jobs SET state=CASE WHEN requested_revision=$6 THEN $1 ELSE 'pending' END,last_error=CASE WHEN requested_revision=$6 THEN $2 ELSE NULL END,attempts=CASE WHEN requested_revision=$6 THEN attempts+1 ELSE 0 END,next_attempt=CASE WHEN requested_revision=$6 THEN $3 ELSE now() END,lease_token=NULL,lease_until=NULL WHERE workspace_id=$4 AND lease_token=$5",&[&state,&error.to_string(),&retry,&job.workspace_id,&token,&job.requested_revision]).await?; + db.db.raw_with("UPDATE meguri__sync_jobs SET state=$1,last_error=$2,attempts=attempts+1,next_attempt=$3,lease_token=NULL,lease_until=NULL WHERE workspace_id=$4 AND lease_token=$5",&[&state,&error.to_string(),&retry,&job.workspace_id,&token]).await?; tracing::warn!(workspace=%job.workspace_id,%error,"publication did not complete"); } } @@ -66,10 +70,20 @@ pub async fn tick(db: &Store) -> Result<()> { } pub async fn publish_one(db: &Store, workspace: Uuid) -> Result<(i64, chrono::DateTime)> { - let record = db.workspace(workspace).await?; + // Only the explicitly authorized snapshot may leave the application. + let id = workspace.to_string(); + let job = query!(SyncJobRow, $workspace_id == id) + .get(&db.db) + .await? + .ok_or(Error::NotFound)?; + let record = crate::store::WorkspaceRecord { + id: workspace, + revision: job.authorized_revision.ok_or(Error::NotFound)?, + document: serde_json::from_str(job.authorized_document.as_deref().ok_or(Error::NotFound)?)?, + }; let now = Utc::now(); let (from, to) = engine::publication_window(&record.document, now)?; - let timeline = db.historical_timeline(&record, from, to).await?; + let timeline = engine::timeline(&record.document, from, to)?; let plan = providers::compile(&record, timeline, now)?; providers::publish(db, &record, &plan, record.document.overwrite_drift).await?; Ok((record.revision, to)) diff --git a/static/app.css b/static/app.css index e253a60..3bebc95 100644 --- a/static/app.css +++ b/static/app.css @@ -224,3 +224,13 @@ a.button{display:inline-block;text-decoration:none;border:1px solid #dbe3de;bord .brand .app-version{align-self:flex-end;margin-bottom:7px;font-size:11px;font-weight:500;letter-spacing:0;color:var(--muted)} @media(max-width:950px){.sidebar .brand{flex-direction:column;gap:2px}.sidebar .brand .app-version{align-self:center;margin:0}} @media(max-width:700px){.sidebar{gap:4px;padding:8px}.sidebar nav :is(button,a){padding:8px 4px}.sidebar .brand .app-version{margin:0;font-size:10px}} +.publication-review{border:1px solid var(--line);border-radius:9px;padding:16px;margin-bottom:22px;background:#fff}.publication-review-heading{display:flex;justify-content:space-between;align-items:center;gap:12px}.publication-sync{font-weight:650}.publication-sync.sync-needed{background:#ffd65a;color:#493500;border:1px solid #c79a14;box-shadow:0 0 0 2px #ffe99c}.publication-draft-note{font-size:12px;color:#76643d;margin:9px 0}.publication-status{display:flex;flex-wrap:wrap;gap:10px;font-size:12px;margin-bottom:12px}.publication-status .sync-error{width:100%}.publication-review .compact-preview{width:100%;margin-top:8px} + +.publication-actions{flex-wrap:wrap;justify-content:flex-end}.publication-workflow{font-size:12px;line-height:1.5}.publication-review-heading{flex-wrap:wrap}@media(max-width:650px){.publication-actions{justify-content:flex-start}} +.map-address{display:inline-flex;flex-wrap:wrap;align-items:baseline;font:11px/1.8 ui-monospace,monospace;color:#7c827f}.map-address button,.map-resource-name button,.map-value-link{border:0;background:transparent;padding:1px 0!important;font:inherit!important;cursor:pointer;text-align:left;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px}.map-resource-name{white-space:pre-wrap}.map-probe{position:fixed;inset:auto;margin:0;width:min(390px,calc(100vw - 24px));max-height:min(550px,calc(100dvh - 32px));overflow:auto;border:1px solid #b9cfc3;border-radius:12px;padding:16px;background:#fff;color:var(--ink);box-shadow:0 14px 55px #183a3438;font-size:12px}.map-probe h3{margin:0;font-size:15px}.map-probe h4{margin:16px 0 8px}.map-probe .muted{margin:10px 0;overflow-wrap:anywhere}.map-probe-chain{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0;padding:0;list-style:none}.map-probe-chain li{display:flex;align-items:center;gap:6px;min-width:0}.map-probe-chain li~li::before{content:"→";color:var(--muted)}.map-probe-chain button{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere;text-align:left}.map-probe-fields{display:flex;flex-wrap:wrap;gap:6px}.map-probe-actions{margin-top:16px;padding-top:12px;border-top:1px solid var(--line);display:flex;justify-content:flex-end}.map-probe button{font-size:11px;padding:6px 8px}.map-probe-chain .active{background:#e1eee5}.map-probe-example{border-top:1px solid var(--line);padding:10px 0;max-height:220px;overflow:auto}.map-probe-example .map-input-token{display:flex;flex-wrap:wrap;gap:5px;overflow-wrap:anywhere}.map-resource-name button{font-weight:650!important}.map-inspector{width:auto}.map-inspector .map-value-link{font-weight:600!important} +.config-transfer .config-text{font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace;tab-size:2;resize:vertical;min-height:220px;white-space:pre;overflow:auto}.config-transfer .transfer-preview{max-height:320px;overflow:auto;margin-top:16px}.config-transfer .compact-change{overflow-wrap:anywhere}.config-transfer .actions{margin:12px 0} + +.config-guide{min-width:0}.config-guide>.section-line{align-items:flex-start;gap:12px}.config-guide>.section-line button{flex-shrink:0}.config-guide h3{font-size:13px;margin:18px 0 7px}.config-guide pre{font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;padding:12px;background:#f4f7f5;border:1px solid var(--line);border-radius:8px}.config-guide .guide-example{background:#edf4ef}@media(max-width:600px){.config-guide>.section-line{flex-wrap:wrap}.config-guide pre{font-size:11px;padding:9px}} + + +@media(max-width:700px){.sidebar nav{min-width:0;flex-wrap:wrap}.sidebar nav :is(button,a){gap:5px;padding:6px 4px}.sidebar-bottom{flex-shrink:0}} diff --git a/static/app.js b/static/app.js index bd6fc7b..1a13628 100644 --- a/static/app.js +++ b/static/app.js @@ -18,6 +18,7 @@ document.addEventListener('alpine:init', () => { })); Alpine.data('meguri', () => ({ ...generationUi(), + syncSubmitting:false,workspacePollAt:0, user: null, csrf: '', version: '', ready: false, busy: false, error: '', toast: '', demo: false, demoLifecycle:null, auth:{oidc:false,password:true}, loginError:null, admin:null, events:[], eventsMore:false, eventSearch:'', eventsLoading:false, loginForm: { username: '', password: '' }, directoryState: { workspaces: [] }, selected: '', detail: null, calendar: { days: [], assignments: [] }, @@ -39,7 +40,7 @@ document.addEventListener('alpine:init', () => { get displayZone() { return this.user?.timezone || browserZone; }, get directory() { return this.detail?.directory || []; }, get people() { - const people = new Map(this.directory.map(u=>[u.id,{id:u.id,user_id:u.id,display_name:u.display_name,email:null,country:u.country || 'CY',username:u.username}])); + const people = new Map(this.directory.map(u=>[u.id,{id:u.id,user_id:u.id,display_name:u.display_name,email:u.email,country:u.country || 'CY',username:u.username}])); for(const p of [...(this.doc?.people || []),...this.draftPeople]) people.set(p.id,{...people.get(p.id),...p}); return [...people.values()]; }, @@ -81,7 +82,7 @@ document.addEventListener('alpine:init', () => { useDemoAccount(account){this.loginForm.username=account.username;this.loginForm.password=this.demoLifecycle?.password||'';}, async copyDemoPassword(){try{await navigator.clipboard.writeText(this.demoLifecycle?.password||'');this.toast=tr('access_demo_password_copied');setTimeout(()=>this.toast='',4500);}catch(e){this.loginForm.password=this.demoLifecycle?.password||'';}}, get setupView() { return ['configuration','settings'].includes(this.view); }, - get coverPeople() { const q=this.coverQuery.toLowerCase(); return this.people.filter(p=>p.id!==this.form.from_user && (this.members.some(m=>m.user.id===p.id) || this.form.force_mode) && [p.display_name,p.email,p.username].some(v=>v?.toLowerCase().includes(q))); }, + get coverPeople() { const q=this.coverQuery.toLowerCase(); return this.people.filter(p=>p.id!==this.form.from_user && (this.members.some(m=>m.user.id===p.id) || !p.user_id || this.form.force_mode) && [p.display_name,p.email,p.username].some(v=>v?.toLowerCase().includes(q))); }, get nextDuty() { const now=Date.now(); return this.calendar.assignments.filter(a=>a.schedule_id===this.scheduleId && a.user_id===this.user?.id && !a.unavailable && Date.parse(a.end)>now).sort((a,b)=>a.start.localeCompare(b.start))[0]; }, get matchingRequest() { return this.needsMe.find(r=>!r.absence_id && r.legs.length===1 && r.legs[0].schedule_id===this.form.schedule_id && r.legs[0].from_user===this.form.from_user && r.legs[0].to_user===this.form.to_user && Date.parse(r.legs[0].start)===Date.parse(this.form.start) && Date.parse(r.legs[0].end)===Date.parse(this.form.end)); }, get inbox() { return [...(this.doc?.requests || [])].reverse(); }, @@ -119,8 +120,8 @@ document.addEventListener('alpine:init', () => { menuHref(view,workspace=this.selected){const url=new URL(location.href);url.searchParams.set('view',view);if(workspace)url.searchParams.set('workspace',workspace);else url.searchParams.delete('workspace');return url.pathname+url.search+url.hash;}, writeNavigation(replace=false){if(!this.ready || this.navigationRestoring)return;const href=this.menuHref(this.view);if(href!==location.pathname+location.search+location.hash)history[replace?'replaceState':'pushState']({},'',href);}, menuClick(event,view){if(event.button!==0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)return;event.preventDefault();this.view=view;}, - async changeWorkspace(){this.modal='';this.scheduleId='';this.startDay='';this.writeNavigation();await this.run(()=>this.loadWorkspace());}, - async restoreNavigation(){const epoch=++this.navigationEpoch;this.navigationRestoring++;try{const route=this.readNavigation();this.modal='';this.error='';this.view=route.view==='instance' && this.user && !this.user.instance_admin?'calendar':route.view;if(this.user){const selected=this.directoryState.workspaces.some(t=>t.id===route.workspace)?route.workspace:this.directoryState.workspaces[0]?.id||'';if(selected!==this.selected){this.selected=selected;this.scheduleId='';this.startDay='';await this.loadWorkspace();}}else this.selected=route.workspace;await this.$nextTick();}catch(e){if(epoch===this.navigationEpoch)this.error=e.message;}finally{this.navigationRestoring--;if(!this.navigationRestoring)this.writeNavigation(true);}}, + async changeWorkspace(){await this.run(async()=>{await this.saveSharedGeneration();this.modal='';this.scheduleId='';this.startDay='';this.writeNavigation();await this.loadWorkspace();});if(this.error && this.workspace)this.selected=this.workspace.id;this.writeNavigation();}, + async restoreNavigation(){const epoch=++this.navigationEpoch;this.navigationRestoring++;try{const route=this.readNavigation();await this.saveSharedGeneration();this.modal='';this.error='';this.view=route.view==='instance' && this.user && !this.user.instance_admin?'calendar':route.view;if(this.user){const selected=this.directoryState.workspaces.some(t=>t.id===route.workspace)?route.workspace:this.directoryState.workspaces[0]?.id||'';if(selected!==this.selected){this.selected=selected;this.scheduleId='';this.startDay='';await this.loadWorkspace();}}else this.selected=route.workspace;await this.$nextTick();}catch(e){if(epoch===this.navigationEpoch)this.error=e.message;}finally{this.navigationRestoring--;if(!this.navigationRestoring)this.writeNavigation(true);}}, async init() { this.$watch('user',user=>{Alpine.store('locale').language=user?.language || I18n.browser;}); this.$watch('form',()=>{if(this.modal==='absence')this.absenceChanged();if(this.modal==='request')this.requestChanged();if(['variable','generation-rule'].includes(this.modal))this.generationChanged();}); @@ -133,13 +134,13 @@ document.addEventListener('alpine:init', () => { const initial=this.readNavigation();this.selected=initial.workspace;this.view=initial.view;window.addEventListener('popstate',()=>this.restoreNavigation()); await this.run(async () => { const session = await this.api('/api/session'); this.version = session.version || ''; this.user = session.user; this.csrf = session.csrf || ''; this.auth = session.auth || {oidc:false,password:true}; this.demo = session.demo;this.demoLifecycle=session.demo_lifecycle; if (this.user) { if(this.view==='instance' && !this.user.instance_admin)this.view='calendar'; await this.loadWorkspaces(); if(this.view==='instance')await this.loadAdmin(); } }); if(this.setupView)this.setupOpen=true;this.ready = true;this.writeNavigation(true);this.$nextTick(()=>{const update=()=>document.documentElement.style.setProperty('--demo-offset',(this.$refs.demoBanner?.getBoundingClientRect().height||0)+'px');new ResizeObserver(update).observe(this.$refs.demoBanner);update();});if(this.view==='configuration')this.loadGenerationDraft(); - setInterval(() => { if (this.user && this.workspace && this.view!=='configuration' && !this.modal && !this.busy && !this.draggedRoute) this.run(() => this.loadWorkspace()); }, 20000); + setInterval(() => { if (this.user && this.workspace && !this.modal && !this.generationSaving && !this.generationSaveError && !this.busy && !this.draggedRoute && (this.publicationPending() || this.detail?.sync?.publication_busy || Date.now()-this.workspacePollAt>=20000)){this.workspacePollAt=Date.now();this.run(() => this.loadWorkspace());} }, 2000); }, consumeLoginError(){const url=new URL(location.href),code=url.searchParams.get('login_error');if(code===null)return;const ref=url.searchParams.get('ref')||'';this.loginError={code:['no_access','failed','unavailable'].includes(code)?code:'failed',ref:ref.slice(0,64)};url.searchParams.delete('login_error');url.searchParams.delete('ref');history.replaceState({},'',url.pathname+url.search+url.hash);}, async signIn() { await this.run(async () => { this.loginError=null; const s = await this.api('/api/login', 'POST', this.loginForm); this.user = s.user; this.csrf = s.csrf; this.loginForm.password = ''; await this.loadWorkspaces(); if(this.view==='instance'){if(this.user.instance_admin)await this.loadAdmin();else this.view='calendar';} }); }, - async signOut() { await this.run(async () => { await this.api('/api/logout', 'POST', {}); this.generationSequence++;clearTimeout(this.generationTimer);this.generationDraft=null;this.generationResult=null;this.generationWorkspace='';this.view='calendar';this.modal='';this.form={};this.user = null; this.admin = null; this.detail = null; this.menu = false; this.selected = '';this.directoryState={workspaces:[]};this.startDay=''; }); }, + async signOut() { await this.run(async () => { await this.saveSharedGeneration();await this.api('/api/logout', 'POST', {}); this.generationSequence++;clearTimeout(this.generationTimer);this.generationDraft=null;this.generationResult=null;this.generationWorkspace='';this.view='calendar';this.modal='';this.form={};this.user = null; this.admin = null; this.detail = null; this.menu = false; this.selected = '';this.directoryState={workspaces:[]};this.startDay=''; }); }, async loadWorkspaces() { this.directoryState = await this.api('/api/workspaces'); if (!this.directoryState.workspaces.some(t => t.id === this.selected)) this.selected = (this.demo && ['alice','bob','carol'].includes(this.user?.username) ? this.directoryState.workspaces.find(t=>t.name===(this.user.username==='carol'?'Customer Support':'Acme Engineering'))?.id : '') || this.directoryState.workspaces[0]?.id || ''; if (this.selected) await this.loadWorkspace(); else this.detail = null; this.writeNavigation(true); }, - async loadWorkspace() { const selected = this.directoryState.workspaces.find(t => t.id === this.selected); if (!selected) return; const detail = await this.api(`/api/workspaces/${selected.id}`);if(this.selected!==selected.id)return;this.detail=detail; if (!this.doc.schedules.some(s => s.id === this.scheduleId)) this.scheduleId = this.doc.schedules[0]?.id || ''; if (!this.startDay) this.startDay = this.monday(); await this.loadCalendar(); if(this.view==='configuration')this.loadGenerationDraft(); }, + async loadWorkspace() { const selected = this.directoryState.workspaces.find(t => t.id === this.selected); if (!selected) return; const detail = await this.api(`/api/workspaces/${selected.id}`);if(this.selected!==selected.id)return;this.detail=detail; if (!this.doc.schedules.some(s => s.id === this.scheduleId)) this.scheduleId = this.doc.schedules[0]?.id || ''; if (!this.startDay) this.startDay = this.monday(); await this.loadCalendar(); this.loadGenerationDraft(); }, async loadCalendar() { const workspace=this.workspace.id;const calendar=await this.api(this.endpoint(`/calendar?from=${this.startDay}&days=28&schedule=${this.scheduleId}`));if(this.selected===workspace)this.calendar=calendar; }, async navigateDays(n) { await this.run(async () => { this.startDay = n === 0 ? this.monday() : this.addDays(this.startDay, n); await this.loadCalendar(); }); }, trapDialog(event){const list=[...this.$refs.dialog.querySelectorAll('button,input,select,textarea,summary,[tabindex="0"]')].filter(e=>!e.disabled && e.offsetParent!==null && e.tabIndex>=0);const first=list[0],last=list.at(-1);if(event.shiftKey && document.activeElement===first){event.preventDefault();last?.focus();}else if(!event.shiftKey && document.activeElement===last){event.preventDefault();first?.focus();}}, @@ -212,6 +213,7 @@ document.addEventListener('alpine:init', () => { catch(e){if(seq===this.absenceSequence)this.absenceError=e.message;} finally{if(seq===this.absenceSequence)this.absenceLoading=false;} }, + absenceRowPending(row){return !this.form.force && (this.absenceResult?.approval_groups||[]).some(g=>g.required_approvers.length && g.legs.some(l=>l.schedule_id===row.schedule_id && Date.parse(l.start) { async sendQuickRequest(){ if(this.matchingRequest && !this.form.force_mode && !this.form.custom_range && !this.form.swap){await this.decide(this.matchingRequest,'approve');if(!this.error){this.modal='';this.toast=tr('replacement_accepted_publication_is_queued_afea8f');}return;} if(!this.canSendRequest)return; - const force=this.form.force_mode; - await this.run(async()=>{await this.api(this.endpoint('/requests'),'POST',this.requestPayload());await this.loadWorkspace();this.modal='';},force?tr('replacement_applied_publication_is_queued_89e283'):tr('request_sent_waiting_for_your_colleague_b70b64')); + const immediate=this.form.force_mode || this.requestResult?.applies_immediately; + await this.run(async()=>{await this.api(this.endpoint('/requests'),'POST',this.requestPayload());await this.loadWorkspace();this.modal='';},immediate?tr('replacement_applied_publication_is_queued_89e283'):tr('request_sent_waiting_for_your_colleague_b70b64')); }, requestTitle(r){const leg=r.legs[0];return leg.to_user===r.requested_by?this.shortName(r.requested_by)+tr('offers_to_cover_935d75')+this.shortName(leg.from_user):this.shortName(r.requested_by)+tr('needs_cover_9e1ff7');}, async locateCoverage(gap){this.view='calendar';this.scheduleId=gap.schedule_id;const local=this.dayKey(new Date(gap.start),this.doc.timezone);this.startDay=this.addDays(local,-1);await this.run(()=>this.loadCalendar());const day=this.calendar.days.find(d=>Date.parse(d.start)<=Date.parse(gap.start) && Date.parse(gap.start)a.schedule_id===gap.schedule_id && a.user_id===gap.user_id && Date.parse(a.start)<=Date.parse(gap.start) && Date.parse(gap.start) { moveParticipant(index, delta) { const other = index + delta; if (other < 0 || other >= this.form.participants.length) return; [this.form.participants[index], this.form.participants[other]] = [this.form.participants[other], this.form.participants[index]]; }, addParticipant(id) { if (id && !this.form.participants.includes(id)) this.form.participants.push(id); }, async save(force = false) { + if(this.modal==='generation-transfer'){await this.applyTransfer();return;} if(this.bindingEditor || this.rulePeopleTarget){this.closeRuleEditor();return;} if(this.modal==='request'){await this.sendQuickRequest();return;} if(['variable','generation-rule'].includes(this.modal)){this.saveGenerationModal();return;} @@ -343,7 +346,8 @@ document.addEventListener('alpine:init', () => { await this.run(async()=>{await this.api(this.endpoint('/routes/order'),'PUT',{revision,ids});await this.loadWorkspace();},tr('routing_order_saved_publication_is_queued_cfb972')); }, async setDrift(value) { await this.run(async () => { await this.api(this.endpoint(), 'PATCH', {revision:this.workspace.revision, overwrite_drift: value }); await this.loadWorkspaces(); }, tr('drift_policy_updated_be40fd')); }, - async retry() { await this.run(async () => { await this.api(this.endpoint('/sync'), 'POST', {}); await this.loadWorkspace(); }, tr('publication_queued_8355e1')); }, + publicationPending(){return this.detail?.sync?.state==='pending' && this.detail.sync.authorized_revision!=null;}, + async retry() { if(this.busy || this.syncSubmitting || this.publicationPending() || this.detail?.sync?.publication_busy)return;this.syncSubmitting=true;try{await this.run(async () => { await this.stageGeneration();await this.api(this.endpoint('/sync'), 'POST', {revision:this.workspace.revision});this.detail.sync={...this.detail.sync,state:'pending',authorized_revision:this.workspace.revision};await this.loadWorkspace(); }, tr('publication_queued_8355e1'));}finally{this.syncSubmitting=false;} }, async preview() { await this.run(async () => { const plan = await this.api(this.endpoint('/preview')); const url = URL.createObjectURL(new Blob([JSON.stringify(plan, null, 2)], { type: 'application/json' })); const link = document.createElement('a'); link.href = url; link.download = 'meguri-publication.json'; link.click(); URL.revokeObjectURL(url); }, tr('publication_preview_downloaded_afd91a')); }, auditDetails(details){const localize=(value,key='')=>{if(Array.isArray(value))return value.map(item=>localize(item,key));if(value && typeof value==='object')return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,localize(v,k)]));if(typeof value==='string' && ['start','end','created_at','applied_at','effective_at'].includes(key) && /^\d{4}-\d{2}-\d{2}T/.test(value) && Number.isFinite(Date.parse(value)))return this.formatTime(value);return value;};return JSON.stringify(localize(details),null,2);}, describeRecipient(recipient) { return recipient.type === 'schedule' ? this.scheduleName(recipient.id) : recipient.type === 'user' ? this.person(recipient.id) : recipient.type === 'team_all' ? tr('everyone_on_the_team_235b55') : tr('a_random_team_member_8f35f3'); }, diff --git a/static/binding-popover.html b/static/binding-popover.html index 22205e4..796e1b9 100644 --- a/static/binding-popover.html +++ b/static/binding-popover.html @@ -20,7 +20,7 @@

- +
diff --git a/static/generation-map.html b/static/generation-map.html index 65bce08..04ea43e 100644 --- a/static/generation-map.html +++ b/static/generation-map.html @@ -1,4 +1,4 @@ - +

@@ -6,11 +6,24 @@
-
+
+ +
+ +
diff --git a/static/generation-map.js b/static/generation-map.js index 9dd2951..5430967 100644 --- a/static/generation-map.js +++ b/static/generation-map.js @@ -1,13 +1,36 @@ // Canvas graph is kept outside Alpine's reactive proxy. It never mutates scheduling state. -function generationMapUi(){let graph=null,origin=null,request=0;return { +function generationMapUi(){let graph=null,origin=null,request=0;const colors=new Map();return { + mapProbe:null,mapProbeHistory:[],mapProbeStyle:{}, + mapBindingKey(b){if(!b)return '';return JSON.stringify([b.kind,b.variable||null,b.iteration||null,b.output||null,b.value??null,b.path||[],b.prefix||'',b.suffix||'',(b.items||[]).map(v=>this.mapBindingKey(v)),b.key?this.mapBindingKey(b.key):null]);}, + mapColor(key){if(!colors.has(key))colors.set(key,`hsl(${(colors.size*137.508)%360} 58% 32%)`);return colors.get(key);}, + mapAddressParts(address){const text=address.replace(/^\{\s*|\s*\}$/g,''),parts=[];let start=0,quote='',escape=false,depth=0;for(let i=0;i({...p,color:this.mapColor(parts.slice(0,i+1).map(p=>p.text).join(''))}));}, + mapAllBindings(rule){const list=[];const walk=v=>{if(!v||typeof v!=='object')return;if(['literal','variable','item','key','lookup','output','list'].includes(v.kind)){list.push(v);if(v.key)walk(v.key);(v.items||[]).forEach(walk);return;}Object.values(v).forEach(walk);};walk(rule);return list;}, + mapAddressBinding(address){const rule=this.mapSnapshot.payload.rules.find(r=>r.id===this.mapCurrent()?.rule);return {rule:rule?.id,binding:this.mapAllBindings(rule).find(b=>'{ '+this.bindingAddress(b,0,rule.iterations)+' }'===address)};}, + mapTraceBinding(rule,binding,row){const key=this.mapBindingKey(binding),rows=this.mapTrace(rule)?.rows||[],found=[];for(const [index,pass] of rows.entries()){if(row!=null&&index!==row)continue;const scan=p=>{if(!p)return null;if(this.mapBindingKey(p.binding)===key)return p;for(const parent of p.parents||[]){const match=scan(parent);if(match)return match;}return null;};const trace=(pass.bindings||[]).map(scan).find(Boolean);if(trace)found.push({row:index,trace});}return found;}, + mapResourceOrigin(item,key){if(item.manual)return {manual:true,title:item.name,field:key,value:item.resource[key]};const rule=this.mapSnapshot.payload.rules.find(r=>r.id===item.rule),block=rule?.blocks.find(b=>b.id===item.block),row=this.mapTrace(item.rule)?.rows.findIndex(r=>r.resources.includes(item.id));let binding=block?.fields[key];const step=/^steps\.(\d+)\.(delay|recipient)$/.exec(key);if(step){const s=block.steps[Number(step[1])];binding=step[2]==='recipient'&&(s.team_all||s.team_random)?this.literal(tr(s.team_random?'a_random_team_member_8f35f3':'everyone_on_the_team_235b55')):s[step[2]];}const condition=/^conditions\.(\d+)$/.exec(key);if(condition)binding=block.conditions[Number(condition[1])]?.value;return {rule:item.rule,row:row>=0?row:null,binding,title:item.name,field:key,value:item.resource[key]};}, + mapNameParts(item){const origin=this.mapResourceOrigin(item,'name'),b=origin.binding;if(!b||b.kind==='literal')return [{text:item.name,origin,color:this.mapColor(this.mapBindingKey(b)||item.id)}];const prefix=b.prefix||'',suffix=b.suffix||'';return [{text:prefix,origin:{...origin,binding:this.literal(prefix)},color:this.mapColor('prefix')},{text:item.name.slice(prefix.length,suffix?item.name.length-suffix.length:undefined),origin,color:this.mapColor(this.mapBindingKey(b))},{text:suffix,origin:{...origin,binding:this.literal(suffix)},color:this.mapColor('suffix')}].filter(p=>p.text);}, + mapSampleElement(value,path){if(!path)return value;for(const key of path.split(' / ')){value=Array.isArray(value)?value[Number(key)-1]:value?.[key];}return value;}, + async mapExploreAddressPart(address,index,event){await this.mapExploreAddress(address,event);if(!this.mapProbe)return;const parts=this.mapAddressParts(address).filter(p=>!p.dot),selected=this.mapAddressParts(address).slice(0,index+1).filter(p=>!p.dot).length,stages=this.mapProbeStages();this.mapProbe.stage=selected===parts.length?Math.max(0,stages.length-1):Math.max(0,selected-(parts.length-stages.length)-1);}, + async mapExploreAddress(address,event){const source=this.mapAddressBinding(address);if(!source.binding)return;const row=this.mapRow==='all'?null:Number(this.mapRow);await this.mapExplore({...source,row,title:address},event);}, + async mapExplore(origin,event){const traces=origin.binding?this.mapTraceBinding(origin.rule,origin.binding,origin.row):[];const probe={...origin,traces,stage:null};if(this.mapProbe)this.mapProbeHistory.push(this.mapProbe);else this.mapProbeHistory=[];this.mapProbe=probe;const rect=event?.currentTarget?.getBoundingClientRect()||{left:innerWidth/2,top:80};this.mapProbeStyle={left:Math.max(12,Math.min(rect.left,innerWidth-402))+'px',top:Math.max(12,Math.min(rect.top,innerHeight-570))+'px'};await this.$nextTick();const popup=document.querySelector('.generation-map .map-probe');if(this.mapOpen && this.mapProbe && popup && !popup.matches(':popover-open'))popup.showPopover();}, + mapProbeClose(){const el=document.querySelector('.generation-map .map-probe');if(el?.matches(':popover-open'))el.hidePopover();this.mapProbe=null;this.mapProbeHistory=[];}, + mapProbeBack(){if(this.mapProbeHistory.length)this.mapProbe=this.mapProbeHistory.pop();}, + mapProbeAddress(){const p=this.mapProbe,rule=this.mapSnapshot?.payload.rules.find(r=>r.id===p?.rule);return p?.binding?'{ '+this.bindingAddress(p.binding,0,rule?.iterations||[])+' }':p?.field||'';}, + mapProbeStages(){return this.mapProbe?.traces[0]?.trace.stages||[];}, + mapProbeValues(){const p=this.mapProbe;if(!p)return [];if(!p.traces.length){return [{label:tr(p.manual?'map_manual':p.binding?.kind==='literal'?'fixed_value_191429':'map_no_trace'),value:p.binding?.kind==='literal'?p.binding.value:p.value}];}return p.traces.slice(0,20).map(({row,trace})=>{const result=p.stage==null?trace.result:trace.stages[p.stage]?.result;return {label:tr('map_pass_number',{number:row+1}),value:this.mapSampleElement(result?.value,p.stage==null?p.elementPath:null),error:result?.error};});}, + mapProbeOutputSources(){const b=this.mapProbe?.binding;if(b?.kind!=='output')return [];const rule=this.mapSnapshot.payload.rules.find(r=>r.blocks.some(block=>block.id===b.output)),block=rule?.blocks.find(block=>block.id===b.output);return block?Object.entries(block.fields).map(([field,binding])=>({title:this.fieldsFor(block.kind).find(f=>f.key===field)?.label||field,binding,rule:rule.id,row:rule.id===this.mapProbe.rule?this.mapProbe.row:null})):[];}, + mapProbeParents(){return this.mapProbe?.traces[0]?.trace.parents||[];}, + mapProbeFollow(trace,event){this.mapExplore({rule:this.mapProbe.rule,row:this.mapProbe.row,binding:trace.binding,title:tr('map_source_chain')},event);}, + mapProbeTarget(){const b=this.mapProbe?.binding;if(!b)return '';if(b.variable)return 'v:'+b.variable;if(b.iteration)return 'i:'+b.iteration;if(b.output)return 'b:'+b.output;if(b.kind==='literal'&&this.mapNodes.some(n=>n.id==='m:'+b.value))return 'm:'+b.value;return '';}, + mapExploreToken(item,field,token,event){const origin=this.mapResourceOrigin(item,field.key);if(!origin.binding)origin.value=field.value;const index=Number(token.path?.split(' / ')[0])-1;if(origin.binding?.kind==='list' && origin.binding.items[index])origin.binding=origin.binding.items[index];else origin.elementPath=token.path;this.mapExplore(origin,event);}, mapOpen:false,mapBusy:false,mapError:'',mapSnapshot:null,mapNodes:[],mapSelected:'',mapExpanded:{},mapRow:'all', async openGenerationMap(){origin=document.activeElement;this.mapOpen=true;this.mapBusy=true;this.mapSnapshot=null;this.mapNodes=[];this.mapSelected='';this.mapExpanded={};this.mapRow='all';await this.$nextTick();this.$refs.generationMap.showModal();await this.refreshGenerationMap();}, - closeGenerationMap(){request++;graph?.destroy();graph=null;this.$refs.generationMap.close();this.mapOpen=false;origin?.focus();}, - async refreshGenerationMap(){const sequence=++request;this.mapBusy=true;this.mapError='';try{const payload=this.generationPayload(),workspace=this.workspace.id;const report=await this.api(this.endpoint('/generation-preview'),'POST',payload);if(sequence!==request || !this.mapOpen || workspace!==this.workspace.id)return;this.mapSnapshot={payload,report};await this.$nextTick();this.drawGenerationMap();}catch(e){if(sequence===request)this.mapError=e.message;}finally{if(sequence===request)this.mapBusy=false;}}, + closeGenerationMap(){this.mapProbeClose();request++;graph?.destroy();graph=null;this.$refs.generationMap.close();this.mapOpen=false;origin?.focus();}, + async refreshGenerationMap(){const sequence=++request;this.mapBusy=true;this.mapError='';try{const payload=this.generationPayload(),workspace=this.workspace.id,document=copy(this.doc);const report=await this.api(this.endpoint('/generation-preview'),'POST',payload);if(sequence!==request || !this.mapOpen || workspace!==this.workspace.id)return;this.mapSnapshot={payload,report,document};await this.$nextTick();this.drawGenerationMap();}catch(e){if(sequence===request)this.mapError=e.message;}finally{if(sequence===request)this.mapBusy=false;}}, mapSelect(id){this.mapSelected=id;this.mapRow='all';if(graph){graph.elements().removeClass('selected related');const n=graph.getElementById(id);n.addClass('selected');n.closedNeighborhood().addClass('related');}}, mapCurrent(){return this.mapNodes.find(n=>n.id===this.mapSelected);}, mapTrace(rule){return this.mapSnapshot?.report.trace?.find(t=>t.rule===rule);}, - mapResources(){const n=this.mapCurrent(),report=this.mapSnapshot?.report;if(this.mapBusy || this.mapError || !n || n.variable || !report?.result)return [];const row=this.mapRow==='all'?null:this.mapTrace(n.rule)?.rows[Number(this.mapRow)];return report.result.managed.filter(m=>(!n.rule || m.rule===n.rule)&&(!n.block || m.block===n.block)&&(!row || row.resources.includes(m.id))).map(m=>{const kind=['rotation','priority'].includes(m.kind)?'schedules':m.kind==='escalation'?'escalations':'routes';const resource=report.result[kind].find(r=>r.id===m.id);return {...m,resource,name:resource?.name||m.id};});}, + mapResources(){const n=this.mapCurrent(),report=this.mapSnapshot?.report;if(this.mapBusy || this.mapError || !n || n.variable)return [];if(n.manual)return [{id:n.resourceId,kind:n.manualKind,resource:n.source,name:n.source.name,manual:true}];if(!report?.result)return [];const row=this.mapRow==='all'?null:this.mapTrace(n.rule)?.rows[Number(this.mapRow)];return report.result.managed.filter(m=>(!n.rule || m.rule===n.rule)&&(!n.block || m.block===n.block)&&(!n.resourceId || m.id===n.resourceId)&&(!row || row.resources.includes(m.id))).map(m=>{const kind=['rotation','priority'].includes(m.kind)?'schedules':m.kind==='escalation'?'escalations':'routes';const resource=report.result[kind].find(r=>r.id===m.id);return {...m,resource,name:resource?.name||m.id};});}, mapIssues(){const n=this.mapCurrent();return (this.mapSnapshot?.report.issues||[]).filter(i=>!n || (n.variable?i.variable_id===n.variable:!i.rule_id || i.rule_id===n.rule)&&(!n.block || !i.block_id || i.block_id===n.block));}, mapRows(){return this.mapTrace(this.mapCurrent()?.rule)?.rows||[];}, mapRowLabel(row,index){const rule=this.mapSnapshot.payload.rules.find(r=>r.id===this.mapCurrent()?.rule);const names=(rule?.iterations||[]).map((iteration,i)=>{const v=row.inputs?.[iteration.id];return typeof v==='object'&&v!==null?(v.name||v.title||row.labels?.[i]||iteration.name):String(v??iteration.name);});return (index+1)+'. '+(names.join(' / ')||tr('map_once'))+' · '+tr('map_'+row.status,{count:1});}, @@ -22,8 +45,8 @@ function generationMapUi(){let graph=null,origin=null,request=0;return { const producer=payload.rules.find(r=>r.blocks.some(b=>b.id===value.output)),block=producer?.blocks.find(b=>b.id===value.output); if(block && (n.block || producer.id!==n.rule)){const managed=report.result?.managed.filter(m=>m.block===block.id)||[],resources=[...(report.result?.schedules||[]),...(report.result?.escalations||[]),...(report.result?.routes||[])],resource=managed.length===1?resources.find(r=>r.id===managed[0].id):null,key='o:'+block.id;const target=this.mapNodes.find(v=>v.block===block.id)||this.mapNodes.find(v=>v.id==='g:'+producer.id)||this.mapNodes.find(v=>v.id==='r:'+producer.id);sources.set(key,{id:key,name:resource?.name||producer.name,type:['rotation','priority'].includes(block.kind)?tr('resource_schedule'):this.label(block.kind),count:managed.length,generated:true,addresses:[],target:target?.id});} } - if(value.kind==='literal'&&typeof value.value==='string'){const id=value.value,resource=[...this.doc.schedules,...this.doc.escalations,...this.doc.routes].find(r=>r.id===id);if(resource)sources.set('saved:'+id,{id:'saved:'+id,name:resource.name,type:tr('source_saved_resource',{name:''}),addresses:[]});} - Object.values(value).forEach(child=>scan(child,owner,depth+1)); + if(value.kind==='literal'&&typeof value.value==='string'){const id=value.value,resource=[...this.doc.schedules,...this.doc.escalations,...this.doc.routes].find(r=>r.id===id);if(resource)sources.set('saved:'+id,{id:'saved:'+id,name:resource.name,type:tr('source_saved_resource',{name:''}),addresses:[],target:'m:'+id});} + Object.entries(value).forEach(([key,child])=>{if(key!=='recipient'||!(value.team_all||value.team_random))scan(child,owner,depth+1);}); }; if(n.variable)variable(n.variable);else if(n.kind==='iteration')scan(n.source.source);else if(n.kind==='condition')scan(rule?.when);else if(rule){if(!n.block)scan(rule.iterations);scan(rule.blocks.filter(b=>!n.block||b.id===n.block));if(!n.block)scan(rule.when);} return [...sources.values()]; @@ -37,7 +60,7 @@ function generationMapUi(){let graph=null,origin=null,request=0;return { if(n.kind==='condition')return [{title:tr('map_condition'),rows:[{label:tr('map_condition_left'),values:unique(rows.map(r=>r.condition?.left))},{label:tr('map_condition_operation'),values:[this.label(rule.when.operation)]},{label:tr('map_condition_right'),values:unique(rows.map(r=>r.condition?.right))}]}]; return rule.blocks.filter(b=>!n.block||n.block===b.id).map(block=>{ const inputs=rows.flatMap(r=>(r.blocks||[]).filter(b=>b.block===block.id)),fields=this.fieldsFor(block.kind),out=[]; - for(const field of fields){const values=unique(inputs.map(b=>b.fields[field.key]));if(values.length)out.push({label:field.label,values});} + for(const field of fields){const values=unique(inputs.map(b=>b.fields[field.key]));if(values.length)out.push({label:field.label,values,binding:block.fields[field.key]});} const maxSteps=Math.max(0,...inputs.map(b=>b.steps.length));for(let i=0;ib.steps[i]?.delay))},{label:tr('map_step_recipient',{step:i+1}),values:unique(inputs.map(b=>b.steps[i]?.recipient))});} const conditions=unique(inputs.flatMap(b=>b.conditions));for(const c of conditions)out.push({label:this.label(c.field)+' · '+(c.negate?tr('map_not')+' ':'')+this.label(c.operation),values:[c.value]}); if(block.kind==='route'&&inputs.length&&!conditions.length)out.push({label:tr('map_condition'),values:[tr('map_matches_all')]}); @@ -45,20 +68,18 @@ function generationMapUi(){let graph=null,origin=null,request=0;return { }).filter(section=>section.rows.length); }, mapResourceFields(item){ - const r=item.resource;if(!r)return [];const fields=[],add=(key,value)=>fields.push({label:tr(key),value}); + const r=item.resource;if(!r)return [];const fields=[],add=(key,label,value,hint='')=>fields.push({key,label:tr(label),value,hint}); if(['rotation','priority'].includes(item.kind)){ - add(item.kind==='priority'?'responsible_people_in_priority_order_7205a5':'participants_in_rotation_order_4edee0',r.participants); - if(item.kind==='priority')add('fallback_schedule_7b7a21',r.fallback); - else {add('anchor_date_e77bd6',this.formatDate(r.anchor));add('shift_length_days_0b28fa',r.shift_days);} - add('handover_time_c9e315',r.handover.slice(0,5)+' · '+this.mapSnapshot.report.result.timezone); + add('participants',item.kind==='priority'?'responsible_people_in_priority_order_7205a5':'participants_in_rotation_order_4edee0',r.participants); + if(item.kind==='priority')add('fallback','fallback_schedule_7b7a21',r.fallback); + else {add('anchor','anchor_date_e77bd6',this.formatDate(r.anchor));add('shift_days','shift_length_days_0b28fa',r.shift_days);} + add('handover','handover_time_c9e315',r.handover.slice(0,5)); }else if(item.kind==='escalation'){ - r.steps.forEach((step,index)=>{fields.push({label:tr('map_step_recipient',{step:index+1}),value:step.recipient.id||(step.recipient.type==='team_all'?tr('everyone_on_the_team_235b55'):tr('a_random_team_member_8f35f3')),hint:(step.delay_minutes===0?tr('immediately_21b60a'):step.delay_minutes+tr('min_after_alert_8adeb2'))+' · '+tr(step.condition==='if-not-acked'?'if_unacknowledged_407d03':'if_still_open_c34949')});}); - add('repeat_count_912e08',r.repeat.count&&r.repeat.wait_minutes?tr('repeat_654579')+r.repeat.count+tr('times_wait_e1b163')+r.repeat.wait_minutes+tr('min_f4e8a8'):tr('end_of_policy_no_repeats_c2ac4f')); - if(r.repeat.count&&r.repeat.wait_minutes){add('reset_recipient_states_on_repeat_8152c6',r.repeat.reset_recipient_states);add('close_alert_after_all_repeats_2bec22',r.repeat.close_alert_after_all);} + r.steps.forEach((step,i)=>{add('steps.'+i+'.recipient','escalation_recipient_9dfd31',step.recipient.id||(step.recipient.type==='team_all'?tr('everyone_on_the_team_235b55'):tr('a_random_team_member_8f35f3')),(step.delay_minutes===0?tr('immediately_21b60a'):step.delay_minutes+tr('min_after_alert_8adeb2'))+' · '+tr(step.condition==='if-not-acked'?'if_unacknowledged_407d03':'if_still_open_c34949'));add('steps.'+i+'.delay','delay_in_minutes_e18c95',step.delay_minutes);}); + add('repeat_count','repeat_count_912e08',r.repeat.count);add('repeat_wait','minutes_between_repeats_f17ab0',r.repeat.wait_minutes); }else if(item.kind==='route'){ - add('resource_all_conditions',r.conditions.length?r.conditions.map(c=>this.conditionText(c)):tr('any_alert_4496c3')); - if(r.window){const w=r.window,days=['mon_24b2a0','tue_529541','wed_23408b','thu_3593cc','fri_bbd6e3','sat_6b782d','sun_48c98c'];add('workspace_working_interval_c7d250',w.weekdays.map(d=>tr(days[d-1])).join(', ')+' · '+w.start.slice(0,5)+'–'+w.end.slice(0,5)+' · '+w.timezone);} - add('resource_detail_destination',r.escalation_id||tr('do_not_notify_1a59a6')); + r.conditions.forEach((c,i)=>add('conditions.'+i,'value_8dce17',c.value,this.conditionText(c))); + add('working','only_during_working_hours_fa1eb6',!!r.window);add('escalation','resource_detail_destination',r.escalation_id||tr('do_not_notify_1a59a6')); } return fields; }, @@ -69,27 +90,58 @@ function generationMapUi(){let graph=null,origin=null,request=0;return { const tokens=[];const visit=(v,path='',depth=0)=>{if(tokens.length>=80||depth>10)return;if(typeof v==='string'&&refs.has(v)){tokens.push({...refs.get(v),path});return;}if(Array.isArray(v)){if(!v.length)tokens.push({text:tr('map_empty_value'),path});else v.forEach((item,i)=>visit(item,path?path+' / '+(i+1):String(i+1),depth+1));return;}if(v&&typeof v==='object'){Object.entries(v).forEach(([key,item])=>visit(item,path?path+' / '+key:key,depth+1));return;}tokens.push({path,text:v===null?tr('map_missing_value'):typeof v==='boolean'?tr(v?'true_88b33e':'false_97cdbd'):typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(v)?tr('map_missing_reference'):String(v)});};visit(value);return tokens; }, mapToggle(){const n=this.mapCurrent();if(!n?.rule)return;this.mapExpanded[n.rule]=!this.mapExpanded[n.rule];this.drawGenerationMap();}, - mapFit(){graph?.fit(undefined,45);},mapZoom(factor){if(graph)graph.zoom({level:Math.max(.2,Math.min(2,graph.zoom()*factor)),renderedPosition:{x:graph.width()/2,y:graph.height()/2}});}, - async mapEdit(){const n=this.mapCurrent();if(!n)return;this.closeGenerationMap();await this.openIssue(n.variable?{kind:'variable',id:n.variable}:{kind:'rule',id:n.rule,block:n.block});}, + mapFit(){if(!graph)return;graph.resize();graph.fit(undefined,35);const box=graph.elements().boundingBox(),zoom=graph.zoom();graph.pan({x:35-box.x1*zoom,y:35-box.y1*zoom});},mapZoom(factor){if(graph)graph.zoom({level:Math.max(.2,Math.min(2,graph.zoom()*factor)),renderedPosition:{x:graph.width()/2,y:graph.height()/2}});}, + async mapEdit(){const n=this.mapCurrent();if(!n)return;if(n.manual){this.closeGenerationMap();this.open(n.manualKind==='escalation'?'policy':n.manualKind,n.source);return;}this.closeGenerationMap();await this.openIssue(n.variable?{kind:'variable',id:n.variable}:{kind:'rule',id:n.rule,block:n.block});}, drawGenerationMap(){ if(!this.mapSnapshot || !this.mapOpen)return;graph?.destroy();const {payload,report}=this.mapSnapshot,nodes=[],edges=[],ids=new Set(),blockTargets=new Map(); const add=(id,title,subtitle,kind,x,y,meta={})=>{ids.add(id);nodes.push({id,title,subtitle,kind,...meta,position:{x,y}});}; const edge=(source,target,role='flow')=>{if(source!==target)edges.push({data:{id:role+':'+source+'>'+target,source,target,role}});}; - payload.variables.forEach((v,i)=>add('v:'+v.id,v.name,this.nodeSummary(v.node),'variable',i*300,-160,{variable:v.id,source:v.node})); - for(const [ruleIndex,rule] of payload.rules.entries()){const trace=this.mapTrace(rule.id),count=report.result?.managed.filter(m=>m.rule===rule.id).length||0,iterated=rule.iterations.length>0;const x=ruleIndex*350,y=0; - const issue=report.issues.some(i=>i.rule_id===rule.id),status=!rule.enabled?tr('disabled_f4f447'):issue?tr('map_invalid'):!report.result?tr('map_unverified'):tr('map_count',{count}); - add('r:'+rule.id,rule.name,status,!rule.enabled?'disabled':issue?'error':'rule',x,y,{rule:rule.id,source:rule,step:ruleIndex+1}); - if(ruleIndex)edge('r:'+payload.rules[ruleIndex-1].id,'r:'+rule.id,'order'); + const references=value=>{const out=[];const walk=v=>{if(!v||typeof v!=='object')return;if(v.kind==='variable'||v.kind==='lookup')out.push('v:'+v.variable);if(v.kind==='output')out.push('b:'+v.output);if(v.kind==='literal'&&typeof v.value==='string')out.push('m:'+v.value);if(v.kind==='literal')return;Object.entries(v).forEach(([key,child])=>{if(key!=='recipient'||!(v.team_all||v.team_random))walk(child);});};walk(value);return [...new Set(out)];}; + const firstUse=new Map();payload.rules.forEach((r,i)=>references(r).forEach(id=>{if(!firstUse.has(id))firstUse.set(id,i);})); + const variables=[...payload.variables].sort((a,b)=>(firstUse.get('v:'+a.id)??Infinity)-(firstUse.get('v:'+b.id)??Infinity)); + const columns=[],varX=new Map();let cursor=0; + payload.rules.forEach((r,i)=>{const vars=variables.filter(v=>firstUse.get('v:'+v.id)===i),width=Math.max(300,vars.length*260);columns.push(cursor+width/2);vars.forEach((v,j)=>varX.set(v.id,cursor+130+j*260));cursor+=width+40;}); + variables.filter(v=>!varX.has(v.id)).forEach(v=>{varX.set(v.id,cursor+130);cursor+=280;}); + const resourceY=420+Math.max(0,...payload.rules.map(r=>r.iterations.length+(r.when?1:0)))*90; + for(const v of variables)add('v:'+v.id,v.name,this.nodeSummary(v.node),'variable',varX.get(v.id),40,{variable:v.id,source:v.node,lane:0}); + const resources=report.result||this.mapSnapshot.document,managed=report.result?.managed||[],all=[...resources.schedules,...resources.escalations,...resources.routes]; + for(const [i,rule] of payload.rules.entries()){ + const x=columns[i],trace=this.mapTrace(rule.id),count=managed.filter(m=>m.rule===rule.id).length,issue=report.issues.some(v=>v.rule_id===rule.id); + add('r:'+rule.id,rule.name,!rule.enabled?tr('disabled_f4f447'):issue?tr('map_invalid'):tr('map_count',{count}),'rule',x,240,{rule:rule.id,source:rule,step:i+1,lane:1,disabled:!rule.enabled,error:issue}); + if(i)edge('r:'+payload.rules[i-1].id,'r:'+rule.id,'order'); let parent='r:'+rule.id; - if(iterated){for(const [k,iteration] of rule.iterations.entries()){const id='i:'+iteration.id;add(id,iteration.name+' · '+tr('map_loop',{count:trace?.stages.find(s=>s.iteration===iteration.id)?.count||0}),'{ '+this.bindingAddress(iteration.source,0,rule.iterations)+' }','iteration',x,120+k*110,{rule:rule.id,source:iteration,iteration:true});edge(parent,id);parent=id;}} - const outputY=120+rule.iterations.length*110; - if(rule.when){const id='c:'+rule.id;add(id,tr('map_condition'),tr('map_filtered',{count:trace?.rows.filter(r=>r.status==='filtered').length||0}),'condition',x,outputY,{rule:rule.id,source:rule.when});edge(parent,id);parent=id;} - if(iterated&&!this.mapExpanded[rule.id]){const id='g:'+rule.id;add(id,tr('map_group'),report.result?tr('map_count',{count}):tr('map_unverified'),'batch',x,outputY+(rule.when?110:0),{rule:rule.id,source:rule.blocks,iteration:true});edge(parent,id);parent=id;} - for(const [j,b] of rule.blocks.entries()){if(iterated&&!this.mapExpanded[rule.id]){blockTargets.set(b.id,parent);continue;}const count=report.result?.managed.filter(m=>m.block===b.id).length||0;const id='b:'+b.id;blockTargets.set(b.id,id);const produced=report.result?.managed.filter(m=>m.block===b.id)||[],resource=produced.length===1?[...report.result.schedules,...report.result.escalations,...report.result.routes].find(r=>r.id===produced[0].id):null;const kind=['rotation','priority'].includes(b.kind)?tr('schedule_type_named',{name:this.label(b.kind)}):this.label(b.kind);add(id,resource?resource.name:kind,report.result?(resource?kind:tr('map_count',{count})):tr('map_unverified'),iterated?'batch':'output',x,outputY+(rule.when?110:0)+j*110,{rule:rule.id,block:b.id,source:b});edge(parent,id);parent=id;} + rule.iterations.forEach((it,j)=>{const id='i:'+it.id;add(id,it.name,tr('map_loop',{count:trace?.stages.find(s=>s.iteration===it.id)?.count||0}),'iteration',x,335+j*90,{rule:rule.id,source:it,iteration:true,lane:1});edge(parent,id);parent=id;}); + if(rule.when){const id='c:'+rule.id;add(id,tr('map_condition'),tr('map_filtered',{count:trace?.rows.filter(r=>r.status==='filtered').length||0}),'condition',x,335+rule.iterations.length*90,{rule:rule.id,source:rule.when,lane:1});edge(parent,id);} + let row=0; + for(const block of rule.blocks){ + const produced=managed.filter(m=>m.block===block.id),id='b:'+block.id,kind=['rotation','priority'].includes(block.kind)?'schedule':block.kind; + blockTargets.set(block.id,id); + const single=produced.length===1?all.find(r=>r.id===produced[0].id):null; + add(id,single?.name||this.label(block.kind),report.result?tr('map_count',{count:produced.length}):tr('map_unverified'),'output',x,resourceY+row++*100,{rule:rule.id,block:block.id,source:block,lane:2,resourceKind:kind,iteration:produced.length>1});edge('r:'+rule.id,id); + if(this.mapExpanded[rule.id]&&produced.length>1)for(const m of produced){const resource=all.find(r=>r.id===m.id);add('o:'+m.id,resource?.name||m.id,this.label(block.kind),'output',x,resourceY+row++*100,{rule:rule.id,block:block.id,resourceId:m.id,source:block,lane:2,resourceKind:kind});edge(id,'o:'+m.id);} + } } - const scan=(value,target)=>{if(!value||typeof value!=='object')return;if(value.kind==='variable'||value.kind==='lookup'){if(ids.has('v:'+value.variable))edge('v:'+value.variable,target,'dependency');}if(value.kind==='output'&&blockTargets.has(value.output))edge(blockTargets.get(value.output),target,'dependency');Object.values(value).forEach(v=>scan(v,target));}; - for(const rule of payload.rules){rule.iterations.forEach(i=>scan(i.source,'i:'+i.id));if(rule.when)scan(rule.when,'c:'+rule.id);rule.blocks.forEach(b=>scan(b,blockTargets.get(b.id)));} - const sheets=nodes.filter(n=>n.kind==='batch').flatMap(n=>[2,1].map(layer=>({data:{id:n.id+':sheet:'+layer,owner:n.id,layer,kind:'sheet'},classes:'stack-sheet',position:{x:n.position.x+layer*8,y:n.position.y-layer*8},selectable:false,grabbable:false}))); - this.mapNodes=nodes;graph=cytoscape({container:this.$refs.generationCanvas,elements:[...sheets,...nodes.map(n=>({data:{id:n.id,label:(n.step?n.step+'. ':'')+n.title+'\n'+n.subtitle,kind:n.kind},position:n.position})),...Array.from(new Map(edges.filter(e=>ids.has(e.data.source)&&ids.has(e.data.target)).map(e=>[e.data.id,e])).values())],layout:{name:'preset'},minZoom:.2,maxZoom:2,wheelSensitivity:.2,style:[{selector:'node',style:{shape:'round-rectangle',width:220,height:66,'background-color':'#fff','border-color':'#cad9cf','border-width':1.5,label:'data(label)','text-wrap':'wrap','text-max-width':195,'text-valign':'center',color:'#234236','font-family':'system-ui','font-size':11,'line-height':1.6,'z-index-compare':'manual','z-index':5}},{selector:'node[kind="variable"]',style:{'background-color':'#f4f0e7','border-color':'#d8c9aa'}},{selector:'node[kind="iteration"],node[kind="batch"]',style:{'background-color':'#edf2fb','border-color':'#94add2','border-style':'dashed'}},{selector:'.stack-sheet',style:{label:'','background-color':'#f4f7fc','border-color':'#94add2','border-style':'solid','border-width':1.5,'z-index':1,events:'no'}},{selector:'node[kind="batch"]',style:{'border-style':'solid','border-width':2}},{selector:'node[kind="condition"]',style:{'background-color':'#fff5df','border-color':'#d4b773'}},{selector:'node[kind="rule"]',style:{'background-color':'#e8f3ec','border-color':'#79a68a'}},{selector:'node[kind="disabled"]',style:{opacity:.5}},{selector:'node[kind="error"]',style:{'background-color':'#fcece7','border-color':'#ce7863'}},{selector:'edge',style:{width:1.5,'line-color':'#a4b6ab','target-arrow-color':'#839e8e','target-arrow-shape':'triangle','curve-style':'bezier'}},{selector:'edge[role="order"]',style:{width:3,'line-color':'#428465','target-arrow-color':'#428465','curve-style':'straight','z-index':3}},{selector:'edge[role="dependency"]',style:{'line-style':'dashed',opacity:.2,'curve-style':'unbundled-bezier','control-point-distances':-45,'control-point-weights':.5}},{selector:'edge.related',style:{opacity:1}},{selector:'.related',style:{'border-color':'#428465','line-color':'#428465','target-arrow-color':'#428465'}},{selector:'node.selected',style:{'border-width':3,'border-color':'#27694a'}}]});graph.on('tap','node:not(.stack-sheet)',e=>this.mapSelect(e.target.id()));graph.on('position','node[kind="batch"]',e=>{const p=e.target.position();for(const layer of [1,2])graph.getElementById(e.target.id()+':sheet:'+layer).position({x:p.x+layer*8,y:p.y-layer*8});});graph.fit(undefined,45);if(!ids.has(this.mapSelected))this.mapSelected=nodes.find(n=>n.rule)?.id||nodes[0]?.id||'';this.mapSelect(this.mapSelected); + const manual=all.filter(r=>!(resources.managed||resources.generation?.managed||[]).some(m=>m.id===r.id)); + manual.forEach((r,i)=>{const kind=resources.schedules.includes(r)?r.type:resources.escalations.includes(r)?'escalation':'route';add('m:'+r.id,r.name,tr('map_manual'),'output',cursor+150,resourceY+i*100,{manual:true,resourceId:r.id,source:r,lane:2,resourceKind:['priority','rotation'].includes(kind)?'schedule':kind,manualKind:kind});}); + payload.rules.forEach(rule=>{for(const ref of references(rule)){const target=ref.startsWith('b:')?blockTargets.get(ref.slice(2)):ref;if(target)edge(target,'r:'+rule.id,'dependency');}}); + const maxX=Math.max(400,...nodes.map(n=>n.position.x))+170,minX=-180; + const guides=[];for(const [i,y] of [140,resourceY-60].entries()){guides.push({data:{id:'line-left-'+i,kind:'guide',label:''},position:{x:minX,y},selectable:false,grabbable:false},{data:{id:'line-right-'+i,kind:'guide',label:''},position:{x:maxX,y},selectable:false,grabbable:false},{data:{id:'line-'+i,source:'line-left-'+i,target:'line-right-'+i,role:'boundary'}});} + [ ['map_variables',40],['map_generators',240],['map_resource_lane',resourceY] ].forEach(([key,y],i)=>guides.push({data:{id:'lane-'+i,kind:'lane',label:tr(key)},position:{x:minX,y},selectable:false,grabbable:false})); + this.mapNodes=nodes;graph=cytoscape({container:this.$refs.generationCanvas,elements:[...guides,...nodes.map(n=>({data:{id:n.id,label:(n.step?n.step+'. ':'')+n.title+'\n'+n.subtitle,kind:n.kind,resourceKind:n.resourceKind||'',disabled:!!n.disabled,error:!!n.error},position:n.position})),...edges.filter(e=>ids.has(e.data.source)&&ids.has(e.data.target))],layout:{name:'preset'},minZoom:.15,maxZoom:2,wheelSensitivity:.2,style:[ + {selector:'node',style:{shape:'round-rectangle',width:220,height:66,'background-color':'#fff','border-color':'#92a89a','border-width':1.5,label:'data(label)','text-wrap':'wrap','text-max-width':185,'text-valign':'center',color:'#243c32','font-family':'system-ui','font-size':11}}, + {selector:'node[kind="variable"]',style:{shape:'ellipse','background-color':'#f5eedf'}}, + {selector:'node[kind="rule"],node[kind="iteration"],node[kind="condition"]',style:{shape:'hexagon','background-color':'#e4eee9',width:240}}, + {selector:'node[resourceKind="schedule"]',style:{'background-color':'#e5efff','border-color':'#799dd1'}}, + {selector:'node[resourceKind="escalation"]',style:{'background-color':'#f2e8fb','border-color':'#b18ace'}}, + {selector:'node[resourceKind="route"]',style:{'background-color':'#fff0d9','border-color':'#c5a46c'}}, + {selector:'node[?disabled]',style:{opacity:.5}},{selector:'node[?error]',style:{'border-color':'#bd4343','border-width':3}}, + {selector:'node[kind="guide"]',style:{width:1,height:1,opacity:0,events:'no'}}, + {selector:'node[kind="lane"]',style:{width:150,height:35,shape:'rectangle','border-width':0,'background-opacity':0,'font-weight':700,'text-max-width':145,events:'no'}}, + {selector:'edge',style:{width:1.5,'line-color':'#96a99d','target-arrow-color':'#739681','target-arrow-shape':'triangle','curve-style':'bezier'}}, + {selector:'edge[role="order"]',style:{width:2.5,'line-color':'#428465','curve-style':'straight'}}, + {selector:'edge[role="dependency"]',style:{'line-style':'dashed',opacity:.5}}, + {selector:'edge[role="boundary"]',style:{'target-arrow-shape':'none','curve-style':'straight','line-color':'#b9c9be',events:'no'}}, + {selector:'.related',style:{'border-color':'#286448',opacity:1}},{selector:'node.selected',style:{'border-width':3,'border-color':'#286448'}} + ]});graph.on('tap','node',e=>{if(ids.has(e.target.id()))this.mapSelect(e.target.id());});graph.on('tap',e=>{if(e.target===graph){this.mapSelected='';graph.elements().removeClass('selected related');}});this.mapFit();if(!ids.has(this.mapSelected))this.mapSelected=nodes.find(n=>n.rule)?.id||nodes[0]?.id||'';if(this.mapSelected)this.mapSelect(this.mapSelected); } };} diff --git a/static/generation-modals.html b/static/generation-modals.html index d1c188d..44723ff 100644 --- a/static/generation-modals.html +++ b/static/generation-modals.html @@ -54,8 +54,8 @@ - - + +
@@ -71,3 +71,20 @@
+ diff --git a/static/generation-page.html b/static/generation-page.html index ccee308..4c85a57 100644 --- a/static/generation-page.html +++ b/static/generation-page.html @@ -53,6 +53,6 @@ -
+
diff --git a/static/generation-preview.html b/static/generation-preview.html index cb6d05b..3d124e2 100644 --- a/static/generation-preview.html +++ b/static/generation-preview.html @@ -1,8 +1,13 @@ -
- -
+
diff --git a/static/generation.js b/static/generation.js index 3975cc3..ae8d01d 100644 --- a/static/generation.js +++ b/static/generation.js @@ -1,19 +1,71 @@ // Visual data and binding editors. Only typed data is sent to the server; no user code is evaluated. function generationUi(){return { ...generationMapUi(), + transferMode:'export',transferText:'',transferExport:'',transferError:'',transferResult:null,transferLoading:false,transferSequence:0,transferBase:null, + async openTransfer(){ + await this.run(async()=>{await this.saveSharedGeneration();this.transferSequence++;clearTimeout(this.transferTimer);this.transferBase={revision:this.workspace.revision,shared_revision:this.generationDraftVersion};this.transferMode='export';this.transferText='';this.transferExport='';this.transferError='';this.transferResult=null;this.modal='generation-transfer';await this.exportTransfer();}); + }, + async exportTransfer(){ + const workspace=this.workspace.id;this.transferLoading=true;this.transferError=''; + try{const result=await this.api(this.endpoint('/generation-export'),'POST',this.transferBase);if(this.modal==='generation-transfer' && this.workspace.id===workspace){this.transferExport=result.text;this.transferText=result.text;}} + catch(e){if(this.modal==='generation-transfer')this.transferError=e.message;} + finally{this.transferLoading=false;} + }, + transferSwitch(mode){this.transferSequence++;clearTimeout(this.transferTimer);this.transferLoading=false;this.transferMode=mode;this.transferError='';if(mode==='import')this.transferChanged();}, + transferChanged(){this.transferSequence++;this.transferResult=null;this.transferError='';this.transferLoading=true;clearTimeout(this.transferTimer);this.transferTimer=setTimeout(()=>this.previewTransfer(),350);}, + async previewTransfer(){ + const seq=++this.transferSequence,text=this.transferText,workspace=this.workspace.id; + if(this.modal!=='generation-transfer'||this.transferMode!=='import')return; + try{const result=await this.api(this.endpoint('/generation-import-preview'),'POST',{...this.transferBase,text});if(seq===this.transferSequence&&this.modal==='generation-transfer'&&workspace===this.workspace.id)this.transferResult={...result,text};} + catch(e){if(seq===this.transferSequence&&this.modal==='generation-transfer')this.transferError=e.message;} + finally{if(seq===this.transferSequence)this.transferLoading=false;} + }, + transferCanApply(){return this.transferMode==='import'&&!this.transferLoading&&!this.transferError&&this.transferResult?.text===this.transferText&&!this.transferResult?.preview.issues.length;}, + async applyTransfer(){ + if(!this.transferCanApply())return; + await this.run(async()=>{const saved=await this.api(this.endpoint('/generation-import'),'POST',{...this.transferBase,text:this.transferText,review_id:this.transferResult.review_id});this.detail.generation_draft=saved;this.modal='';await this.loadWorkspace();this.loadGenerationDraft(true);},tr('transfer_applied')); + }, + transferGuideSections(){return ['structure','variables','bindings','generators','schedules','escalations','routes','merge','example'].map(id=>({id,title:tr('transfer_guide_'+id+'_title'),body:tr('transfer_guide_'+id),code:id==='example'}));}, + transferGuideText(){return '# Meguri · meguri/v2\n\n'+this.transferGuideSections().map(s=>'## '+s.title+'\n\n'+(s.code?'```json\n'+s.body+'\n```':s.body)).join('\n\n');}, + async copyTransferGuide(){await this.run(()=>navigator.clipboard.writeText(this.transferGuideText()),tr('transfer_guide_copied'));}, + async copyTransfer(){await this.run(()=>navigator.clipboard.writeText(this.transferExport),tr('transfer_copied'));}, + downloadTransfer(){const url=URL.createObjectURL(new Blob([this.transferExport+'\n'],{type:'application/json'})),a=document.createElement('a');a.href=url;a.download='meguri-config.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}, + + generationSaveTask:null,generationSaveError:'',generationSavedKey:'',generationDraftVersion:0,generationSaving:false, generationDraft:null,generationWorkspace:'',generationResult:null,generationError:'',generationLoading:false,generationSequence:0,bindingEditor:null,bulkNode:null,bulkText:'',generationAll:false,dataAdvanced:false,dataExpanded:{},dataPersonTarget:null,dataPersonQuery:'',ruleAdvanced:false,rulePeopleTarget:null,rulePeopleQuery:'',generationIssuesOpen:false,editorScroll:0,editorOrigin:null, nodeKinds:['text','person','number','boolean','date','time','duration','list','dictionary'], newNode(kind='text',value=null,key=''){return {id:crypto.randomUUID(),kind,key,value:value??(kind==='boolean'?false:['number','duration'].includes(kind)?0:kind==='time'?'11:00':kind==='date'?this.today():''),entries:[]};}, literal(value){return {kind:'literal',value,path:[],prefix:'',suffix:''};}, + generationContent(payload){return JSON.stringify({variables:payload?.variables||[],rules:payload?.rules||[],route_position:payload?.route_position||0});}, + generationDirty(){return !!this.generationDraft && this.generationContent(this.generationPayload())!==this.generationContent(this.doc?.generation);}, + generationUnsaved(){return !!this.generationDraft && this.generationContent(this.generationPayload())!==this.generationSavedKey;}, + publicationDirty(){return !!this.detail?.sync?.has_changes || this.generationDirty();}, loadGenerationDraft(reset=false){ if(!this.workspace)return; - const clean=this.generationDraft && JSON.stringify(this.generationDraft.variables)===JSON.stringify(this.doc.generation.variables||[]) && JSON.stringify(this.generationDraft.rules)===JSON.stringify(this.doc.generation?.rules||[]) && this.generationDraft.route_position===(this.doc.generation?.route_position||0); - if(reset || !this.generationDraft || this.generationWorkspace!==this.workspace.id || (clean && this.generationDraft.revision!==this.workspace.revision)){ - this.generationWorkspace=this.workspace.id;this.generationDraft={revision:this.workspace.revision,variables:copy(this.doc.generation.variables||[]),rules:copy(this.doc.generation?.rules||[]),route_position:this.doc.generation?.route_position||0}; + const shared=this.detail.generation_draft||{revision:0,payload:null}; + const localClean=!this.generationDraft || this.generationContent(this.generationPayload())===this.generationSavedKey; + if(reset || !this.generationDraft || this.generationWorkspace!==this.workspace.id || (localClean && !this.modal && (this.generationDraftVersion!==shared.revision || this.generationDraft.revision!==this.workspace.revision))){ + const data=shared.payload || this.doc.generation; + this.generationWorkspace=this.workspace.id;this.generationDraft={revision:this.workspace.revision,variables:copy(data.variables||[]),rules:copy(data.rules||[]),route_position:data.route_position||0}; + this.generationDraftVersion=shared.revision;this.generationSavedKey=this.generationContent(this.generationDraft);this.generationSaveError=''; this.generationResult=null;this.generationAll=false; } if(this.can('edit_schedules'))this.generationChanged(); }, + async reloadSharedGeneration(){await this.run(async()=>{await this.loadWorkspace();this.loadGenerationDraft(true);});}, + async saveSharedGeneration(){ + if(!this.can('edit_schedules') || !this.generationDraft)return; + while(this.generationSaveTask)await this.generationSaveTask; + if(this.generationSaveError)throw new Error(this.generationSaveError); + const workspace=this.workspace.id,payload=this.generationPayload(),key=this.generationContent(payload); + if(key===this.generationSavedKey)return; + this.generationSaving=true; + const task=this.api(this.endpoint('/generation-draft'),'PUT',{revision:this.generationDraftVersion,payload}); + this.generationSaveTask=task; + try{const saved=await task;if(this.workspace?.id===workspace){this.generationDraftVersion=saved.revision;this.generationSavedKey=key;this.detail.generation_draft=saved;}} + catch(e){if(this.workspace?.id===workspace)this.generationSaveError=e.message;throw e;} + finally{if(this.generationSaveTask===task){this.generationSaveTask=null;this.generationSaving=false;}} + }, generationPayload(){ if(!this.generationDraft)return null; const payload=copy(this.generationDraft); @@ -25,7 +77,7 @@ function generationUi(){return { async previewGeneration(){ const sequence=++this.generationSequence,payload=this.generationPayload(); if(!payload || !this.can('edit_schedules')){this.generationLoading=false;return;} - try{const result=await this.api(this.endpoint('/generation-preview'),'POST',payload);if(sequence===this.generationSequence)this.generationResult={...result,key:JSON.stringify(payload)};} + try{await this.saveSharedGeneration();if(sequence!==this.generationSequence)return;const result=await this.api(this.endpoint('/generation-preview'),'POST',payload);if(sequence===this.generationSequence)this.generationResult={...result,key:JSON.stringify(payload)};} catch(e){if(sequence===this.generationSequence)this.generationError=e.message;} finally{if(sequence===this.generationSequence)this.generationLoading=false;} }, @@ -62,10 +114,19 @@ function generationUi(){return { requestAnimationFrame(()=>{const target=action.block?document.querySelector('[role="dialog"] [data-block-id="'+action.block+'"]'):null;if(target)target.scrollIntoView({block:'start'});}); }, generationCanApply(){return !this.generationLoading && this.generationResult && !this.generationResult.issues.length && this.generationResult.key===JSON.stringify(this.generationPayload());}, - generationChanges(){return this.generationResult?.changes||[];}, + generationChanges(){return this.modal?(this.generationResult?.changes||[]):(this.generationDirty()?(this.generationResult?.publication_changes||[]):(this.detail?.sync?.changes||[]));}, generationVisibleChanges(){const changes=this.generationChanges();return this.generationAll?changes:changes.filter(c=>c.action!=='unchanged').slice(0,8);}, generationCount(action){return this.generationChanges().filter(c=>c.action===action).length;}, - async applyGeneration(){if(!this.generationCanApply())return;await this.run(async()=>{await this.api(this.endpoint('/generation'),'POST',{...this.generationPayload(),review_id:this.generationResult.review_id});await this.loadWorkspaces();this.loadGenerationDraft(true);},tr('workspace_data_and_generated_schedules_saved_publica_e4ae3d'));}, + async stageGeneration(){ + await this.saveSharedGeneration(); + if(!this.generationDirty())return; + const payload={...this.generationPayload(),shared_revision:this.generationDraftVersion}; + const review=await this.api(this.endpoint('/generation-preview'),'POST',payload); + if(review.issues.length)throw new Error(review.issues.map(i=>i.message).join('; ')); + await this.api(this.endpoint('/generation'),'POST',{...payload,review_id:review.review_id}); + this.modal='';await this.loadWorkspace();this.loadGenerationDraft(true); + }, + async applyGeneration(){await this.run(()=>this.stageGeneration(),tr('workspace_data_and_generated_schedules_saved_publica_e4ae3d'));}, generated(id){return (this.doc?.generation?.managed||[]).some(m=>m.id===id);}, moveGenerationRule(index,delta){const list=this.generationDraft.rules,to=index+delta;if(to<0||to>=list.length)return;[list[index],list[to]]=[list[to],list[index]];}, sourceRuleName(id){const owner=this.doc?.generation?.managed?.find(m=>m.id===id);return this.doc?.generation?.rules?.find(r=>r.id===owner?.rule)?.name || this.generationDraft?.rules?.find(r=>r.id===owner?.rule)?.name || tr('generated_resource');}, @@ -160,7 +221,7 @@ function generationUi(){return { const collection=this.modal==='variable'?'variables':'rules',list=this.generationDraft[collection],at=list.findIndex(v=>v.id===this.form.id); at<0?list.push(copy(this.form)):list.splice(at,1,copy(this.form));this.modal='';this.bindingEditor=null;this.generationChanged(); }, - removeGenerationItem(collection,id){this.generationDraft[collection]=this.generationDraft[collection].filter(v=>v.id!==id);}, + removeGenerationItem(collection,id){const item=this.generationDraft[collection].find(v=>v.id===id);if(!item || !window.confirm(tr(collection==='variables'?'confirm_remove_variable':'confirm_remove_generation_rule',{name:item.name})))return;this.generationDraft[collection]=this.generationDraft[collection].filter(v=>v.id!==id);}, nodeRows(root){const rows=[];const visit=(node,parent,depth,index)=>{rows.push({node,parent,depth,index});if(depth<9 && this.dataExpanded[node.id])node.entries.forEach((child,i)=>visit(child,node,depth+1,i));};visit(root,null,0,0);return rows;}, setNodeKind(node,kind){const previous=node.kind;node.kind=kind;node.entries=[];if(['list','dictionary'].includes(kind))node.value=null;else if(kind==='boolean')node.value=false;else if(['number','duration'].includes(kind))node.value=0;else if(kind==='date')node.value=this.today();else if(kind==='time')node.value='11:00';else if(typeof node.value!=='string' || ['date','time'].includes(previous))node.value='';}, toggleDataNode(node){this.dataExpanded[node.id]=!this.dataExpanded[node.id];}, diff --git a/static/index.html b/static/index.html index 77121c9..25ad585 100644 --- a/static/index.html +++ b/static/index.html @@ -233,16 +233,14 @@
-
- - - - - - - - - +
+
+
+

+

+

+

+

@@ -516,12 +514,12 @@
-