Merge NEXT: planning, generation map and portable configuration on top of the OIDC access model
NEXT predates the access work, so the identity layer follows master: access comes from configured groups, instance administrators replace the site administration, and accounts are created by SSO or by an administrator. Kept from NEXT: manual publication with the Review changes panel and shared generator drafts, portable import/export (meguri/v2), the layered generation map with binding provenance, JSM provider changes, contact/approval semantics and the account-owned Person. Dropped: site administration, local sign-up, and the mandatory unique email with its contact/account migrations. The manual-publication migration is renumbered m_0010 after m_0009_access. Accounts without an email still get a Person in every workspace they reach, a changed sign-in email updates the account's person, and an account that loses access keeps its linked person so schedules stay valid. Instance administrators may use Sync and shared drafts. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
38105c65c2
57 files changed
+6890
-431
No files matched your search
@@ -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
|
||||
|
||||
+67
-3
@@ -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.
|
||||
+61
-1
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+66
-4
@@ -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:<ownership digest>]` 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.
|
||||
+65
-1
@@ -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.
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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#"
|
||||
|
||||
+22
-9
@@ -45,20 +45,25 @@ impl Permission {
|
||||
pub struct User {
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
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<String>,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[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<Utc>,
|
||||
pub assignments: Vec<Assignment>,
|
||||
}
|
||||
|
||||
/// A compact representation of a verified calendar-day cycle, independent of providers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecurringRotation {
|
||||
pub start: DateTime<Utc>,
|
||||
pub end: DateTime<Utc>,
|
||||
pub shift_days: u16,
|
||||
pub participants: Vec<Uuid>,
|
||||
}
|
||||
+113
@@ -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<Option<RecurringRotation>> {
|
||||
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::<DateTime<Utc>>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
-6
@@ -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<String>,
|
||||
pub id: Uuid,
|
||||
pub kind: String,
|
||||
pub fields: BTreeMap<String, Binding>,
|
||||
@@ -107,6 +113,8 @@ pub struct Block {
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct BlockWire {
|
||||
#[serde(default)]
|
||||
pub portable_key: Option<String>,
|
||||
pub id: Uuid,
|
||||
pub kind: String,
|
||||
pub fields: BTreeMap<String, Binding>,
|
||||
@@ -133,6 +141,7 @@ impl From<BlockWire> 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<Value> {
|
||||
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::<BTreeMap<_,_>>(),"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::<BTreeMap<_,_>>(),"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::<BTreeMap<_,_>>(),
|
||||
"steps":block.steps.iter().map(|s|json!({"delay":resolved(&s.delay),"recipient":resolved(&s.recipient),"condition":s.condition})).collect::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"conditions":block.conditions.iter().map(|c|json!({"field":c.field,"operation":c.operation,"value":resolved(&c.value),"negate":c.negate})).collect::<Vec<_>>()
|
||||
}));
|
||||
if manual_ids.contains(&id) {
|
||||
|
||||
+112
-11
@@ -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<i64>,
|
||||
pub variables: Vec<Variable>,
|
||||
pub rules: Vec<Rule>,
|
||||
#[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<String>,
|
||||
}
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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}))
|
||||
}
|
||||
+48
-33
@@ -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<usize> {
|
||||
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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -96,6 +96,9 @@ pub struct AuditRow {
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "sync_jobs")]
|
||||
pub struct SyncJobRow {
|
||||
pub authorized_revision: Option<i64>,
|
||||
pub authorized_document: Option<String>,
|
||||
pub published_document: Option<String>,
|
||||
#[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 {
|
||||
|
||||
+738
@@ -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<T: serde::de::DeserializeOwned>(v: Value) -> Result<T> {
|
||||
serde_json::from_value(v).map_err(fail)
|
||||
}
|
||||
fn normalized(s: &str) -> String {
|
||||
s.trim().to_lowercase()
|
||||
}
|
||||
fn unique_names(values: impl Iterator<Item = String>) -> 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<String> {
|
||||
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<Value> {
|
||||
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<String, String>,
|
||||
iterations: BTreeMap<String, String>,
|
||||
outputs: BTreeMap<String, Value>,
|
||||
}
|
||||
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<Value> {
|
||||
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::<Result<_>>()?,
|
||||
);
|
||||
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<Value> {
|
||||
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::<Result<Vec<_>>>()?,"generators":rules}),
|
||||
)
|
||||
}
|
||||
fn identity(
|
||||
scope: Uuid,
|
||||
key: Option<&Value>,
|
||||
fallback: &str,
|
||||
existing: Option<Uuid>,
|
||||
owned: &BTreeSet<Uuid>,
|
||||
) -> Result<Uuid> {
|
||||
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<Uuid>) {
|
||||
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<Uuid>,
|
||||
doc: &WorkspaceDocument,
|
||||
depth: usize,
|
||||
) -> Result<Node> {
|
||||
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::<Result<_>>()?,
|
||||
);
|
||||
}
|
||||
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<Value> {
|
||||
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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.join(",\n"),
|
||||
end
|
||||
)
|
||||
}
|
||||
_ => short,
|
||||
}
|
||||
}
|
||||
render(value, 0)
|
||||
}
|
||||
@@ -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<Value>> {
|
||||
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<BTreeMap<String, String>> {
|
||||
let mut result = BTreeMap::new();
|
||||
for r in &draft.rules {
|
||||
let mut used: BTreeSet<String> = 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<Value> {
|
||||
Ok(match txt(&n["kind"])? {
|
||||
"list" => Value::Array(
|
||||
n["entries"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.map(compact_node)
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
"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::<Result<Vec<_>>>()?})
|
||||
} 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<String, String>) -> Result<Value> {
|
||||
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::<Result<Vec<_>>>()?})
|
||||
}
|
||||
"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<Value> {
|
||||
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::<Result<Vec<_>>>()?})
|
||||
}
|
||||
_ => 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<Value> {
|
||||
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::<Result<_>>()?,
|
||||
);
|
||||
}
|
||||
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::<Result<_>>()?,
|
||||
);
|
||||
}
|
||||
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::<Result<_>>()?,
|
||||
);
|
||||
}
|
||||
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<String> {
|
||||
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<Value> {
|
||||
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::<Result<Vec<_>>>()?;
|
||||
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::<Result<Vec<_>>>()?;
|
||||
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::<Result<Vec<_>>>()?,
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>()});
|
||||
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::<Result<_>>()?,
|
||||
);
|
||||
}
|
||||
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::<Result<_>>()?);
|
||||
}
|
||||
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::<Result<_>>()?);
|
||||
}
|
||||
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<String, Value>) -> 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(())
|
||||
}
|
||||
+588
-68
@@ -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<Vec<Value>> {
|
||||
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<Utc>,
|
||||
) -> Result<String> {
|
||||
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::<Value>(&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::<Value>(&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<Utc>,
|
||||
) -> Result<(Vec<Value>, Vec<Value>)> {
|
||||
if let Some(rotation) = &schedule.recurring {
|
||||
let zone = schedule.timezone.parse::<chrono_tz::Tz>().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::<Result<Vec<_>>>()?;
|
||||
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::<chrono_tz::Tz>().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::<Result<Vec<_>>>()?;
|
||||
return Ok((rotations, vec![]));
|
||||
}
|
||||
let zone = schedule.timezone.parse::<chrono_tz::Tz>().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<Utc>,
|
||||
) -> 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::<Value>(&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::<Result<Vec<_>>>()?;
|
||||
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<Utc>) -> DateTime<Utc> {
|
||||
DateTime::from_timestamp_millis(value.timestamp_millis()).unwrap()
|
||||
}
|
||||
|
||||
fn half_hour_floor(value: DateTime<Utc>, zone: chrono_tz::Tz) -> DateTime<Utc> {
|
||||
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<bool> {
|
||||
let zone = schedule
|
||||
.timezone
|
||||
.parse::<chrono_tz::Tz>()
|
||||
.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"}]}]});
|
||||
|
||||
@@ -15,11 +15,14 @@ pub struct PublishedSchedule {
|
||||
pub name: String,
|
||||
pub timezone: String,
|
||||
pub periods: Vec<Assignment>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recurring: Option<RecurringRotation>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Publication {
|
||||
pub workspace_id: Uuid,
|
||||
pub revision: i64,
|
||||
pub as_of: DateTime<Utc>,
|
||||
pub from: DateTime<Utc>,
|
||||
pub to: DateTime<Utc>,
|
||||
pub schedules: Vec<PublishedSchedule>,
|
||||
@@ -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 {
|
||||
|
||||
+101
-67
@@ -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::<BTreeSet<_>>()
|
||||
{
|
||||
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<serde_json::Value> {
|
||||
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<Uuid>,
|
||||
) -> BTreeSet<Uuid> {
|
||||
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<Uuid>) -> Result<()> {
|
||||
fn validate_approval_access(
|
||||
doc: &WorkspaceDocument,
|
||||
legs: &[ReplacementLeg],
|
||||
members: &BTreeSet<Uuid>,
|
||||
) -> 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<serde_json::Value> {
|
||||
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
|
||||
"#;
|
||||
+174
-26
@@ -30,16 +30,43 @@ pub fn uuid(value: &str) -> Result<Uuid> {
|
||||
pub fn user(row: UserRow) -> Result<User> {
|
||||
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<WorkspaceRecord> {
|
||||
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<Vec<Permission>>,
|
||||
) -> 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<Vec<Value>> {
|
||||
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<Option<WorkspaceDocument>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<Value>(&row.payload)?})
|
||||
}
|
||||
None => json!({"revision":0,"payload":null}),
|
||||
},
|
||||
)
|
||||
}
|
||||
pub async fn save_shared_draft(
|
||||
&self,
|
||||
workspace: Uuid,
|
||||
actor: Uuid,
|
||||
revision: i64,
|
||||
payload: Value,
|
||||
) -> Result<Value> {
|
||||
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<now()) RETURNING j.workspace_id)
|
||||
INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT workspace_id,$2,'publication.requested',jsonb_build_object('revision',$3::bigint)::text,now() FROM queued
|
||||
"#, &[&workspace.to_string(), &actor.to_string(), &revision]).await?;
|
||||
if result.rows_affected().0 == 0 {
|
||||
return Err(Error::Conflict(
|
||||
crate::i18n::text("err_publication_busy_or_stale").into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +554,7 @@ const COMMIT_WORKSPACE: &str = r#"
|
||||
WITH updated AS (
|
||||
UPDATE meguri__workspaces SET document=$3,revision=revision+1
|
||||
WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL AND disabled_at IS NULL
|
||||
AND ($5 <> '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<Value> {
|
||||
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))
|
||||
}
|
||||
+32
-2
@@ -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<Response> {
|
||||
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<Response> {
|
||||
)?);
|
||||
}
|
||||
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)
|
||||
|
||||
+20
-6
@@ -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<now()) AND next_attempt<=now()",&[&token,&job.workspace_id]).await?;
|
||||
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<now()) AND next_attempt<=now() AND authorized_revision=$3 AND state IN ('pending','publishing','failed','conflict')",&[&token,&job.workspace_id,&job.authorized_revision]).await?;
|
||||
if claimed.rows_affected().0 == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -46,7 +50,7 @@ pub async fn tick(db: &Store) -> 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<Utc>)> {
|
||||
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))
|
||||
|
||||
@@ -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}}
|
||||
+14
-10
@@ -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)<Date.parse(row.end) && Date.parse(row.start)<Date.parse(l.end)));},
|
||||
get canSaveAbsence(){return this.modal==='absence' && !this.absenceLoading && this.absenceResult && !this.absenceResult.plan.errors.length && this.absenceResult.key===JSON.stringify(this.absencePayload());},
|
||||
newAbsenceRange(values={}) { const clock=(this.doc?.working_interval?.start||'11:00:00');return {id:crypto.randomUUID(),start:this.form.start,end:this.addDays(this.form.end,1),start_time:clock,end_time:clock,schedule_id:'',replacement:'',replace_projects:false,...values}; },
|
||||
coverAbsenceRow(row){
|
||||
@@ -258,8 +260,8 @@ document.addEventListener('alpine:init', () => {
|
||||
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)<Date.parse(d.end));const assignment=this.calendar.assignments.find(a=>a.schedule_id===gap.schedule_id && a.user_id===gap.user_id && Date.parse(a.start)<=Date.parse(gap.start) && Date.parse(gap.start)<Date.parse(a.end));if(day && assignment)this.openDuty(day,assignment);},
|
||||
@@ -267,6 +269,7 @@ document.addEventListener('alpine:init', () => {
|
||||
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'); },
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<label x-show="['item','key'].includes(row.binding.kind)"><span x-text="t('iteration_18905a')"></span><select :aria-label="t('binding_iteration_f1778d')" x-model="row.binding.iteration"><option value="" x-text="t('choose_an_iteration_5d2162')"></option><template x-for="i in form.iterations" :key="i.id"><option :value="i.id" :selected="row.binding.iteration===i.id" x-text="bindingSummary({kind:row.binding.kind==='key'?'key':'item',iteration:i.id})"></option></template></select></label>
|
||||
<p class="info-note" x-show="row.binding.kind==='output'" x-text="t('generated_reference_hint')"></p>
|
||||
<label x-show="row.binding.kind==='output'"><span x-text="t('output_block_3853ab')"></span><select :aria-label="t('binding_output_block_e2b0ab')" x-model="row.binding.output"><option value="" x-text="t('choose_an_output_747632')"></option><template x-for="b in matchingOutputs(row.type)" :key="b.id"><option :value="b.id" :selected="row.binding.output===b.id" x-text="generatedResourceLabel(b)+' · '+bindingSummary(b.fields.name)"></option></template></select></label>
|
||||
<label x-show="!['literal','output','key'].includes(row.binding.kind)"><span x-text="t('nested_field_list_position_optional_bac49a')"></span><input :aria-label="t('nested_field_or_position_eaeb07')" :value="(row.binding.path||[]).join(' / ')" @input="row.binding.path=$event.target.value.split('/').map(s=>s.trim()).filter(Boolean)" :placeholder="t('primary_1_4b34b8')"><small><span x-text="t('leave_empty_to_use_the_whole_value_list_positions_st_8da342')"></span></small></label>
|
||||
<label x-show="!['literal','output','key'].includes(row.binding.kind)"><span x-text="t('nested_field_list_position_optional_bac49a')"></span><input x-data="{pathText:(row.binding.path||[]).join(' / '),pathBinding:row.binding,pathKind:row.binding.kind}" x-effect="if(pathBinding!==row.binding || pathKind!==row.binding.kind){pathBinding=row.binding;pathKind=row.binding.kind;pathText=(row.binding.path||[]).join(' / ')}" :aria-label="t('nested_field_or_position_eaeb07')" :value="pathText" @input="pathText=$event.target.value;row.binding.path=pathText.split('/').map(s=>s.trim()).filter(Boolean)" :placeholder="t('primary_1_4b34b8')"><small><span x-text="t('leave_empty_to_use_the_whole_value_list_positions_st_8da342')"></span></small></label>
|
||||
<div class="two-columns" x-show="row.type==='text'"><label><span x-text="t('prefix_90eceb')"></span><input :aria-label="t('text_prefix_62b30b')" x-model="row.binding.prefix" :placeholder="t('optional_0c6c41')"></label><label><span x-text="t('suffix_885db9')"></span><input :aria-label="t('text_suffix_f191fa')" x-model="row.binding.suffix" :placeholder="t('optional_0c6c41')"></label></div>
|
||||
</div></template>
|
||||
<div class="binding-value-preview" x-data="{sample:{text:'',context:'',error:''}}" x-effect="sample=bindingValuePreview()">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<dialog class="generation-map" x-ref="generationMap" @cancel.prevent="closeGenerationMap()">
|
||||
<dialog class="generation-map" x-ref="generationMap" @cancel.prevent="mapProbe?mapProbeClose():closeGenerationMap()" @click="if($event.target===$el){const r=$el.getBoundingClientRect();if($event.clientX<r.left || $event.clientX>r.right || $event.clientY<r.top || $event.clientY>r.bottom)closeGenerationMap()}">
|
||||
<header class="map-heading"><div><h2 x-text="t('map_title')"></h2><p x-text="t('map_hint')"></p></div><button type="button" @click="closeGenerationMap()" :aria-label="t('close_dialog_7b2902')" x-text="t('map_close')"></button></header>
|
||||
<div class="map-toolbar"><span class="map-status" :class="{'map-status-error':mapSnapshot?.report.issues.length}" x-text="mapBusy?t('cover_checking'):mapSnapshot?.report.issues.length?t('diagnostics_count',{count:mapSnapshot.report.issues.length}):t('generation_checked')"></span><button @click="refreshGenerationMap()" :disabled="mapBusy" x-text="t('map_refresh')"></button><div class="grow"></div><button @click="mapZoom(.8)" :aria-label="t('map_zoom_out')" x-text="t('map_minus')"></button><button @click="mapZoom(1.25)" :aria-label="t('map_zoom_in')" x-text="t('map_plus')"></button><button @click="mapFit()" x-text="t('map_fit')"></button></div>
|
||||
<p class="validation-error" x-show="mapError" x-text="mapError"></p>
|
||||
@@ -6,11 +6,24 @@
|
||||
<label><span x-text="t('map_selected')"></span><select :aria-label="t('map_selected')" :value="mapSelected" @change="mapSelect($event.target.value)"><template x-for="n in mapNodes" :key="n.id"><option :value="n.id" x-text="n.title"></option></template></select></label>
|
||||
<template x-if="mapCurrent()"><div><h3 x-text="mapCurrent()?.title"></h3><p class="muted" x-text="mapCurrent()?.subtitle"></p><div class="actions"><button x-show="mapCurrent()?.iteration" @click="mapToggle()" x-text="mapExpanded[mapCurrent()?.rule]?t('map_collapse'):t('map_expand')"></button><button @click="mapEdit()" x-text="t('map_edit')"></button></div>
|
||||
<label x-show="mapRows().length"><span x-text="t('map_pass')"></span><select :aria-label="t('map_pass')" x-model="mapRow"><option value="all" x-text="t('map_all')"></option><template x-for="(row,index) in mapRows()" :key="index"><option :value="String(index)" x-text="mapRowLabel(row,index)"></option></template></select></label>
|
||||
<details class="map-inputs" open><summary x-text="t('map_inputs')"></summary><div class="map-input-content"><template x-if="mapRow==='all'"><div class="map-source-list"><p class="muted" x-show="!mapInputSources().length" x-text="t('map_no_sources')"></p><template x-for="source in mapInputSources()" :key="source.id"><div class="map-source-card"><div class="map-source-title"><span class="badge" x-text="(source.generated?t('generated_symbol')+' ':'')+source.type"></span><strong x-text="source.name"></strong><small x-show="source.count>1" x-text="source.count"></small></div><div class="map-source-addresses"><template x-for="address in source.addresses" :key="address"><code x-text="address"></code></template></div><button class="text" x-show="source.target && source.target!==mapSelected" @click="mapSelect(source.target)" x-text="t('map_view_source')"></button></div></template><p class="muted" x-show="mapRows().length" x-text="t('map_choose_pass_values')"></p></div></template><div x-show="mapRow!=='all'"><p class="muted" x-show="!mapInputSections().length" x-text="t('map_no_inputs')"></p><template x-for="(section,sectionIndex) in mapInputSections()" :key="sectionIndex"><section class="map-input-section"><h4 x-text="section.title"></h4><template x-for="(input,inputIndex) in section.rows" :key="inputIndex"><div class="map-input-field"><div class="map-input-label"><span x-text="input.label"></span><small x-show="input.values.length>1" x-text="t('map_distinct_values',{count:input.values.length})"></small></div><template x-for="(value,valueIndex) in input.values" :key="valueIndex"><div class="map-input-value"><template x-for="(token,tokenIndex) in mapInputTokens(value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><strong x-text="token.text"></strong></div></template></div></template></div></template></section></template></div></div></details>
|
||||
<details class="map-inputs" open><summary x-text="t('map_inputs')"></summary><div class="map-input-content"><template x-if="mapRow==='all'"><div class="map-source-list"><p class="muted" x-show="!mapInputSources().length" x-text="t('map_no_sources')"></p><template x-for="source in mapInputSources()" :key="source.id"><div class="map-source-card"><div class="map-source-title"><span class="badge" x-text="(source.generated?t('generated_symbol')+' ':'')+source.type"></span><button class="map-value-link" type="button" :style="{color:mapColor(source.id)}" @click="mapExplore({rule:mapCurrent()?.rule,binding:source.id.startsWith('v:')?{kind:'variable',variable:source.id.slice(2)}:source.id.startsWith('o:')?{kind:'output',output:source.id.slice(2)}:{kind:'literal',value:source.id.slice(6)},title:source.name,value:source.id.startsWith('v:')?sampleNode(mapSnapshot.payload.variables.find(v=>v.id===source.id.slice(2)).node):source.name},$event)" x-text="source.name"></button><small x-show="source.count>1" x-text="source.count"></small></div><div class="map-source-addresses"><template x-for="address in source.addresses" :key="address"><code class="map-address"><span><span x-text="t('symbol_brace_open')"></span> </span><template x-for="(part,partIndex) in mapAddressParts(address)" :key="partIndex"><span><span x-show="part.dot" x-text="part.text"></span><button x-show="!part.dot" type="button" :style="{color:part.color}" @click="mapExploreAddressPart(address,partIndex,$event)" x-text="part.text"></button></span></template><span> <span x-text="t('symbol_brace_close')"></span></span></code></template></div><button class="text" x-show="source.target && source.target!==mapSelected" @click="mapSelect(source.target)" x-text="t('map_view_source')"></button></div></template><p class="muted" x-show="mapRows().length" x-text="t('map_choose_pass_values')"></p></div></template><div x-show="mapRow!=='all'"><p class="muted" x-show="!mapInputSections().length" x-text="t('map_no_inputs')"></p><template x-for="(section,sectionIndex) in mapInputSections()" :key="sectionIndex"><section class="map-input-section"><h4 x-text="section.title"></h4><template x-for="(input,inputIndex) in section.rows" :key="inputIndex"><div class="map-input-field"><div class="map-input-label"><span x-text="input.label"></span><small x-show="input.values.length>1" x-text="t('map_distinct_values',{count:input.values.length})"></small></div><template x-for="(value,valueIndex) in input.values" :key="valueIndex"><div class="map-input-value"><template x-for="(token,tokenIndex) in mapInputTokens(value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><button type="button" class="map-value-link" :style="{color:mapColor(input.label+':'+token.path)}" @click="mapExplore({rule:mapCurrent()?.rule,row:Number(mapRow),binding:input.binding,title:input.label,value},$event)" x-text="token.text"></button></div></template></div></template></div></template></section></template></div></div></details>
|
||||
<div class="map-issues"><template x-for="(issue,index) in mapIssues()" :key="index"><p class="validation-error" x-text="l(issue.message)"></p></template></div>
|
||||
<h4 x-text="t('map_resources',{count:mapResources().length})"></h4><p class="muted" x-show="!mapResources().length" x-text="mapSnapshot?.report.issues.length?t('map_unverified'):t('map_empty')"></p>
|
||||
<template x-for="r in mapResources()" :key="r.id"><details class="map-resource"><summary><span class="badge" x-text="label(r.kind)"></span><strong x-text="r.name"></strong></summary><div class="map-resource-fields"><template x-for="(field,fieldIndex) in mapResourceFields(r)" :key="fieldIndex"><div class="map-input-field"><div class="map-input-label" x-text="field.label"></div><small class="map-field-hint" x-show="field.hint" x-text="field.hint"></small><template x-for="(token,tokenIndex) in mapInputTokens(field.value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><strong x-text="token.text"></strong></div></template></div></template></div></details></template>
|
||||
<h4 x-text="t(mapCurrent()?.manual?'map_manual_resources':'map_generated_resources',{count:mapResources().length})"></h4><p class="muted" x-show="!mapResources().length" x-text="mapSnapshot?.report.issues.length?t('map_unverified'):t('map_empty')"></p>
|
||||
<template x-for="r in mapResources()" :key="r.id"><details class="map-resource"><summary><span class="badge" x-text="label(r.kind)"></span><strong class="map-resource-name"><template x-for="(part,index) in mapNameParts(r)" :key="index"><button type="button" :style="{color:part.color}" @click.stop.prevent="mapExplore(part.origin,$event)" x-text="part.text"></button></template></strong></summary><div class="map-resource-fields"><template x-for="(field,fieldIndex) in mapResourceFields(r)" :key="fieldIndex"><div class="map-input-field"><div class="map-input-label" x-text="field.label"></div><small class="map-field-hint" x-show="field.hint" x-text="field.hint"></small><template x-for="(token,tokenIndex) in mapInputTokens(field.value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><button type="button" class="map-value-link" :style="{color:mapColor(r.block+':'+field.key+':'+token.path)}" @click="mapExploreToken(r,field,token,$event)" x-text="token.text"></button></div></template></div></template></div></details></template>
|
||||
</div></template></aside></div>
|
||||
<footer class="map-legend"><span class="map-legend-variable" x-text="t('map_variables')"></span><span class="map-legend-rule" x-text="t('map_rules')"></span><span class="map-legend-iteration" x-text="t('map_iterations')"></span><span x-text="t('map_readonly')"></span></footer>
|
||||
<footer class="map-legend"><span class="map-legend-variable" x-text="t('map_variables')"></span><span class="map-legend-rule" x-text="t('map_generators')"></span><span class="map-legend-iteration" x-text="t('map_resource_lane')"></span><span x-text="t('map_readonly')"></span></footer>
|
||||
|
||||
<section class="map-probe" popover="manual" x-ref="mapProbe" :style="mapProbeStyle" role="region" :aria-label="t('map_explore')" @keydown.escape.stop.prevent="mapProbeClose()" @click.outside="if(mapProbe && !$event.target.closest('.map-address,.map-value-link,.map-resource-name'))mapProbeClose()">
|
||||
<template x-if="mapProbe"><div><div class="section-line"><h3 x-text="t('map_explore')"></h3><button type="button" @click="mapProbeClose()" x-text="t('done_e9b450')"></button></div>
|
||||
<button type="button" x-show="mapProbeHistory.length" @click="mapProbeBack()" x-text="t('map_explore_back')"></button>
|
||||
<p class="muted" x-text="mapProbe.title"></p><code class="map-address"><span><span x-text="t('symbol_brace_open')"></span> </span><template x-for="(part,index) in mapAddressParts(mapProbeAddress())" :key="index"><span :style="part.dot?{}:{color:part.color}" x-text="part.text"></span></template><span> <span x-text="t('symbol_brace_close')"></span></span></code>
|
||||
<h4 x-text="t('map_source_chain')"></h4><ol class="map-probe-chain">
|
||||
<template x-for="(parent,index) in mapProbeParents()" :key="'parent:'+index"><li><button type="button" @click="mapProbeFollow(parent,$event)" x-text="bindingAddress(parent.binding,0,mapSnapshot.payload.rules.find(r=>r.id===mapProbe.rule)?.iterations||[])"></button></li></template>
|
||||
<template x-for="(stage,index) in mapProbeStages()" :key="'stage:'+index"><li><button type="button" :class="{'active':mapProbe.stage===index}" :aria-current="mapProbe.stage===index?'step':null" @click="mapProbe.stage=index" x-text="stage.path.length?stage.path[stage.path.length-1]:bindingAddress({...mapProbe.binding,path:[],prefix:'',suffix:''},0,mapSnapshot.payload.rules.find(r=>r.id===mapProbe.rule)?.iterations||[])"></button></li></template>
|
||||
<li x-show="mapProbe.traces.length"><button type="button" :class="{'active':mapProbe.stage==null}" :aria-current="mapProbe.stage==null?'step':null" @click="mapProbe.stage=null" x-text="t('map_resolved_value')"></button></li></ol>
|
||||
<div x-show="mapProbeOutputSources().length"><h4 x-text="t('map_source_fields')"></h4><div class="map-probe-fields"><template x-for="(source,index) in mapProbeOutputSources()" :key="index"><button type="button" @click="mapExplore(source,$event)" x-text="source.title"></button></template></div></div>
|
||||
<h4 x-text="t('map_value_examples')"></h4><template x-for="(sample,index) in mapProbeValues()" :key="index"><div class="map-probe-example"><small x-text="sample.label"></small><p class="validation-error" x-show="sample.error" x-text="l(sample.error||'')"></p><template x-for="(token,tokenIndex) in mapInputTokens(sample.value)" :key="tokenIndex"><div class="map-input-token"><small x-text="token.path"></small><span class="badge" x-show="token.kind" x-text="token.kind"></span><strong x-text="token.text"></strong></div></template></div></template>
|
||||
<p class="muted" x-show="mapProbe.traces.length>20" x-text="t('map_examples_limited')"></p><footer class="map-probe-actions" x-show="mapProbeTarget()"><button type="button" @click="mapSelect(mapProbeTarget());mapProbeClose()" x-text="t('map_view_source')"></button></footer></div></template>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
+86
-34
@@ -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<text.length;i++){const c=text[i];if(quote){if(escape)escape=false;else if(c==='\\')escape=true;else if(c===quote)quote='';continue;}if(c==='"'||c==="'"){quote=c;continue;}if(c==='[')depth++;if(c===']')depth--;if(c==='.'&&depth===0){parts.push({text:text.slice(start,i),dot:false});parts.push({text:'.',dot:true});start=i+1;}}parts.push({text:text.slice(start),dot:false});return parts.map((p,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;i<maxSteps;i++){out.push({label:tr('map_step_delay',{step:i+1}),values:unique(inputs.map(b=>b.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);
|
||||
}
|
||||
};}
|
||||
@@ -54,8 +54,8 @@
|
||||
</div></template>
|
||||
</div></template><button type="button" class="text binding-tweak" @click="editBinding(block.fields[field.key] ||= literal(''),field.type,field.label)" :aria-label="t('bind_891f4e')+field.label" x-text="t('binding_picker_button')" :title="t('value_source_c0ae15')"></button>
|
||||
</div></template></div>
|
||||
<template x-if="block.kind==='escalation'"><div><template x-for="(step,stepIndex) in block.steps" :key="stepIndex"><div class="generation-step"><span class="flow-arrow"><span x-text="t('symbol_fc4866')"></span></span><div class="inline-binding inline-delay"><template x-if="step.delay.kind==='literal'"><input type="number" min="0" :aria-label="t('delay_in_minutes_e18c95')" :value="step.delay.value" @input="step.delay.value=$event.target.value===''?null:Number($event.target.value)"></template><button type="button" class="binding-button" x-show="step.delay.kind!=='literal'" @click="editBinding(step.delay,'number',t('delay_in_minutes_e18c95'))" x-text="bindingSummary(step.delay)"></button><span x-text="t('min_f4e8a8')"></span><button type="button" class="binding-popup-trigger" :aria-label="t('bind_891f4e')+t('delay_in_minutes_e18c95')" @click="editBinding(step.delay,'number',t('delay_in_minutes_e18c95'))" x-text="t('binding_picker_button')"></button></div><select :aria-label="t('escalation_condition_5b4c13')" x-model="step.condition"><option value="if-not-acked" x-text="t('if_not_acknowledged_5fc93a')"></option><option value="if-not-closed" x-text="t('if_not_closed_4369ad')"></option></select><select :aria-label="t('recipient_type_32b1f5')" :value="step.user?'person':'schedule'" @change="step.user=$event.target.value==='person'"><option value="schedule" x-text="t('schedule_0a8ada')"></option><option value="person" x-text="t('person_8c41ae')"></option></select><button type="button" class="binding-button grow" @click="editBinding(step.recipient,step.user?'person':'schedule',t('escalation_recipient_9dfd31'))" x-text="bindingSummary(step.recipient)"></button><button type="button" class="text danger" @click="block.steps.splice(stepIndex,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><button type="button" class="text" @click="addGenStep(block)"><span x-text="t('escalation_step_72b706')"></span></button></div></template>
|
||||
<template x-if="block.kind==='route'"><div><p class="muted" x-show="!block.conditions.length"><span x-text="t('matches_any_alert_add_conditions_to_target_this_proj_299abf')"></span></p><template x-for="(condition,conditionIndex) in block.conditions" :key="conditionIndex"><div class="generation-step"><span class="logic-key" x-text="conditionIndex?t('and_c2f934'):t('if_7be63e')"></span><select :aria-label="t('routing_field_a63fdc')" x-model="condition.field"><option value="tags" x-text="t('tags_848eed')"></option><option value="message" x-text="t('message_68f414')"></option><option value="priority" x-text="t('priority_886cbf')"></option><option value="description" x-text="t('description_55f8eb')"></option><option value="source" x-text="t('source_6da13a')"></option></select><select :aria-label="t('routing_operation_01689c')" x-model="condition.operation"><option value="contains" x-text="t('contains_33e15d')"></option><option value="equals" x-text="t('equals_09b6a6')"></option><option value="starts-with" x-text="t('starts_with_2a61f5')"></option><option value="matches" x-text="t('matches_ee2dbd')"></option></select><button type="button" class="binding-button grow" @click="editBinding(condition.value,'text',t('routing_condition_value_f76e2d'))" x-text="bindingSummary(condition.value)"></button><label class="check"><input type="checkbox" x-model="condition.negate"><span x-text="t('not_ca1dd3')"></span></label><button type="button" class="text danger" @click="block.conditions.splice(conditionIndex,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><button type="button" class="text" @click="addGenCondition(block)"><span x-text="t('and_condition_63ce48')"></span></button></div></template>
|
||||
<template x-if="block.kind==='escalation'"><div><template x-for="(step,stepIndex) in block.steps" :key="stepIndex"><div class="generation-step"><span class="flow-arrow"><span x-text="t('symbol_fc4866')"></span></span><div class="inline-binding inline-delay"><template x-if="step.delay.kind==='literal'"><input type="number" min="0" :aria-label="t('delay_in_minutes_e18c95')" :value="step.delay.value" @input="step.delay.value=$event.target.value===''?null:Number($event.target.value)"></template><button type="button" class="binding-button" x-show="step.delay.kind!=='literal'" @click="editBinding(step.delay,'number',t('delay_in_minutes_e18c95'))" x-text="bindingSummary(step.delay)"></button><span x-text="t('min_f4e8a8')"></span><button type="button" class="binding-popup-trigger" :aria-label="t('bind_891f4e')+t('delay_in_minutes_e18c95')" @click="editBinding(step.delay,'number',t('delay_in_minutes_e18c95'))" x-text="t('binding_picker_button')"></button></div><select :aria-label="t('escalation_condition_5b4c13')" x-model="step.condition"><option value="if-not-acked" x-text="t('if_not_acknowledged_5fc93a')"></option><option value="if-not-closed" x-text="t('if_not_closed_4369ad')"></option></select><select :aria-label="t('recipient_type_32b1f5')" :value="step.team_all?'team_all':step.team_random?'team_random':step.user?'person':'schedule'" @change="step.team_all=$event.target.value==='team_all';step.team_random=$event.target.value==='team_random';step.user=$event.target.value==='person'"><option value="schedule" x-text="t('schedule_0a8ada')"></option><option value="person" x-text="t('person_8c41ae')"></option><option value="team_all" x-text="t('everyone_on_the_team_235b55')"></option><option value="team_random" x-text="t('a_random_team_member_8f35f3')"></option></select><button x-show="!step.team_all&&!step.team_random" type="button" class="binding-button grow" @click="editBinding(step.recipient,step.user?'person':'schedule',t('escalation_recipient_9dfd31'))" x-text="bindingSummary(step.recipient)"></button><button type="button" class="text danger" @click="block.steps.splice(stepIndex,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><button type="button" class="text" @click="addGenStep(block)"><span x-text="t('escalation_step_72b706')"></span></button></div></template>
|
||||
<template x-if="block.kind==='route'"><div><p class="muted" x-show="!block.conditions.length"><span x-text="t('matches_any_alert_add_conditions_to_target_this_proj_299abf')"></span></p><template x-for="(condition,conditionIndex) in block.conditions" :key="conditionIndex"><div class="generation-step"><span class="logic-key" x-text="conditionIndex?t('and_c2f934'):t('if_7be63e')"></span><select :aria-label="t('routing_field_a63fdc')" x-model="condition.field"><option value="tags" x-text="t('tags_848eed')"></option><option value="message" x-text="t('message_68f414')"></option><option value="priority" x-text="t('priority_886cbf')"></option><option value="description" x-text="t('description_55f8eb')"></option><option value="source" x-text="t('source_6da13a')"></option></select><select :aria-label="t('routing_operation_01689c')" x-model="condition.operation"><option value="greater-than" x-text="t('routing_greater_than')"></option><option value="less-than" x-text="t('routing_less_than')"></option><option value="contains" x-text="t('contains_33e15d')"></option><option value="equals" x-text="t('equals_09b6a6')"></option><option value="starts-with" x-text="t('starts_with_2a61f5')"></option><option value="matches" x-text="t('matches_ee2dbd')"></option></select><button type="button" class="binding-button grow" @click="editBinding(condition.value,'text',t('routing_condition_value_f76e2d'))" x-text="bindingSummary(condition.value)"></button><label class="check"><input type="checkbox" x-model="condition.negate"><span x-text="t('not_ca1dd3')"></span></label><button type="button" class="text danger" @click="block.conditions.splice(conditionIndex,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><button type="button" class="text" @click="addGenCondition(block)"><span x-text="t('and_condition_63ce48')"></span></button></div></template>
|
||||
</section></template>
|
||||
</section>
|
||||
<details class="recipe-section rule-result" open>
|
||||
@@ -71,3 +71,20 @@
|
||||
<div x-show="!rulePeopleTarget"><!-- GENERATION_PREVIEW --></div>
|
||||
|
||||
</div></template>
|
||||
<template x-if="modal==='generation-transfer'"><div class="config-transfer">
|
||||
<p class="muted" x-text="t('transfer_hint')"></p>
|
||||
<div class="actions"><button type="button" :class="{primary:transferMode==='export'}" @click="transferSwitch('export')" x-text="t('transfer_export')"></button><button type="button" :class="{primary:transferMode==='import'}" @click="transferSwitch('import')" x-text="t('transfer_import')"></button><button type="button" :class="{primary:transferMode==='guide'}" :aria-pressed="transferMode==='guide'" @click="transferSwitch('guide')" x-text="t('transfer_guide')"></button></div>
|
||||
<section class="config-guide" x-show="transferMode==='guide'" :aria-label="t('transfer_guide')"><div class="section-line"><p class="muted" x-text="t('transfer_guide_hint')"></p><button type="button" @click="copyTransferGuide()" x-text="t('transfer_guide_copy')"></button></div><template x-for="section in transferGuideSections()" :key="section.id"><section><h3 x-text="section.title"></h3><pre :class="{'guide-example':section.code}" x-text="section.body"></pre></section></template></section>
|
||||
<div x-show="transferMode==='export'"><label><span x-text="t('transfer_document')"></span><textarea class="config-text" rows="16" readonly :value="transferExport" spellcheck="false"></textarea></label><div class="actions"><button type="button" @click="copyTransfer()" :disabled="!transferExport || transferLoading" x-text="t('transfer_copy')"></button><button type="button" @click="downloadTransfer()" :disabled="!transferExport || transferLoading" x-text="t('transfer_download')"></button></div></div>
|
||||
<div x-show="transferMode==='import'"><label><span x-text="t('transfer_paste')"></span><textarea class="config-text" rows="16" x-model="transferText" @input="transferChanged()" :disabled="busy" spellcheck="false"></textarea></label>
|
||||
<p class="muted" x-text="t('transfer_identity_hint')"></p>
|
||||
<div class="transfer-preview" aria-live="polite"><template x-if="transferResult"><div>
|
||||
<h3 x-text="t('transfer_inputs')"></h3><template x-for="(change,index) in transferResult.inputs" :key="index"><div class="compact-change"><span class="badge" x-text="label(change.action)"></span><strong x-text="change.name"></strong><small x-text="t(change.kind==='variable'?'map_variables':'map_generators')"></small></div></template>
|
||||
<div x-show="transferResult.contacts?.length"><h3 x-text="t('transfer_contacts')"></h3><template x-for="contact in transferResult.contacts" :key="contact.id"><p x-text="contact.email"></p></template></div>
|
||||
<h3 x-text="t('map_generated_resources')"></h3><p x-show="!transferResult.preview.changes.length" x-text="t('transfer_no_resources')"></p><template x-for="change in transferResult.preview.changes" :key="change.id"><div class="compact-change"><span class="badge" x-text="label(change.action)"></span><strong x-text="change.name"></strong><small x-text="label(change.kind)"></small></div></template>
|
||||
<template x-for="(issue,index) in transferResult.preview.issues" :key="index"><p class="validation-error"><strong x-text="issue.path"></strong><span x-text="l(issue.message)"></span></p></template>
|
||||
<p class="validation-warning" x-show="transferResult.preview.cancelled_requests" x-text="transferResult.preview.cancelled_requests+t('pending_requests_will_be_cancelled_because_their_sch_ea9b18')"></p>
|
||||
</div></template></div></div>
|
||||
<p role="status" x-show="transferLoading" x-text="t('cover_checking')"></p><p role="alert" class="validation-error" x-show="transferError" x-text="l(transferError)"></p>
|
||||
<div class="modal-footer"><button type="button" @click="modal=''" x-text="t('close_dialog_7b2902')"></button><button type="button" class="primary" x-show="transferMode==='import'" :disabled="busy || !transferCanApply()" @click="applyTransfer()" x-text="t('transfer_apply')"></button></div>
|
||||
</div></template>
|
||||
@@ -53,6 +53,6 @@
|
||||
</div></section>
|
||||
</div>
|
||||
|
||||
<div class="page-validation" x-data="{validationScope:'generation'}"><!-- VALIDATION_STATUS --></div><div class="generation-review-bar"><!-- GENERATION_PREVIEW --><div class="actions review-actions"><button @click="loadGenerationDraft(true)"><span x-text="t('reload_saved_state_1b1cb9')"></span></button><button class="primary" @click="applyGeneration()" :disabled="busy || !generationCanApply()" x-show="can('edit_schedules')"><span x-text="t('apply_checked_changes_efa2a4')"></span></button></div></div>
|
||||
<div class="page-validation" x-data="{validationScope:'generation'}"><!-- VALIDATION_STATUS --></div>
|
||||
</div></template>
|
||||
</section>
|
||||
@@ -1,8 +1,13 @@
|
||||
<details class="generation-preview generation-review compact-preview" aria-live="polite">
|
||||
<summary><strong x-text="t(modal?'server_preview_09022a':'setup_review_changes')"></strong><span class="preview-totals" x-show="generationResult"><span class="preview-status status-create" x-text="t('preview_created',{count:generationCount('create')})"></span><span class="preview-status status-update" x-text="t('preview_updated',{count:generationCount('update')})"></span><span class="preview-status status-remove" x-text="t('preview_removed',{count:generationCount('remove')})"></span></span><small x-show="generationLoading" x-text="t('cover_checking')"></small></summary>
|
||||
<div class="compact-preview-body"><template x-if="generationResult"><div>
|
||||
<p class="validation-warning" x-show="generationResult.cancelled_requests" x-text="generationResult.cancelled_requests+t('pending_requests_will_be_cancelled_because_their_sch_ea9b18')"></p>
|
||||
<details class="generation-preview generation-review compact-preview" aria-live="polite" :open="!modal && ['failed','conflict'].includes(detail?.sync?.state)">
|
||||
<summary><strong x-text="t(modal?'server_preview_09022a':'publication_details')"></strong><span class="preview-totals" ><span class="preview-status status-create" x-text="t('preview_created',{count:generationCount('create')})"></span><span class="preview-status status-update" x-text="t('preview_updated',{count:generationCount('update')})"></span><span class="preview-status status-remove" x-text="t('preview_removed',{count:generationCount('remove')})"></span></span><small x-show="generationLoading" x-text="t('cover_checking')"></small></summary>
|
||||
<div class="compact-preview-body"><div class="publication-status" x-show="!modal">
|
||||
<span x-text="t('publication_fe5f07')+label(detail?.sync?.state || 'draft')"></span>
|
||||
<span x-text="doc.integration.provider==='mock'?t('local_preview_provider_aca82c'):t('jira_service_management_071f90')"></span>
|
||||
<span class="sync-error" x-text="l(detail?.sync?.last_error || '')"></span>
|
||||
<small x-show="detail?.sync?.window_end" x-text="t('publication_coverage_end',{date:detail?.sync?.window_end?new Date(detail.sync.window_end).toLocaleString(language):''})"></small>
|
||||
</div><div>
|
||||
<p class="validation-warning" x-show="generationResult?.cancelled_requests" x-text="(generationResult?.cancelled_requests||0)+t('pending_requests_will_be_cancelled_because_their_sch_ea9b18')"></p>
|
||||
<div class="compact-change-list"><template x-for="change in generationVisibleChanges()" :key="change.id"><div class="compact-change"><span class="badge preview-status" :class="'status-'+change.action" x-text="label(change.action)"></span><div><strong x-text="change.name"></strong><small x-text="label(change.kind)+(change.source?' · '+change.source:'')"></small></div></div></template></div>
|
||||
<div class="preview-list-footer"><small class="preview-status status-unchanged" x-text="t('preview_unchanged',{count:generationCount('unchanged')})"></small><button class="text preview-list-toggle" type="button" x-show="generationChanges().length>generationVisibleChanges().length || generationAll" @click="generationAll=!generationAll" x-text="generationAll?t('show_fewer_resources_3953fb'):t('show_all_382ebd')+generationChanges().length+t('resources_015def')"></button></div>
|
||||
</div></template></div>
|
||||
</div></div>
|
||||
</details>
|
||||
+68
-7
@@ -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];},
|
||||
|
||||
+26
-25
@@ -233,16 +233,14 @@
|
||||
</div>
|
||||
<div class="actions" x-show="view==='calendar'"><button @click="open('absence')" x-show="can('request_overrides')"><span x-text="t('plan_time_off_33c0fc')"></span></button><button class="primary" x-show="can('request_overrides') || can('force_overrides')" @click="nextOwnDuty()"><span x-text="t('ask_for_cover_53c1cd')"></span></button></div>
|
||||
</div>
|
||||
<div class="sync-bar" x-show="setupView || ['failed','conflict'].includes(detail?.sync?.state)" :class="{'sync-problem':['failed','conflict'].includes(detail?.sync?.state)}">
|
||||
<span class="status-dot" :class="detail?.sync?.state==='synced'?'green':'amber-dot'">
|
||||
</span>
|
||||
<span x-text="detail?.sync?.state==='synced' ? t('schedule_is_in_sync_be394a') : t('publication_fe5f07')+label(detail?.sync?.state || 'pending')">
|
||||
</span>
|
||||
<span class="muted" x-text="doc.integration.provider==='mock'?t('local_preview_provider_aca82c'):t('jira_service_management_071f90')">
|
||||
</span>
|
||||
<span class="sync-error" x-text="l(detail?.sync?.last_error || '')">
|
||||
</span>
|
||||
<button class="text" x-show="can('manage_integration')" @click="retry" :disabled="busy"><span x-text="t('sync_now_2b7d93')"></span></button>
|
||||
<div class="publication-review" data-publication-review>
|
||||
<div class="publication-review-heading"><strong x-text="t('setup_review_changes')"></strong><div class="actions publication-actions"><button type="button" @click="openTransfer()" :disabled="busy || generationSaving" x-show="can('edit_schedules')" x-text="t('transfer_title')"></button><button @click="reloadSharedGeneration()" :disabled="busy || generationSaving" x-show="can('edit_schedules')" x-text="t('reload_saved_state_1b1cb9')"></button><button @click="applyGeneration()" :disabled="busy || !generationCanApply()" x-show="can('edit_schedules')" x-text="t('apply_checked_changes_efa2a4')"></button><button class="publication-sync" :class="{'sync-needed':publicationDirty()}" x-show="can('manage_integration')" @click="retry" :disabled="busy || generationSaving || !!generationSaveError || syncSubmitting || publicationPending() || detail?.sync?.publication_busy"><span x-text="t(syncSubmitting?'publication_submitting':publicationPending()?'publication_pending_button':detail?.sync?.publication_busy?'publication_running_button':'sync_now_2b7d93')"></span></button></div></div>
|
||||
<div class="publication-draft-note"><span x-text="generationSaveError || ((generationSaving || generationUnsaved())?t('shared_draft_saving'):publicationDirty()?t('publication_waiting_click'):t('publication_no_changes'))"></span></div>
|
||||
<p class="muted publication-workflow" x-text="t('publication_workflow_hint')"></p>
|
||||
<p class="publication-draft-note" x-show="detail?.sync?.publication_busy" x-text="t('publication_lease_wait',{date:detail?.sync?.lease_until?formatTime(detail.sync.lease_until):''})"></p>
|
||||
<p class="publication-draft-note" x-show="detail?.sync?.state==='publishing' && !detail?.sync?.publication_busy" x-text="t('publication_lease_expired')"></p>
|
||||
<p class="publication-draft-note" role="status" x-show="publicationPending()" x-text="t('publication_pending_hint')"></p>
|
||||
<!-- GENERATION_PREVIEW -->
|
||||
</div>
|
||||
<section class="coverage-problems panel" x-show="detail?.coverage?.total">
|
||||
<h2 x-text="t('coverage_attention')"></h2>
|
||||
@@ -516,12 +514,12 @@
|
||||
</main>
|
||||
</div>
|
||||
<div class="modal-backdrop" x-cloak x-show="modal" @keydown.escape.window="picker?picker=null:(bindingEditor || rulePeopleTarget)?closeRuleEditor():modal=''" @click.self="modal=''">
|
||||
<section class="modal" :class="{'wide-modal':['variable','generation-rule','absence'].includes(modal),'constructor-modal':modal==='generation-rule'}" role="dialog" aria-modal="true" aria-labelledby="dialog-title" @keydown.tab="trapDialog($event)" x-ref="dialog">
|
||||
<section class="modal" :class="{'wide-modal':['variable','generation-rule','generation-transfer','absence'].includes(modal),'constructor-modal':modal==='generation-rule'}" role="dialog" aria-modal="true" aria-labelledby="dialog-title" @keydown.tab="trapDialog($event)" x-ref="dialog">
|
||||
<form @submit.prevent="save()">
|
||||
<div class="modal-heading">
|
||||
<div>
|
||||
<span class="eyebrow" x-text="modal.startsWith('admin-')?t('access_instance_eyebrow'):['request','day','force'].includes(modal)?scheduleName(form.schedule_id || scheduleId):doc?.name || 'MEGURI'"></span>
|
||||
<h2 id="dialog-title" x-text="modal==='request'?(form.force_mode?t('apply_without_approval_d72926'):form.from_user===user?.id?t('who_can_cover_for_you_6e894c'):t('cover_person',{name:shortName(form.from_user)})):modal==='day'?t('choose_a_shift_330348'):({variable:t('workspace_variable_7abc57'),'generation-rule':t('generation_rule_d34ccf'),rotation:t('rotation_906fe5'),priority:t('ordered_project_coverage_cf12c3'),coverage:t('project_coverage_536388'),request:t('request_a_change_3d465f'),force:t('apply_without_confirmation_f0350d'),absence:t('planned_absence_bfcea7'),holiday:t('public_holiday_49ec03'),'holiday-import':t('import_public_holidays_8098e3'),'remove-schedule':t('remove_schedule_ef0a8a'),person:t('scheduling_person_1273c6'),working:t('workspace_working_interval_c7d250'),'member-permissions':t('access_employee_permissions_title'),'admin-user':t('access_new_user_title'),'admin-groups':t('access_edit_groups_title'),'admin-password':t('access_reset_password_title'),'admin-delete':t('access_delete_workspace_title'),account:t('your_account_4ab291'),policy:t('escalation_policy_bbb0f7'),route:t('routing_rule_b00b22'),integration:t('connected_provider_9702c0'),preferences:t('team_preferences_7fcab5')})[modal]">
|
||||
<h2 id="dialog-title" x-text="modal==='request'?(form.force_mode?t('apply_without_approval_d72926'):form.from_user===user?.id?t('who_can_cover_for_you_6e894c'):t('cover_person',{name:shortName(form.from_user)})):modal==='day'?t('choose_a_shift_330348'):({'generation-transfer':t('transfer_title'),variable:t('workspace_variable_7abc57'),'generation-rule':t('generation_rule_d34ccf'),rotation:t('rotation_906fe5'),priority:t('ordered_project_coverage_cf12c3'),coverage:t('project_coverage_536388'),request:t('request_a_change_3d465f'),force:t('apply_without_confirmation_f0350d'),absence:t('planned_absence_bfcea7'),holiday:t('public_holiday_49ec03'),'holiday-import':t('import_public_holidays_8098e3'),'remove-schedule':t('remove_schedule_ef0a8a'),person:t('scheduling_person_1273c6'),working:t('workspace_working_interval_c7d250'),'member-permissions':t('access_employee_permissions_title'),'admin-user':t('access_new_user_title'),'admin-groups':t('access_edit_groups_title'),'admin-password':t('access_reset_password_title'),'admin-delete':t('access_delete_workspace_title'),account:t('your_account_4ab291'),policy:t('escalation_policy_bbb0f7'),route:t('routing_rule_b00b22'),integration:t('connected_provider_9702c0'),preferences:t('team_preferences_7fcab5')})[modal]">
|
||||
</h2>
|
||||
</div>
|
||||
<button type="button" class="icon-button" @click="modal=''" :aria-label="t('close_dialog_7b2902')"><span x-text="t('symbol_67fba2')"></span></button>
|
||||
@@ -569,11 +567,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="pickerEditing">
|
||||
<label><span x-text="t('name_709a23')"></span><input x-model="pickerName">
|
||||
<label><span x-text="t('name_709a23')"></span><input x-model="pickerName" :readonly="!!people.find(p=>p.id===pickerEditing)?.user_id">
|
||||
</label>
|
||||
<label><span x-text="t('email_for_jsm_lookup_193cfb')"></span><input type="email" x-model="pickerEmail">
|
||||
<label><span x-text="t('email_for_jsm_lookup_193cfb')"></span><input type="email" x-model="pickerEmail" :readonly="!!people.find(p=>p.id===pickerEditing)?.user_id">
|
||||
</label>
|
||||
<label><span x-text="t('scheduling_country_393322')"></span><select :aria-label="t('scheduling_country_393322')" x-model="pickerCountry">
|
||||
<label><span x-text="t('scheduling_country_393322')"></span><select :aria-label="t('scheduling_country_393322')" x-model="pickerCountry" :disabled="!!people.find(p=>p.id===pickerEditing)?.user_id">
|
||||
<template x-for="country in countries" :key="country.code">
|
||||
<option :value="country.code" :selected="pickerCountry===country.code" x-text="country.name">
|
||||
</option>
|
||||
@@ -581,12 +579,14 @@
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="primary" @click="savePersonDetails()"><span x-text="t('use_these_details_775a68')"></span></button>
|
||||
<p class="info-note" x-show="people.find(p=>p.id===pickerEditing)?.user_id" x-text="t('account_person_authoritative')"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template><div x-show="!picker">
|
||||
<template x-if="modal==='account'">
|
||||
<div>
|
||||
<label><span x-text="t('language')"></span><select x-model="form.language" :aria-label="t('language')"><option value="" x-text="t('language_auto')"></option><template x-for="code in I18n.supported"><option :value="code" :selected="form.language===code" x-text="t('language_'+code)"></option></template></select></label>
|
||||
<label x-show="user?.email"><span x-text="t('account_email')"></span><input type="email" :value="user?.email" readonly></label>
|
||||
<label><span x-text="t('display_name_c7874a')"></span><input x-model="form.display_name" required>
|
||||
</label>
|
||||
<label><span x-text="t('country_of_work_88453b')"></span><select :aria-label="t('country_of_work_88453b')" x-model="form.country">
|
||||
@@ -613,17 +613,18 @@
|
||||
</template>
|
||||
<template x-if="modal==='person'">
|
||||
<div>
|
||||
<label><span x-text="t('name_709a23')"></span><input x-model="form.display_name" required>
|
||||
<label><span x-text="t('name_709a23')"></span><input x-model="form.display_name" required :readonly="!!form.user_id">
|
||||
</label>
|
||||
<label><span x-text="t('email_for_jsm_lookup_193cfb')"></span><input type="email" x-model="form.email" :required="!form.user_id">
|
||||
<label><span x-text="t('email_for_jsm_lookup_193cfb')"></span><input type="email" x-model="form.email" required :readonly="!!form.user_id">
|
||||
</label>
|
||||
<label><span x-text="t('scheduling_country_393322')"></span><select :aria-label="t('scheduling_country_393322')" x-model="form.country">
|
||||
<label><span x-text="t('scheduling_country_393322')"></span><select :aria-label="t('scheduling_country_393322')" x-model="form.country" :disabled="!!form.user_id">
|
||||
<template x-for="country in countries" :key="country.code">
|
||||
<option :value="country.code" :selected="form.country===country.code" x-text="country.name">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<p class="info-note" x-show="form.user_id" x-text="t('account_person_authoritative')"></p>
|
||||
<p class="info-note"><span x-text="t('an_email_contact_can_be_scheduled_without_logging_in_34d74f')"></span></p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -704,7 +705,7 @@
|
||||
<div class="cover-range"><small x-text="t('cover_timezone',{zone:doc.timezone})"></small><div class="cover-boundary"><label><span x-text="t('cover_start_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.start_day"><input type="text" :aria-label="t('cover_start_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_start_time')"></span><input type="time" :aria-label="t('cover_start_time')" x-model="form.start_time" @input="coverRangeChanged()" required></label></div><div class="cover-boundary"><label><span x-text="t('cover_end_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.end_day"><input type="text" :aria-label="t('cover_end_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_end_time')"></span><input type="time" :aria-label="t('cover_end_time')" x-model="form.end_time" @input="coverRangeChanged()" required></label></div></div><details class="range-options"><summary x-text="t('cover_swap_optional')"></summary><label class="check"><input type="checkbox" x-model="form.swap"><span x-text="t('i_ll_take_a_shift_in_return_b47888')"></span></label><template x-if="form.swap"><div><div class="cover-boundary"><label><span x-text="t('cover_return_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.return_start"><input type="text" :aria-label="t('cover_return_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_return_time')"></span><input type="time" :aria-label="t('cover_return_time')" x-model="form.return_start_time" @input="coverRangeChanged()" required></label></div><div class="cover-boundary"><label><span x-text="t('cover_return_end_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.return_end"><input type="text" :aria-label="t('cover_return_end_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_return_end_time')"></span><input type="time" :aria-label="t('cover_return_end_time')" x-model="form.return_end_time" @input="coverRangeChanged()" required></label></div></div></template></details>
|
||||
<label x-show="!matchingRequest || form.force_mode || form.custom_range || form.swap"><span x-text="t('comment_optional_f28415')"></span><input :aria-label="t('comment_optional_f28415')" x-model="form.reason" maxlength="1000" :placeholder="t('e_g_ops_123_vacation_befe39')"></label>
|
||||
<p class="validation-error" role="status" x-show="requestError" x-text="l(requestError)"></p><p class="muted" x-show="requestLoading"><span x-text="t('checking_availability_24f241')"></span></p>
|
||||
<div class="request-ready" x-show="requestResult && !requestLoading"><template x-for="leg in requestResult?.legs || []"><small x-text="person(leg.from_user)+' → '+person(leg.to_user)+' · '+formatTime(leg.start)+' → '+formatTime(leg.end)"></small></template><span x-text="form.force_mode?t('applies_immediately_no_approval_needed_842779'):matchingRequest?t('your_acceptance_completes_this_request_3713f7'):(form.from_user===user?.id?shortName(form.to_user):shortName(form.from_user))+t('will_confirm_in_meguri_a4a9b6')"></span></div>
|
||||
<div class="request-ready" x-show="requestResult && !requestLoading"><template x-for="leg in requestResult?.legs || []"><small x-text="person(leg.from_user)+' → '+person(leg.to_user)+' · '+formatTime(leg.start)+' → '+formatTime(leg.end)"></small></template><span x-text="(form.force_mode||requestResult?.applies_immediately)?t('applies_immediately_no_approval_needed_842779'):matchingRequest?t('your_acceptance_completes_this_request_3713f7'):(requestResult?.required_approvers||[]).map(id=>shortName(id)).join(', ')+t('will_confirm_in_meguri_a4a9b6')"></span></div>
|
||||
</div></template>
|
||||
</div></template>
|
||||
<template x-if="modal==='force'">
|
||||
@@ -753,7 +754,7 @@
|
||||
<template x-for="warning in absenceResult.plan.warnings"><p class="validation-warning" x-text="l(warning)"></p></template>
|
||||
<p class="muted" x-show="!absenceResult.plan.rows.length"><span x-text="t('no_scheduled_shifts_change_in_this_interval_98915a')"></span></p>
|
||||
<div class="impact-scroll" x-show="absenceResult.plan.rows.length"><table class="impact-table"><thead><tr><th><span x-text="t('schedule_interval_3cf85c')"></span></th><th><span x-text="t('coverage_80e135')"></span></th><th><span x-text="t('action_97c89a')"></span></th></tr></thead><tbody>
|
||||
<template x-for="row in (showAllAbsence?absenceResult.plan.rows:absenceResult.plan.rows.slice(0,8))" :key="row.schedule_id+row.start"><tr><td class="impact-interval"><strong x-text="row.schedule_name"></strong><small><span x-text="formatTime(row.start)"></span><span x-text="t('symbol_213edd')"></span><span x-text="formatTime(row.end)"></span></small></td><td><span x-text="person(row.before)"></span> <span x-text="t('symbol_213edd')"></span> <strong x-text="row.after?person(row.after):t('uncovered_88710a')"></strong></td><td><button type="button" class="text" x-show="row.status==='uncovered'" @click="coverAbsenceRow(row)" x-text="t('choose_a_substitute_be8f67')"></button><span x-show="row.status!=='uncovered'" x-text="row.status==='replacement'?(form.force?t('apply_now_a356cc'):t('needs_confirmation_722b99')):t('automatic_secondary_fallback_7197c5')"></span></td></tr></template>
|
||||
<template x-for="row in (showAllAbsence?absenceResult.plan.rows:absenceResult.plan.rows.slice(0,8))" :key="row.schedule_id+row.start"><tr><td class="impact-interval"><strong x-text="row.schedule_name"></strong><small><span x-text="formatTime(row.start)"></span><span x-text="t('symbol_213edd')"></span><span x-text="formatTime(row.end)"></span></small></td><td><span x-text="person(row.before)"></span> <span x-text="t('symbol_213edd')"></span> <strong x-text="row.after?person(row.after):t('uncovered_88710a')"></strong></td><td><button type="button" class="text" x-show="row.status==='uncovered'" @click="coverAbsenceRow(row)" x-text="t('choose_a_substitute_be8f67')"></button><span x-show="row.status!=='uncovered'" x-text="row.status==='replacement'?(absenceRowPending(row)?t('needs_confirmation_722b99'):t('apply_now_a356cc')):t('automatic_secondary_fallback_7197c5')"></span></td></tr></template>
|
||||
</tbody></table></div>
|
||||
<button class="text" type="button" x-show="absenceResult.plan.rows.length>8" @click="showAllAbsence=!showAllAbsence" x-text="showAllAbsence?t('show_fewer_intervals_6d52e6'):t('show_all_382ebd')+absenceResult.plan.rows.length+t('intervals_d03402')"></button>
|
||||
<p class="info-note" x-show="absenceResult.plan.groups.length && !form.force"><span x-text="t('absence_takes_effect_when_saved_each_replacement_con_a55d09')"></span></p>
|
||||
@@ -1008,7 +1009,7 @@
|
||||
<option value="message" x-text="t('message_68f414')"></option>
|
||||
<option value="description" x-text="t('description_55f8eb')"></option>
|
||||
</select>
|
||||
<select :aria-label="t('operation_430d32')" x-model="c.operation">
|
||||
<select :aria-label="t('operation_430d32')" x-model="c.operation"><option value="greater-than" x-text="t('routing_greater_than')"></option><option value="less-than" x-text="t('routing_less_than')"></option>
|
||||
<option value="contains" x-text="t('contains_33e15d')"></option>
|
||||
<option value="equals" x-text="t('equals_09b6a6')"></option>
|
||||
<option value="starts-with" x-text="t('starts_with_2a61f5')"></option>
|
||||
@@ -1061,17 +1062,17 @@
|
||||
<!-- GENERATION_MODALS -->
|
||||
<div class="modal-footer cover-footer" x-show="modal==='request' && Date.parse(form.end)>Date.now() && (can('request_overrides') || can('force_overrides'))"><div class="form-validation" x-data="{validationScope:'form'}"><!-- VALIDATION_STATUS --></div>
|
||||
<button type="button" @click="modal=''"><span x-text="t('cancel_77dfd2')"></span></button>
|
||||
<button type="button" class="force-action" x-show="can('force_overrides') && !form.force_mode" @click="form.force_mode=true"><span x-text="t('apply_without_approval_d72926')"></span></button>
|
||||
<button type="button" class="force-action" x-show="can('force_overrides') && !form.force_mode && !requestResult?.applies_immediately" @click="form.force_mode=true"><span x-text="t('apply_without_approval_d72926')"></span></button>
|
||||
<button type="button" x-show="form.force_mode && can('request_overrides')" @click="form.force_mode=false;form.to_user=form.from_user===user?.id?'':user?.id"><span x-text="t('request_approval_instead_b84265')"></span></button>
|
||||
<button type="button" class="primary" @click="sendQuickRequest()" :disabled="busy || !canSendRequest" x-show="can('request_overrides') || form.force_mode" x-text="form.force_mode?t('apply_now_a356cc'):matchingRequest && !form.custom_range && !form.swap?t('accept_cover_d9a527'):form.from_user===user?.id?t('send_request_a89d64'):t('offer_to_cover_054c95')"></button>
|
||||
<button type="button" class="primary" @click="sendQuickRequest()" :disabled="busy || !canSendRequest" x-show="can('request_overrides') || form.force_mode" x-text="form.force_mode || requestResult?.applies_immediately?t('apply_now_a356cc'):matchingRequest && !form.custom_range && !form.swap?t('accept_cover_d9a527'):form.from_user===user?.id?t('send_request_a89d64'):t('offer_to_cover_054c95')"></button>
|
||||
</div>
|
||||
<div class="modal-footer" x-show="!['request','day'].includes(modal)">
|
||||
<div class="modal-footer" x-show="!['request','day','generation-transfer'].includes(modal)">
|
||||
<div class="form-validation" x-data="{validationScope:'form'}"><!-- VALIDATION_STATUS --></div>
|
||||
<button type="button" x-show="modal==='account'" @click="modal='';signOut()"><span x-text="t('sign_out_dc1649')"></span></button>
|
||||
<button type="button" @click="modal=''"><span x-text="t('cancel_77dfd2')"></span></button>
|
||||
|
||||
<button type="button" class="primary" x-show="rulePeopleTarget" @click="closeRuleEditor()" x-text="t('done_e9b450')"></button>
|
||||
<button x-show="!rulePeopleTarget" class="primary" :class="{'remove-confirm':modal==='remove-schedule'}" :disabled="busy || (modal==='holiday-import' && !canImport) || (modal==='absence' && !canSaveAbsence) || (modal==='remove-schedule' && !canRemoveSchedule) || (modal==='admin-delete' && form.confirm!==form.expected)" x-text="busy?t('saving_56a228'):modal==='admin-user'?t('access_create_user'):modal==='admin-password'?t('access_reset_password'):modal==='admin-delete'?t('access_delete_permanently'):modal==='remove-schedule'?(form.redirect_to?t('remove_redirect_references_f1a241'):removal?.plan.escalation_steps.length?t('remove_schedule_affected_steps_7348af'):t('remove_schedule_ef0a8a')):modal==='holiday-import'?t('import_new_holidays_b36db6'):modal==='request'?t('send_for_confirmation_f3df0f'):modal==='force'?t('apply_replacement_9b0845'):['variable','generation-rule'].includes(modal)?t('use_in_draft_a8f78c'):t('save_changes_179359')">
|
||||
<button x-show="!rulePeopleTarget" class="primary" :class="{'remove-confirm':modal==='remove-schedule'}" :disabled="busy || (modal==='holiday-import' && !canImport) || (modal==='absence' && !canSaveAbsence) || (modal==='remove-schedule' && !canRemoveSchedule) || (modal==='admin-delete' && form.confirm!==form.expected)" x-text="busy?t('saving_56a228'):modal==='admin-user'?t('access_create_user'):modal==='admin-password'?t('access_reset_password'):modal==='admin-delete'?t('access_delete_permanently'):modal==='remove-schedule'?(form.redirect_to?t('remove_redirect_references_f1a241'):removal?.plan.escalation_steps.length?t('remove_schedule_affected_steps_7348af'):t('remove_schedule_ef0a8a')):modal==='holiday-import'?t('import_new_holidays_b36db6'):modal==='request'?(requestResult?.applies_immediately?t('apply_replacement_9b0845'):t('send_for_confirmation_f3df0f')):modal==='force'?t('apply_replacement_9b0845'):['variable','generation-rule'].includes(modal)?t('use_in_draft_a8f78c'):t('save_changes_179359')">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+506
-31
@@ -1670,9 +1670,9 @@
|
||||
"fr": " périodes"
|
||||
},
|
||||
"absence_takes_effect_when_saved_each_replacement_con_a55d09": {
|
||||
"en": "Absence takes effect when saved. Each replacement confirms all their affected shifts in one request per range. Until confirmed, uncovered intervals block publication; the provider keeps its last published configuration.",
|
||||
"ru": "Отсутствие учитывается сразу после сохранения. Подменяющий подтверждает все свои затронутые смены одним запросом на интервал. Пока нет подтверждения, смены без дежурного блокируют публикацию; внешний сервис сохраняет прежнюю конфигурацию.",
|
||||
"fr": "L’absence est prise en compte dès l’enregistrement. Chaque remplaçant confirme toutes ses astreintes dans une demande par période. Les périodes non couvertes bloquent la publication ; le fournisseur conserve sa dernière configuration."
|
||||
"en": "Absence takes effect when saved. Only participants with workspace accounts need to approve replacements; contacts do not. Replacements with no required approvals apply immediately.",
|
||||
"ru": "Отпуск сохраняется сразу. Подтверждение замены требуется только от участников с аккаунтом и доступом к workspace. Контакты без аккаунта не подтверждают; если подтверждения не нужны, замена применяется сразу.",
|
||||
"fr": "L’absence prend effet à l’enregistrement. Seuls les participants disposant d’un compte et d’un accès approuvent les remplacements ; les contacts n’approuvent pas. Sans approbation requise, le remplacement s’applique immédiatement."
|
||||
},
|
||||
"some_shifts_have_no_available_replacement_they_are_s_c20d79": {
|
||||
"en": "Some shifts have no available replacement. They are shown as uncovered and block publication.",
|
||||
@@ -2190,9 +2190,9 @@
|
||||
"fr": "Recharger l’état enregistré"
|
||||
},
|
||||
"apply_checked_changes_efa2a4": {
|
||||
"en": "Apply checked changes",
|
||||
"ru": "Применить проверенные изменения",
|
||||
"fr": "Appliquer les modifications vérifiées"
|
||||
"en": "Save checked changes",
|
||||
"ru": "Сохранить проверенные изменения",
|
||||
"fr": "Enregistrer les modifications vérifiées"
|
||||
},
|
||||
"workspace_variables_2c8617": {
|
||||
"en": "Workspace variables",
|
||||
@@ -3125,9 +3125,9 @@
|
||||
"fr": "Remplacement accepté. Publication en attente."
|
||||
},
|
||||
"replacement_applied_publication_is_queued_89e283": {
|
||||
"en": "Replacement applied. Publication is queued.",
|
||||
"ru": "Подмена применена. Публикация в очереди.",
|
||||
"fr": "Remplacement appliqué. Publication en attente."
|
||||
"en": "Replacement applied. Synchronize to publish.",
|
||||
"ru": "Замена применена. Синхронизируйте для публикации.",
|
||||
"fr": "Remplacement appliqué. Synchronisez pour publier."
|
||||
},
|
||||
"request_sent_waiting_for_your_colleague_b70b64": {
|
||||
"en": "Request sent. Waiting for your colleague.",
|
||||
@@ -3185,9 +3185,9 @@
|
||||
"fr": "\"] .move-route"
|
||||
},
|
||||
"routing_order_saved_publication_is_queued_cfb972": {
|
||||
"en": "Routing order saved. Publication is queued.",
|
||||
"ru": "Порядок правил сохранён. Публикация в очереди.",
|
||||
"fr": "Ordre de routage enregistré. Publication en attente."
|
||||
"en": "Routing order saved. Synchronize to publish.",
|
||||
"ru": "Порядок маршрутов сохранён. Синхронизируйте для публикации.",
|
||||
"fr": "Ordre enregistré. Synchronisez pour publier."
|
||||
},
|
||||
"drift_policy_updated_be40fd": {
|
||||
"en": "Drift policy updated.",
|
||||
@@ -3205,9 +3205,9 @@
|
||||
"fr": "Aperçu de publication téléchargé."
|
||||
},
|
||||
"workspace_data_and_generated_schedules_saved_publica_e4ae3d": {
|
||||
"en": "Workspace data and generated schedules saved. Publication is queued.",
|
||||
"ru": "Данные пространства и расписания сохранены. Публикация в очереди.",
|
||||
"fr": "Données de l’espace et plannings générés enregistrés. Publication en attente."
|
||||
"en": "Workspace changes saved. Synchronize when ready.",
|
||||
"ru": "Изменения workspace сохранены. Синхронизируйте, когда всё будет готово.",
|
||||
"fr": "Modifications enregistrées. Synchronisez lorsque vous êtes prêt."
|
||||
},
|
||||
"choose_person_f20051": {
|
||||
"en": "Choose person",
|
||||
@@ -3700,9 +3700,9 @@
|
||||
"fr": "La valeur de routage doit contenir de 1 à 500 caractères"
|
||||
},
|
||||
"err_jsm_cloud_and_team_ids_must_be_uuids_f75b5f": {
|
||||
"en": "JSM cloud and team IDs must be UUIDs",
|
||||
"ru": "ID облака и команды JSM должны быть UUID",
|
||||
"fr": "Les ID du cloud et de l’équipe JSM doivent être des UUID"
|
||||
"en": "JSM cloud ID must be a UUID; team ID must be a UUID, optionally prefixed with og-",
|
||||
"ru": "ID облака JSM должен быть UUID; ID команды — UUID, возможно с префиксом og-",
|
||||
"fr": "L’ID du cloud JSM doit être un UUID ; l’ID de l’équipe doit être un UUID, éventuellement précédé de og-"
|
||||
},
|
||||
"err_jsm_account_ids_must_contain_1_200_characters_9fb430": {
|
||||
"en": "JSM account IDs must contain 1–200 characters",
|
||||
@@ -3924,11 +3924,6 @@
|
||||
"ru": "Выберите существующего пользователя пространства из справочника",
|
||||
"fr": "Choisissez un utilisateur existant de l’espace dans l’annuaire"
|
||||
},
|
||||
"err_a_selected_replacement_has_no_team_access_to_con_dd538a": {
|
||||
"en": "A selected replacement has no workspace access to confirm. Grant access or ask a lead to apply without confirmation.",
|
||||
"ru": "У выбранного сотрудника нет доступа к пространстве для подтверждения. Выдайте доступ или попросите тимлида применить без согласования.",
|
||||
"fr": "Le remplaçant choisi n’a pas accès à l’espace pour confirmer. Accordez-lui l’accès ou demandez à un responsable d’appliquer sans validation."
|
||||
},
|
||||
"err_comment_must_be_at_most_1000_characters_e5d5ef": {
|
||||
"en": "Comment must be at most 1000 characters",
|
||||
"ru": "Комментарий должен быть не длиннее 1000 символов",
|
||||
@@ -3944,11 +3939,6 @@
|
||||
"ru": "Можно отдать свою смену или предложить подменить коллегу",
|
||||
"fr": "Vous pouvez céder votre astreinte ou proposer de remplacer un collègue"
|
||||
},
|
||||
"err_both_people_need_team_access_to_approve_ask_a_le_7b0fc5": {
|
||||
"en": "Both people need workspace access to approve. Ask a lead to apply without approval for a contact.",
|
||||
"ru": "Оба участника должны иметь доступ к пространстве для подтверждения. Для контакта попросите тимлида применить без согласования.",
|
||||
"fr": "Les deux personnes doivent avoir accès à l’espace pour approuver. Pour un contact, demandez à un responsable d’appliquer sans accord."
|
||||
},
|
||||
"err_this_request_has_already_been_resolved_26b69e": {
|
||||
"en": "This request has already been resolved",
|
||||
"ru": "Этот запрос уже обработан",
|
||||
@@ -5605,9 +5595,9 @@
|
||||
"fr": "Carte de génération"
|
||||
},
|
||||
"map_hint": {
|
||||
"en": "Rules run from left to right. Their resources appear below; stacked cards represent repeated outputs.",
|
||||
"ru": "Правила выполняются слева направо. Результаты — под своим правилом; стопки карточек обозначают повторяющиеся ресурсы.",
|
||||
"fr": "Les règles s’exécutent de gauche à droite. Leurs ressources sont en dessous ; les cartes empilées indiquent des sorties répétées."
|
||||
"en": "Variables → generators → resources. Select a colored value to explore its origin.",
|
||||
"ru": "Переменные → генераторы → ресурсы. Нажмите на цветное значение, чтобы исследовать его происхождение.",
|
||||
"fr": "Variables → générateurs → ressources. Sélectionnez une valeur colorée pour explorer sa provenance."
|
||||
},
|
||||
"map_close": {
|
||||
"en": "Close",
|
||||
@@ -6403,5 +6393,490 @@
|
||||
"en": "v{version}",
|
||||
"ru": "v{version}",
|
||||
"fr": "v{version}"
|
||||
},
|
||||
"jsm_invalid_coverage_interval": {
|
||||
"en": "JSM coverage requires valid intervals of at least one millisecond and an unambiguous base start.",
|
||||
"ru": "Для покрытия JSM нужны интервалы не короче миллисекунды и однозначное начало базовой ротации.",
|
||||
"fr": "La couverture JSM nécessite des intervalles d’au moins une milliseconde et un début de rotation de base non ambigu."
|
||||
},
|
||||
"jsm_coverage_conflict": {
|
||||
"en": "JSM schedule overrides changed outside Meguri. Publication paused.",
|
||||
"ru": "Замены в расписании JSM изменились вне Meguri. Публикация приостановлена.",
|
||||
"fr": "Les remplacements du planning JSM ont changé en dehors de Meguri. Publication suspendue."
|
||||
},
|
||||
"jsm_coverage_response_invalid": {
|
||||
"en": "JSM coverage read-back does not match the requested interval.",
|
||||
"ru": "Сохранённое покрытие JSM не совпадает с запрошенным интервалом.",
|
||||
"fr": "La couverture relue depuis JSM ne correspond pas à l’intervalle demandé."
|
||||
},
|
||||
"err_shared_draft_limit": {
|
||||
"en": "The shared draft exceeds the workspace limits.",
|
||||
"ru": "Общий черновик превышает ограничения workspace.",
|
||||
"fr": "Le brouillon partagé dépasse les limites de l’espace."
|
||||
},
|
||||
"err_shared_draft_stale": {
|
||||
"en": "A colleague changed this draft. Reload the shared draft before saving.",
|
||||
"ru": "Коллега изменил черновик. Загрузите общий черновик заново перед сохранением.",
|
||||
"fr": "Un collègue a modifié ce brouillon. Rechargez le brouillon partagé avant de sauvegarder."
|
||||
},
|
||||
"err_publication_busy_or_stale": {
|
||||
"en": "The workspace changed or publication is running. Refresh before synchronizing.",
|
||||
"ru": "Workspace изменился или публикация уже выполняется. Обновите состояние перед синхронизацией.",
|
||||
"fr": "L’espace a changé ou une publication est en cours. Actualisez avant de synchroniser."
|
||||
},
|
||||
"enum_draft": {
|
||||
"en": "Awaiting synchronization",
|
||||
"ru": "Ожидает синхронизации",
|
||||
"fr": "En attente de synchronisation"
|
||||
},
|
||||
"shared_draft_saving": {
|
||||
"en": "Saving shared draft…",
|
||||
"ru": "Сохраняем общий черновик…",
|
||||
"fr": "Enregistrement du brouillon partagé…"
|
||||
},
|
||||
"publication_waiting_click": {
|
||||
"en": "Changes are shared with your workspace. Synchronize when ready to publish.",
|
||||
"ru": "Изменения доступны коллегам. Нажмите «Синхронизировать», когда всё будет готово.",
|
||||
"fr": "Les modifications sont partagées. Synchronisez quand vous êtes prêt à publier."
|
||||
},
|
||||
"publication_no_changes": {
|
||||
"en": "No unpublished changes.",
|
||||
"ru": "Нет неопубликованных изменений.",
|
||||
"fr": "Aucune modification non publiée."
|
||||
},
|
||||
"publication_coverage_end": {
|
||||
"en": "Published coverage through {date}",
|
||||
"ru": "Покрытие опубликовано до {date}",
|
||||
"fr": "Couverture publiée jusqu’au {date}"
|
||||
},
|
||||
"publication_details": {
|
||||
"en": "Resource changes and publication",
|
||||
"ru": "Изменения ресурсов и публикация",
|
||||
"fr": "Modifications des ressources et publication"
|
||||
},
|
||||
"enum_generation.draft_saved": {
|
||||
"en": "Shared generator draft saved",
|
||||
"ru": "Общий черновик генератора сохранён",
|
||||
"fr": "Brouillon partagé du générateur enregistré"
|
||||
},
|
||||
"confirm_remove_variable": {
|
||||
"en": "Remove variable “{name}” from the shared draft? Rules using it may become invalid.",
|
||||
"ru": "Удалить переменную «{name}» из общего черновика? Правила, использующие её, могут стать невалидными.",
|
||||
"fr": "Supprimer la variable « {name} » du brouillon partagé ? Les règles qui l’utilisent peuvent devenir invalides."
|
||||
},
|
||||
"confirm_remove_generation_rule": {
|
||||
"en": "Remove rule “{name}” from the shared draft?",
|
||||
"ru": "Удалить правило «{name}» из общего черновика?",
|
||||
"fr": "Supprimer la règle « {name} » du brouillon partagé ?"
|
||||
},
|
||||
"publication_workflow_hint": {
|
||||
"en": "Drafts are shared and autosaved. Save checked changes applies a validated draft in Meguri; it is unavailable while checking or when errors remain. Reload retrieves the shared draft. Sync saves and publishes in one step — saving first is optional.",
|
||||
"ru": "Общий черновик сохраняется автоматически. «Сохранить проверенные изменения» применяет проверенный черновик в Meguri; кнопка недоступна во время проверки или при ошибках. Восстановление загружает общий черновик. Синхронизация сохраняет и публикует за один шаг — отдельно сохранять перед ней необязательно.",
|
||||
"fr": "Le brouillon partagé est enregistré automatiquement. Enregistrer l’état applique le brouillon validé dans Meguri ; ce bouton est indisponible pendant la vérification ou en cas d’erreur. Recharger récupère le brouillon partagé. Synchroniser enregistre et publie en une étape ; aucun enregistrement préalable n’est nécessaire."
|
||||
},
|
||||
"routing_greater_than": {
|
||||
"en": "Higher than",
|
||||
"ru": "Выше",
|
||||
"fr": "Supérieur à"
|
||||
},
|
||||
"routing_less_than": {
|
||||
"en": "Lower than",
|
||||
"ru": "Ниже",
|
||||
"fr": "Inférieur à"
|
||||
},
|
||||
"enum_greater-than": {
|
||||
"en": "Higher than",
|
||||
"ru": "Выше",
|
||||
"fr": "Supérieur à"
|
||||
},
|
||||
"enum_less-than": {
|
||||
"en": "Lower than",
|
||||
"ru": "Ниже",
|
||||
"fr": "Inférieur à"
|
||||
},
|
||||
"publication_lease_wait": {
|
||||
"en": "Publication is reserved until {date}. After a restart, recovery waits for this lock to expire to avoid simultaneous writes.",
|
||||
"ru": "Публикация заблокирована для других запусков до {date}. После перезапуска восстановление ждёт истечения блокировки, чтобы избежать одновременной записи.",
|
||||
"fr": "La publication est réservée jusqu’au {date}. Après un redémarrage, la reprise attend l’expiration du verrou pour éviter des écritures simultanées."
|
||||
},
|
||||
"publication_lease_expired": {
|
||||
"en": "The publication lock has expired. The worker can resume; you can also request synchronization again.",
|
||||
"ru": "Блокировка публикации истекла. Воркер может продолжить работу; также можно снова нажать «Синхронизировать».",
|
||||
"fr": "Le verrou de publication a expiré. Le worker peut reprendre ; vous pouvez aussi relancer la synchronisation."
|
||||
},
|
||||
"publication_submitting": {
|
||||
"en": "Submitting…",
|
||||
"ru": "Отправка…",
|
||||
"fr": "Envoi…"
|
||||
},
|
||||
"publication_pending_button": {
|
||||
"en": "Queued…",
|
||||
"ru": "В очереди…",
|
||||
"fr": "En attente…"
|
||||
},
|
||||
"publication_running_button": {
|
||||
"en": "Synchronizing…",
|
||||
"ru": "Синхронизация…",
|
||||
"fr": "Synchronisation…"
|
||||
},
|
||||
"publication_pending_hint": {
|
||||
"en": "Synchronization requested. Waiting for the worker; status updates automatically.",
|
||||
"ru": "Синхронизация запрошена. Ожидаем запуска воркера; статус обновляется автоматически.",
|
||||
"fr": "Synchronisation demandée. En attente du worker ; le statut se met à jour automatiquement."
|
||||
},
|
||||
"map_generators": {
|
||||
"en": "Generators",
|
||||
"ru": "Генераторы",
|
||||
"fr": "Générateurs"
|
||||
},
|
||||
"map_resource_lane": {
|
||||
"en": "Resources",
|
||||
"ru": "Ресурсы",
|
||||
"fr": "Ressources"
|
||||
},
|
||||
"map_manual": {
|
||||
"en": "Manual resource",
|
||||
"ru": "Ручной ресурс",
|
||||
"fr": "Ressource manuelle"
|
||||
},
|
||||
"map_manual_resources": {
|
||||
"en": "Manual resources · {count}",
|
||||
"ru": "Ручные ресурсы · {count}",
|
||||
"fr": "Ressources manuelles · {count}"
|
||||
},
|
||||
"map_generated_resources": {
|
||||
"en": "Generated resources · {count}",
|
||||
"ru": "Сгенерированные ресурсы · {count}",
|
||||
"fr": "Ressources générées · {count}"
|
||||
},
|
||||
"map_explore": {
|
||||
"en": "Explore value origin",
|
||||
"ru": "Исследовать происхождение",
|
||||
"fr": "Explorer la provenance"
|
||||
},
|
||||
"map_explore_back": {
|
||||
"en": "Back",
|
||||
"ru": "Назад",
|
||||
"fr": "Retour"
|
||||
},
|
||||
"map_source_fields": {
|
||||
"en": "Source fields",
|
||||
"ru": "Поля источника",
|
||||
"fr": "Champs de la source"
|
||||
},
|
||||
"map_source_chain": {
|
||||
"en": "Source chain",
|
||||
"ru": "Цепочка источников",
|
||||
"fr": "Chaîne des sources"
|
||||
},
|
||||
"map_source_value": {
|
||||
"en": "Source value",
|
||||
"ru": "Исходное значение",
|
||||
"fr": "Valeur source"
|
||||
},
|
||||
"map_resolved_value": {
|
||||
"en": "Final value",
|
||||
"ru": "Итоговое значение",
|
||||
"fr": "Valeur finale"
|
||||
},
|
||||
"map_value_examples": {
|
||||
"en": "Value examples",
|
||||
"ru": "Примеры значений",
|
||||
"fr": "Exemples de valeurs"
|
||||
},
|
||||
"map_pass_number": {
|
||||
"en": "Pass {number}",
|
||||
"ru": "Итерация {number}",
|
||||
"fr": "Passage {number}"
|
||||
},
|
||||
"map_no_trace": {
|
||||
"en": "No evaluated example in this snapshot",
|
||||
"ru": "В этом снимке нет вычисленного примера",
|
||||
"fr": "Aucun exemple calculé dans cet instantané"
|
||||
},
|
||||
"map_examples_limited": {
|
||||
"en": "Showing the first 20 passes. Select a pass to inspect another.",
|
||||
"ru": "Показаны первые 20 итераций. Выберите итерацию для просмотра остальных.",
|
||||
"fr": "Les 20 premiers passages sont affichés. Sélectionnez un passage pour voir les autres."
|
||||
},
|
||||
"transfer_contacts": {
|
||||
"en": "New scheduling contacts (no login access)",
|
||||
"ru": "Новые контакты дежурств (без доступа к входу)",
|
||||
"fr": "Nouveaux contacts d’astreinte (sans accès de connexion)"
|
||||
},
|
||||
"transfer_title": {
|
||||
"en": "Import / Export",
|
||||
"ru": "Импорт / Экспорт",
|
||||
"fr": "Importer / Exporter"
|
||||
},
|
||||
"transfer_export": {
|
||||
"en": "Export",
|
||||
"ru": "Экспорт",
|
||||
"fr": "Exporter"
|
||||
},
|
||||
"transfer_import": {
|
||||
"en": "Import",
|
||||
"ru": "Импорт",
|
||||
"fr": "Importer"
|
||||
},
|
||||
"transfer_document": {
|
||||
"en": "Configuration · meguri/v2 JSON",
|
||||
"ru": "Конфигурация · meguri/v2 JSON",
|
||||
"fr": "Configuration · JSON meguri/v2"
|
||||
},
|
||||
"transfer_paste": {
|
||||
"en": "Paste or edit configuration",
|
||||
"ru": "Вставьте или отредактируйте конфигурацию",
|
||||
"fr": "Coller ou modifier la configuration"
|
||||
},
|
||||
"transfer_hint": {
|
||||
"en": "Exports variables and generators from the shared draft. Import adds new names and updates matching names; omitted items remain. Applying saves the shared draft. Use Sync separately to publish.",
|
||||
"ru": "Экспортирует переменные и генераторы общего черновика. Импорт добавляет новые имена и обновляет совпадающие; отсутствующие элементы остаются. Применение сохраняет общий черновик. Для публикации отдельно нажмите Sync.",
|
||||
"fr": "Exporte les variables et générateurs du brouillon partagé. L’import ajoute les nouveaux noms et met à jour les noms correspondants ; les éléments absents restent. L’application enregistre le brouillon partagé. Synchronisez séparément pour publier."
|
||||
},
|
||||
"transfer_identity_hint": {
|
||||
"en": "Use ordinary values and email addresses. Keep output keys when editing. Named list entries and people are matched before positions; review resource changes after renaming or reordering. Existing v1 files are still accepted.",
|
||||
"ru": "Используйте обычные значения и email. Сохраняйте ключи выходных блоков при редактировании. Имена элементов списка и люди сопоставляются до позиций; проверяйте изменения ресурсов после переименования или перестановки. Старые файлы v1 поддерживаются.",
|
||||
"fr": "Utilisez des valeurs ordinaires et des emails. Conservez les clés de sortie. Les noms et personnes des listes sont rapprochés avant les positions ; vérifiez les changements après renommage ou réorganisation. Les fichiers v1 restent acceptés."
|
||||
},
|
||||
"transfer_copy": {
|
||||
"en": "Copy",
|
||||
"ru": "Копировать",
|
||||
"fr": "Copier"
|
||||
},
|
||||
"transfer_download": {
|
||||
"en": "Download JSON",
|
||||
"ru": "Скачать JSON",
|
||||
"fr": "Télécharger le JSON"
|
||||
},
|
||||
"transfer_inputs": {
|
||||
"en": "Variables and generators",
|
||||
"ru": "Переменные и генераторы",
|
||||
"fr": "Variables et générateurs"
|
||||
},
|
||||
"transfer_no_resources": {
|
||||
"en": "No resource changes.",
|
||||
"ru": "Изменений ресурсов нет.",
|
||||
"fr": "Aucune modification de ressource."
|
||||
},
|
||||
"transfer_apply": {
|
||||
"en": "Apply to shared draft",
|
||||
"ru": "Применить к общему черновику",
|
||||
"fr": "Appliquer au brouillon partagé"
|
||||
},
|
||||
"transfer_applied": {
|
||||
"en": "Configuration imported into the shared draft.",
|
||||
"ru": "Конфигурация импортирована в общий черновик.",
|
||||
"fr": "Configuration importée dans le brouillon partagé."
|
||||
},
|
||||
"transfer_copied": {
|
||||
"en": "Configuration copied.",
|
||||
"ru": "Конфигурация скопирована.",
|
||||
"fr": "Configuration copiée."
|
||||
},
|
||||
"transfer_invalid": {
|
||||
"en": "Invalid configuration: {detail}",
|
||||
"ru": "Некорректная конфигурация: {detail}",
|
||||
"fr": "Configuration invalide : {detail}"
|
||||
},
|
||||
"transfer_duplicate": {
|
||||
"en": "Duplicate or ambiguous name: {name}",
|
||||
"ru": "Повторяющееся или неоднозначное имя: {name}",
|
||||
"fr": "Nom en double ou ambigu : {name}"
|
||||
},
|
||||
"transfer_reference": {
|
||||
"en": "Source is missing or ambiguous: {name}",
|
||||
"ru": "Источник отсутствует или неоднозначен: {name}",
|
||||
"fr": "Source absente ou ambiguë : {name}"
|
||||
},
|
||||
"transfer_email_required": {
|
||||
"en": "Set a valid scheduling email before exporting this person: {person}",
|
||||
"ru": "Укажите корректный email контакта перед экспортом этого человека: {person}",
|
||||
"fr": "Définissez un email d’astreinte valide avant d’exporter cette personne : {person}"
|
||||
},
|
||||
"transfer_limit": {
|
||||
"en": "Configuration exceeds the size or nesting limit.",
|
||||
"ru": "Конфигурация превышает ограничение размера или вложенности.",
|
||||
"fr": "La configuration dépasse la limite de taille ou d’imbrication."
|
||||
},
|
||||
"transfer_version": {
|
||||
"en": "Expected format: meguri/v2 (or legacy meguri/v1).",
|
||||
"ru": "Ожидаемый формат: meguri/v2 (или прежний meguri/v1).",
|
||||
"fr": "Format attendu : meguri/v2 (ou ancien meguri/v1)."
|
||||
},
|
||||
"transfer_fix_errors": {
|
||||
"en": "Fix the preview errors before importing.",
|
||||
"ru": "Исправьте ошибки предпросмотра перед импортом.",
|
||||
"fr": "Corrigez les erreurs de prévisualisation avant l’import."
|
||||
},
|
||||
"transfer_v2_binding": {
|
||||
"en": "Invalid compact value or reference. Check its type, arguments and required fields.",
|
||||
"ru": "Некорректное компактное значение или ссылка. Проверьте тип, аргументы и обязательные поля.",
|
||||
"fr": "Valeur ou référence compacte invalide. Vérifiez le type, les arguments et les champs requis."
|
||||
},
|
||||
"transfer_guide": {
|
||||
"en": "Guide",
|
||||
"ru": "Guide",
|
||||
"fr": "Guide"
|
||||
},
|
||||
"transfer_guide_copy": {
|
||||
"en": "Copy full guide",
|
||||
"ru": "Скопировать весь гайд",
|
||||
"fr": "Copier le guide complet"
|
||||
},
|
||||
"transfer_guide_copied": {
|
||||
"en": "Full guide copied as Markdown.",
|
||||
"ru": "Полный гайд скопирован в Markdown.",
|
||||
"fr": "Guide complet copié en Markdown."
|
||||
},
|
||||
"transfer_guide_hint": {
|
||||
"en": "Complete v2 reference for hand editing or an LLM. Copy includes every section and the example.",
|
||||
"ru": "Полная справка v2 для ручного редактирования или LLM. Копируются все разделы и пример.",
|
||||
"fr": "Référence v2 complète pour édition manuelle ou LLM. La copie inclut toutes les sections et l’exemple."
|
||||
},
|
||||
"transfer_guide_structure_title": {
|
||||
"en": "1. Document and notation",
|
||||
"ru": "1. Документ и обозначения",
|
||||
"fr": "1. Document et notation"
|
||||
},
|
||||
"transfer_guide_structure": {
|
||||
"en": "Produce strict JSON: no comments, trailing commas, executable code or string interpolation. All three root fields are required. Only variables and generators are transferred. B below means a literal value or a binding; ? marks an optional field in schemas (do not put ? in JSON keys). Names, keys and enum values are literal strings, not bindings. Unknown options are errors. Export uses v2; old v1 imports remain supported.\n\n{\"format\":\"meguri/v2\", \"variables\":{}, \"generators\":[]}",
|
||||
"ru": "Создавайте строгий JSON: без комментариев, запятых в конце списков, исполняемого кода и подстановок внутри строк. Все три корневых поля обязательны. Переносятся только переменные и генераторы. Ниже B — обычное значение или привязка; ? отмечает необязательное поле схемы (в JSON знак ? не пишется). Имена, ключи и значения перечислений — обычные строки, не привязки. Неизвестные опции — ошибка. Экспорт использует v2; импорт старого v1 поддерживается.\n\n{\"format\":\"meguri/v2\", \"variables\":{}, \"generators\":[]}",
|
||||
"fr": "Produisez du JSON strict : sans commentaires, virgules finales, code exécutable ni interpolation de chaînes. Les trois champs racine sont obligatoires. Seuls les variables et générateurs sont transférés. B désigne une valeur littérale ou une liaison ; ? marque un champ facultatif du schéma (sans ? dans les clés JSON). Noms, clés et énumérations sont des chaînes littérales. Les options inconnues sont des erreurs. Export en v2 ; import v1 conservé.\n\n{\"format\":\"meguri/v2\", \"variables\":{}, \"generators\":[]}"
|
||||
},
|
||||
"transfer_guide_variables_title": {
|
||||
"en": "2. Variable values and types",
|
||||
"ru": "2. Значения и типы переменных",
|
||||
"fr": "2. Valeurs et types des variables"
|
||||
},
|
||||
"transfer_guide_variables": {
|
||||
"en": "variables maps unique names to values. JSON strings/numbers/booleans/arrays/objects become text/number/boolean/list/dictionary. A valid email string becomes a person; dates and times are not inferred. Null is not a variable type. Existing people resolve by exact email; unknown emails become scheduling contacts (country CY), never logins or workspace members. Provider accounts are resolved separately before publication. A dictionary uses alphabetical key order; use $object pairs when order matters or keys start with $. Variable wrappers are not binding operators.\n\n\"on_call\": [\"alice@example.com\", \"bob@example.com\"]\n\"projects\": [{\"name\":\"alpha\", \"owners\":[\"alice@example.com\"]}]\n{\"$person\":\"alice@example.com\"}\n{\"$text\":\"email-looking-text@example.com\"}\n{\"$date\":\"2026-10-01\"}\n{\"$time\":\"09:00\"}\n{\"$duration\":30}\n{\"$object\":[[\"z\",1],[\"a\",2],[\"$custom\",true]]}",
|
||||
"ru": "variables сопоставляет уникальные имена значениям. Строки/числа/булевы/массивы/объекты JSON становятся текстом/числом/булевым/списком/словарём. Валидный email становится персоной; даты и время не угадываются. Null не является типом переменной. Люди сопоставляются по точному email; неизвестные email создают контакты дежурств (страна CY), без учётной записи и доступа в workspace. Аккаунты провайдера проверяются отдельно перед публикацией. Ключи обычного словаря идут по алфавиту; для заданного порядка или ключей с $ используйте пары $object. Обёртки типов переменных не являются операторами привязок.\n\n\"on_call\": [\"alice@example.com\", \"bob@example.com\"]\n\"projects\": [{\"name\":\"alpha\", \"owners\":[\"alice@example.com\"]}]\n{\"$person\":\"alice@example.com\"}\n{\"$text\":\"email-looking-text@example.com\"}\n{\"$date\":\"2026-10-01\"}\n{\"$time\":\"09:00\"}\n{\"$duration\":30}\n{\"$object\":[[\"z\",1],[\"a\",2],[\"$custom\",true]]}",
|
||||
"fr": "variables associe des noms uniques à des valeurs. Chaînes/nombres/booléens/tableaux/objets JSON deviennent texte/nombre/booléen/liste/dictionnaire. Un email valide devient une personne ; dates et heures ne sont pas déduites. Null n’est pas un type de variable. Les personnes sont rapprochées par email exact ; les nouveaux emails créent des contacts d’astreinte (pays CY), sans compte ni accès. Les comptes fournisseur sont vérifiés avant publication. Les dictionnaires ordinaires suivent l’ordre alphabétique ; utilisez les paires $object pour imposer l’ordre ou pour des clés commençant par $. Ces enveloppes ne sont pas des opérateurs de liaison.\n\n\"on_call\": [\"alice@example.com\", \"bob@example.com\"]\n\"projects\": [{\"name\":\"alpha\", \"owners\":[\"alice@example.com\"]}]\n{\"$person\":\"alice@example.com\"}\n{\"$text\":\"email-looking-text@example.com\"}\n{\"$date\":\"2026-10-01\"}\n{\"$time\":\"09:00\"}\n{\"$duration\":30}\n{\"$object\":[[\"z\",1],[\"a\",2],[\"$custom\",true]]}"
|
||||
},
|
||||
"transfer_guide_bindings_title": {
|
||||
"en": "3. Bindings (B)",
|
||||
"ru": "3. Привязки (B)",
|
||||
"fr": "3. Liaisons (B)"
|
||||
},
|
||||
"transfer_guide_bindings": {
|
||||
"en": "Ordinary scalars and arrays are literal bindings; an array containing references does NOT evaluate them: use $list. A literal object needs $literal. Each binding object has exactly one $ operator. Optional prefix/suffix require a text result. Optional path is an array of exact dictionary keys or one-based list indexes written as strings. Dots, spaces and slashes inside a segment stay literal. $var/$item/$key accept a name or [name,...path]; do not also supply path when the shorthand already includes a path. $lookup keys resolve to text. A $person marker inside a generic $literal resolves an email to the workspace person identity.\n\n{\"$var\":\"projects\"}\n{\"$var\":[\"settings\",\"owners\",\"1\"]}\n{\"$item\":[\"Project\",\"name\"],\"prefix\":\"MG: \",\"suffix\":\" primary\"}\n{\"$key\":\"Project\"}\n{\"$output\":[\"Generator name\",\"output-key\"]}\n{\"$lookup\":[\"tiers\",{\"$item\":[\"Project\",\"tier\"]}],\"path\":[\"delay\"]}\n{\"$list\":[{\"$item\":[\"Project\",\"owners\",\"1\"]},\"bob@example.com\"]}\n{\"$literal\":{\"custom\":\"object\"}}\n{\"$literal\":{\"$person\":\"alice@example.com\"}}\n{\"$literal\":\"text\",\"prefix\":\"A \",\"suffix\":\" B\"}",
|
||||
"ru": "Обычные скаляры и массивы — константы; массив со ссылками НЕ вычисляет их: нужен $list. Для объекта-константы нужен $literal. В объекте привязки ровно один оператор $. Необязательные prefix/suffix требуют текстовый результат. Необязательный path — массив точных ключей словаря или индексов списка от единицы, записанных строками. Точки, пробелы и слеши внутри сегмента — часть ключа. $var/$item/$key принимают имя или [имя,...путь]; если путь уже в массиве, отдельный path не добавляйте. Ключ $lookup должен давать текст. Маркер $person внутри общего $literal преобразует email в идентификатор человека текущего workspace.\n\n{\"$var\":\"projects\"}\n{\"$var\":[\"settings\",\"owners\",\"1\"]}\n{\"$item\":[\"Project\",\"name\"],\"prefix\":\"MG: \",\"suffix\":\" primary\"}\n{\"$key\":\"Project\"}\n{\"$output\":[\"Generator name\",\"output-key\"]}\n{\"$lookup\":[\"tiers\",{\"$item\":[\"Project\",\"tier\"]}],\"path\":[\"delay\"]}\n{\"$list\":[{\"$item\":[\"Project\",\"owners\",\"1\"]},\"bob@example.com\"]}\n{\"$literal\":{\"custom\":\"object\"}}\n{\"$literal\":{\"$person\":\"alice@example.com\"}}\n{\"$literal\":\"text\",\"prefix\":\"A \",\"suffix\":\" B\"}",
|
||||
"fr": "Les scalaires et tableaux ordinaires sont littéraux ; les références dans un tableau ne sont PAS évaluées : utilisez $list. Un objet littéral exige $literal. Chaque objet de liaison contient exactement un opérateur $. prefix/suffix facultatifs exigent un résultat texte. path est une liste de clés exactes ou d’indices de liste à partir de un écrits en chaînes. Points, espaces et barres obliques restent littéraux. $var/$item/$key acceptent un nom ou [nom,...chemin] ; ne doublez pas un chemin abrégé avec path. La clé de $lookup doit produire du texte. Un marqueur $person dans un $literal générique résout l’email vers l’identité de la personne du workspace.\n\n{\"$var\":\"projects\"}\n{\"$var\":[\"settings\",\"owners\",\"1\"]}\n{\"$item\":[\"Project\",\"name\"],\"prefix\":\"MG: \",\"suffix\":\" primary\"}\n{\"$key\":\"Project\"}\n{\"$output\":[\"Generator name\",\"output-key\"]}\n{\"$lookup\":[\"tiers\",{\"$item\":[\"Project\",\"tier\"]}],\"path\":[\"delay\"]}\n{\"$list\":[{\"$item\":[\"Project\",\"owners\",\"1\"]},\"bob@example.com\"]}\n{\"$literal\":{\"custom\":\"object\"}}\n{\"$literal\":{\"$person\":\"alice@example.com\"}}\n{\"$literal\":\"text\",\"prefix\":\"A \",\"suffix\":\" B\"}"
|
||||
},
|
||||
"transfer_guide_generators_title": {
|
||||
"en": "4. Generators, loops and filters",
|
||||
"ru": "4. Генераторы, циклы и фильтры",
|
||||
"fr": "4. Générateurs, boucles et filtres"
|
||||
},
|
||||
"transfer_guide_generators": {
|
||||
"en": "A generator needs name and outputs. enabled defaults to true. for is an ordered list of nested loops over lists/dictionaries; an empty list of loops generates once. Each loop needs a unique as alias and an in binding; later loops may use earlier aliases. when filters each row. equals/not_equals compare equal JSON types; contains means list membership or text substring; not_empty ignores the third operand (use null). false and 0 are nonempty. Output keys are unique within a generator and must remain stable. Define producers before consumers. $output may reference an earlier output in the same row; outputs of generators without loops are shared across generators. Iterated outputs cannot be referenced across generators.\n\n{name, enabled?:true, for?:[{as,in:B}], when?:[B,operation,B], outputs:[]}\nwhen operations: equals | not_equals | contains | not_empty\n\"when\": [{\"$item\":[\"Project\",\"enabled\"]},\"equals\",true]\n\"when\": [{\"$item\":[\"Project\",\"owners\"]},\"not_empty\",null]\nEvery output: {key, type, name:B, ...type-specific fields}",
|
||||
"ru": "Генератору нужны name и outputs. enabled по умолчанию true. for — упорядоченный список вложенных циклов по спискам/словарям; без циклов генератор выполняется один раз. Каждый цикл задаёт уникальное имя as и привязку in; следующие циклы могут ссылаться на предыдущие. when фильтрует каждую итерацию. equals/not_equals сравнивают одинаковые JSON-типы; contains проверяет наличие в списке или подстроку; not_empty игнорирует третий операнд (пишите null). false и 0 считаются непустыми. Ключи выходов уникальны внутри генератора; сохраняйте их при правках. Объявляйте источники перед потребителями. $output может ссылаться на предыдущий выход той же итерации; выходы генератора без циклов общие для других генераторов. Ссылаться на выходы чужих циклов нельзя.\n\n{name, enabled?:true, for?:[{as,in:B}], when?:[B,operation,B], outputs:[]}\nОперации when: equals | not_equals | contains | not_empty\n\"when\": [{\"$item\":[\"Project\",\"enabled\"]},\"equals\",true]\n\"when\": [{\"$item\":[\"Project\",\"owners\"]},\"not_empty\",null]\nЛюбой выход: {key, type, name:B, ...type-specific fields}",
|
||||
"fr": "Un générateur exige name et outputs. enabled vaut true par défaut. for est une liste ordonnée de boucles imbriquées sur listes/dictionnaires ; sans boucle, une seule exécution. Chaque boucle exige un alias as unique et une liaison in ; les suivantes peuvent utiliser les alias précédents. when filtre chaque ligne. equals/not_equals comparent des types JSON identiques ; contains teste l’appartenance ou une sous-chaîne ; not_empty ignore le troisième opérande (utilisez null). false et 0 sont non vides. Les clés de sortie sont uniques par générateur et doivent rester stables. Placez les producteurs avant les consommateurs. $output peut viser une sortie antérieure de la même ligne ; les sorties sans boucle sont partagées entre générateurs. Les sorties itérées ne traversent pas les générateurs.\n\n{name, enabled?:true, for?:[{as,in:B}], when?:[B,operation,B], outputs:[]}\nOpérations when : equals | not_equals | contains | not_empty\n\"when\": [{\"$item\":[\"Project\",\"enabled\"]},\"equals\",true]\n\"when\": [{\"$item\":[\"Project\",\"owners\"]},\"not_empty\",null]\nChaque sortie : {key, type, name:B, ...type-specific fields}"
|
||||
},
|
||||
"transfer_guide_schedules_title": {
|
||||
"en": "5. Schedule outputs",
|
||||
"ru": "5. Выходы-расписания",
|
||||
"fr": "5. Sorties planning"
|
||||
},
|
||||
"transfer_guide_schedules": {
|
||||
"en": "All listed data fields accept B; key/type are fixed strings. participants must resolve to an ordered list of 1–100 distinct people/emails. rotation continuously cycles through them from anchor; shift_days counts local calendar days (1–366). priority chooses the first available person, respecting absences/holidays, then fallback. It does not alternate primary/secondary people. fallback must reference a schedule. Literal references use an exact existing schedule name; generated references use $output. handover defaults to 11:00 in the workspace IANA zone; ambiguous/nonexistent local times are rejected. Anchor is an ISO date, years 2000–2100. Workspace holidays and availability are not part of this file.\n\nrotation: {key,type:\"rotation\",name:B,participants:B,anchor:B,shift_days?:7,handover?:\"11:00\"}\npriority: {key,type:\"priority\",name:B,participants:B,fallback:B,handover?:\"11:00\"}",
|
||||
"ru": "Все поля данных ниже принимают B; key/type — фиксированные строки. participants должен дать упорядоченный список 1–100 разных людей/email. rotation циклически меняет их от anchor; shift_days — число местных календарных дней (1–366). priority выбирает первого доступного человека с учётом отсутствий/праздников, затем fallback. Это не чередование primary/secondary. fallback ссылается на расписание. Константа-ссылка — точное имя существующего расписания; ссылка на генерируемое — $output. handover по умолчанию 11:00 в IANA-зоне workspace; неоднозначное или несуществующее местное время отклоняется. Anchor — ISO-дата, год 2000–2100. Праздники и доступность workspace в этот файл не входят.\n\nrotation: {key,type:\"rotation\",name:B,participants:B,anchor:B,shift_days?:7,handover?:\"11:00\"}\npriority: {key,type:\"priority\",name:B,participants:B,fallback:B,handover?:\"11:00\"}",
|
||||
"fr": "Tous les champs de données ci-dessous acceptent B ; key/type sont fixes. participants doit produire une liste ordonnée de 1 à 100 personnes/emails distincts. rotation les alterne depuis anchor ; shift_days compte les jours calendaires locaux (1–366). priority choisit la première personne disponible selon absences/jours fériés, puis fallback. Ce n’est pas une alternance primary/secondary. fallback référence un planning. Une référence littérale utilise le nom exact d’un planning existant ; une référence générée utilise $output. handover vaut 11:00 dans le fuseau IANA du workspace ; les heures ambiguës/inexistantes sont refusées. Anchor est une date ISO, année 2000–2100. Disponibilités et jours fériés ne sont pas transférés.\n\nrotation: {key,type:\"rotation\",name:B,participants:B,anchor:B,shift_days?:7,handover?:\"11:00\"}\npriority: {key,type:\"priority\",name:B,participants:B,fallback:B,handover?:\"11:00\"}"
|
||||
},
|
||||
"transfer_guide_escalations_title": {
|
||||
"en": "6. Escalation outputs",
|
||||
"ru": "6. Выходы-эскалации",
|
||||
"fr": "6. Sorties escalade"
|
||||
},
|
||||
"transfer_guide_escalations": {
|
||||
"en": "Steps notify a schedule, person, or every member of the provider team configured in the workspace. Exactly one recipient option per step. after is an integer delay in minutes AFTER ALERT CREATION, not after the previous step (0–10080, default 0). if defaults to not-acked; not-closed is also supported. after, schedule and person accept B; team and if are fixed strings. There are 1–20 steps. repeat_count is 0–20 (default 0, disabled); repeat_wait is 0–10080 minutes (default 0). Both repeat fields accept B. Providers execute notification timers; Meguri only publishes configuration. Unsupported provider capabilities block publication.\n\n{key,type:\"escalation\",name:B,repeat_count?:0,repeat_wait?:0,steps:[]}\nstep: {after?:0,if?:\"not-acked\",schedule:B}\n or {after?:0,if?:\"not-acked\",person:B}\n or {after?:0,if?:\"not-acked\",team:\"all\"|\"random\"}\n\"steps\": [{\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},{\"after\":30,\"team\":\"all\"}]\n\nRandom selects one member of the configured provider team when the provider executes the step; it does not call each colleague in turn.\n{\"after\":30,\"team\":\"random\"}",
|
||||
"ru": "Шаги уведомляют расписание, человека или всех участников команды провайдера, заданной в workspace. В шаге ровно один вариант получателя. after — целое число минут ПОСЛЕ СОЗДАНИЯ АЛЕРТА, не после предыдущего шага (0–10080, по умолчанию 0). if по умолчанию not-acked; также поддерживается not-closed. after, schedule и person принимают B; team и if — фиксированные строки. Шагов 1–20. repeat_count — 0–20 (по умолчанию 0, повторы выключены); repeat_wait — 0–10080 минут (по умолчанию 0). Оба поля повторения принимают B. Таймеры уведомлений выполняет провайдер; Meguri публикует конфигурацию. Неподдерживаемые возможности провайдера блокируют публикацию.\n\n{key,type:\"escalation\",name:B,repeat_count?:0,repeat_wait?:0,steps:[]}\nШаг: {after?:0,if?:\"not-acked\",schedule:B}\n или {after?:0,if?:\"not-acked\",person:B}\n или {after?:0,if?:\"not-acked\",team:\"all\"|\"random\"}\n\"steps\": [{\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},{\"after\":30,\"team\":\"all\"}]\n\nRandom выбирает одного участника команды при выполнении шага провайдером; это не последовательный обзвон коллег.\n{\"after\":30,\"team\":\"random\"}",
|
||||
"fr": "Les étapes notifient un planning, une personne ou toute l’équipe fournisseur configurée dans le workspace. Une seule option de destinataire par étape. after est un entier en minutes APRÈS LA CRÉATION DE L’ALERTE, non après l’étape précédente (0–10080, défaut 0). if vaut not-acked par défaut ; not-closed est aussi accepté. after, schedule et person acceptent B ; team et if sont fixes. De 1 à 20 étapes. repeat_count : 0–20 (défaut 0, désactivé) ; repeat_wait : 0–10080 minutes (défaut 0). Les deux acceptent B. Le fournisseur exécute les temporisations ; Meguri publie la configuration. Une capacité non prise en charge bloque la publication.\n\n{key,type:\"escalation\",name:B,repeat_count?:0,repeat_wait?:0,steps:[]}\nÉtape : {after?:0,if?:\"not-acked\",schedule:B}\n ou {after?:0,if?:\"not-acked\",person:B}\n ou {after?:0,if?:\"not-acked\",team:\"all\"|\"random\"}\n\"steps\": [{\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},{\"after\":30,\"team\":\"all\"}]\n\nRandom sélectionne un membre de l’équipe lors de l’exécution par le fournisseur ; il ne contacte pas les collègues à tour de rôle.\n{\"after\":30,\"team\":\"random\"}"
|
||||
},
|
||||
"transfer_guide_routes_title": {
|
||||
"en": "7. Routing outputs",
|
||||
"ru": "7. Выходы-маршруты",
|
||||
"fr": "7. Sorties routage"
|
||||
},
|
||||
"transfer_guide_routes": {
|
||||
"en": "escalation is optional: omission creates a route with no escalation destination (no notification). working accepts B resolving to boolean; true uses the workspace working-days/hours/timezone, which cannot be changed in this file. Conditions are ANDed; an empty list is a catch-all. Conditions contain a literal field and operation, a B that resolves to nonempty text (1–500 characters), and optionally the literal \"not\". matches is a regular expression; escape backslashes in JSON. Routing comparison semantics are those of the provider (P1 is highest alert priority). Routing order matters: first matching rule wins; put specific routes before catch-alls.\n\n{key,type:\"route\",name:B,escalation?:B,working?:false,conditions?:[]}\ncondition: [field,operation,B] or [field,operation,B,\"not\"]\nfields: tags | priority | message | description | source | alias\noperations: matches | contains | equals | greater-than | less-than | starts-with | ends-with\n\"conditions\": [[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]",
|
||||
"ru": "escalation необязателен: если его нет, маршрут не направляет в эскалацию (без уведомления). working принимает B с булевым результатом; true использует рабочие дни/часы/зону workspace, которые этим файлом не меняются. Условия объединяются через И; пустой список — маршрут для всех алертов. Условие содержит фиксированные field и operation, B с непустым текстом (1–500 символов) и необязательную строку \"not\". matches — регулярное выражение; экранируйте обратные слеши в JSON. Семантику сравнений задаёт провайдер (P1 — высший приоритет алерта). Порядок важен: срабатывает первый подходящий маршрут; частные правила ставьте перед общими.\n\n{key,type:\"route\",name:B,escalation?:B,working?:false,conditions?:[]}\nУсловие: [field,operation,B] или [field,operation,B,\"not\"]\nПоля: tags | priority | message | description | source | alias\nОперации: matches | contains | equals | greater-than | less-than | starts-with | ends-with\n\"conditions\": [[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]",
|
||||
"fr": "escalation est facultatif : son omission crée une route sans destination d’escalade (sans notification). working accepte B produisant un booléen ; true utilise jours/heures/fuseau de travail du workspace, non modifiables ici. Les conditions sont combinées par ET ; une liste vide est une règle générale. Chaque condition contient field et operation littéraux, B produisant du texte non vide (1–500 caractères), puis éventuellement \"not\". matches est une expression régulière ; échappez les antislashs en JSON. Les comparaisons suivent le fournisseur (P1 est la priorité maximale). La première route correspondante gagne ; placez les règles spécifiques avant les générales.\n\n{key,type:\"route\",name:B,escalation?:B,working?:false,conditions?:[]}\nCondition : [field,operation,B] ou [field,operation,B,\"not\"]\nChamps : tags | priority | message | description | source | alias\nOpérations : matches | contains | equals | greater-than | less-than | starts-with | ends-with\n\"conditions\": [[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]"
|
||||
},
|
||||
"transfer_guide_merge_title": {
|
||||
"en": "8. Import behavior and limits",
|
||||
"ru": "8. Поведение импорта и ограничения",
|
||||
"fr": "8. Import et limites"
|
||||
},
|
||||
"transfer_guide_merge": {
|
||||
"en": "Merge matches variable/generator names case-insensitively after trimming: update matches, append new names, retain omitted names. An included generator replaces its entire definition, so removed outputs can remove resources. Existing generator order and routing insertion position remain unchanged; new generators append. Keep output keys stable. Dictionary fields and uniquely named/equal list entries retain identity; ambiguous combined renames/reorders can change resource identity—review creates/removes. Manual resources resolve by exact name and are never imported. Preview shows errors, input/resource changes, contacts and cancellations. Apply saves the shared draft; Sync separately publishes. Other edits invalidate a preview. An LLM should return ONLY a complete v2 JSON document, with real supplied emails/names and no invented options; ask for missing required data.\n\nLimits: JSON 1,000,000 bytes; 100 variables; 10,000 value nodes; nesting depth 8; 1,000 entries/collection.\n50 generators; 4 loops and 20 outputs/generator; 500 expanded rows/generator.\nWorkspace totals: 200 schedules, 200 escalations, 500 routes; up to 900 generated resources.\nNames: variable 1–80 characters; generator/resource 1–120; output key 1–120; dictionary key 1–120.\nText variable ≤2000 characters. Routing conditions ≤20/route. Binding nesting ≤16.\nNumbers must be finite; durations nonnegative; day counts, delays and repeat values are integers.\nDates: YYYY-MM-DD. Times: HH:MM or HH:MM:SS. Scheduling uses the workspace IANA timezone.",
|
||||
"ru": "Сопоставление имён переменных/генераторов — без учёта регистра и крайних пробелов: совпадающие обновляются, новые добавляются, отсутствующие остаются. Включённый генератор заменяется целиком, поэтому удалённые выходы могут удалить ресурсы. Порядок существующих генераторов и позиция вставки маршрутов не меняются; новые генераторы добавляются в конец. Сохраняйте ключи выходов. Поля словаря и однозначно именованные/совпадающие элементы списка сохраняют идентичность; неоднозначные переименования с перестановкой могут её изменить — проверяйте создания/удаления. Ручные ресурсы разрешаются по точному имени и не импортируются. Предпросмотр показывает ошибки, изменения, контакты и отмены запросов. Apply сохраняет общий черновик; Sync отдельно публикует. Чужие правки делают предпросмотр устаревшим. LLM должна вернуть ТОЛЬКО полный JSON v2, с предоставленными реальными email/именами, без выдуманных опций; недостающие обязательные данные нужно запросить.\n\nЛимиты: JSON 1 000 000 байт; 100 переменных; 10 000 узлов значений; глубина 8; 1000 элементов в коллекции.\n50 генераторов; 4 цикла и 20 выходов на генератор; 500 развёрнутых итераций на генератор.\nВсего в workspace: 200 расписаний, 200 эскалаций, 500 маршрутов; до 900 генерируемых ресурсов.\nИмена: переменная 1–80 символов; генератор/ресурс 1–120; ключ выхода 1–120; ключ словаря 1–120.\nТекст переменной ≤2000 символов. Условия маршрутизации ≤20 на маршрут. Вложенность привязок ≤16.\nЧисла конечные; длительности неотрицательные; дни, задержки и параметры повторения — целые числа.\nДаты: YYYY-MM-DD. Время: HH:MM или HH:MM:SS. Расчёт использует IANA-зону workspace.",
|
||||
"fr": "Fusion par noms de variables/générateurs sans casse ni espaces périphériques : mise à jour, ajout des nouveaux noms, conservation des absents. Un générateur inclus remplace toute sa définition ; supprimer des sorties peut supprimer des ressources. Ordre des générateurs existants et position d’insertion des routes conservés ; nouveaux générateurs ajoutés à la fin. Gardez les clés de sortie. Champs de dictionnaire et éléments de liste identifiables conservent leur identité ; renommages/réordonnancements ambigus peuvent la modifier : vérifiez créations/suppressions. Les ressources manuelles sont résolues par nom exact, jamais importées. La prévisualisation montre erreurs, changements, contacts et annulations. Apply enregistre le brouillon partagé ; Sync publie séparément. Les autres modifications invalident la revue. Un LLM doit renvoyer UNIQUEMENT un document JSON v2 complet avec les emails/noms fournis, sans options inventées, et demander les données obligatoires manquantes.\n\nLimites : JSON 1 000 000 octets ; 100 variables ; 10 000 nœuds ; profondeur 8 ; 1000 éléments par collection.\n50 générateurs ; 4 boucles et 20 sorties par générateur ; 500 lignes développées par générateur.\nTotaux du workspace : 200 plannings, 200 escalades, 500 routes ; jusqu’à 900 ressources générées.\nNoms : variable 1–80 caractères ; générateur/ressource 1–120 ; clé de sortie 1–120 ; clé de dictionnaire 1–120.\nTexte de variable ≤2000 caractères. Conditions de routage ≤20 par route. Imbrication des liaisons ≤16.\nNombres finis ; durées non négatives ; jours, délais et paramètres de répétition entiers.\nDates : YYYY-MM-DD. Heures : HH:MM ou HH:MM:SS. Calcul dans le fuseau IANA du workspace."
|
||||
},
|
||||
"transfer_guide_example_title": {
|
||||
"en": "9. Complete importable example",
|
||||
"ru": "9. Полный пример для импорта",
|
||||
"fr": "9. Exemple complet importable"
|
||||
},
|
||||
"transfer_guide_example": {
|
||||
"en": "{\n \"format\": \"meguri/v2\",\n \"variables\": {\n \"on_call\": [\"alice@example.com\", \"bob@example.com\"],\n \"projects\": [{\"name\":\"alpha\",\"owners\":[\"alice@example.com\",\"bob@example.com\"],\"tag\":\"project:alpha\"}]\n },\n \"generators\": [\n {\"name\":\"General\",\"outputs\":[\n {\"key\":\"weekly\",\"type\":\"rotation\",\"name\":\"General on-call\",\"participants\":{\"$var\":\"on_call\"},\"anchor\":\"2026-09-28\",\"shift_days\":7,\"handover\":\"11:00\"}\n ]},\n {\"name\":\"Owners\",\"for\":[{\"as\":\"Project\",\"in\":{\"$var\":\"projects\"}}],\"outputs\":[\n {\"key\":\"primary\",\"type\":\"priority\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" owners\"},\"participants\":{\"$item\":[\"Project\",\"owners\"]},\"fallback\":{\"$output\":[\"General\",\"weekly\"]}},\n {\"key\":\"escalation\",\"type\":\"escalation\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" escalation\"},\"steps\":[\n {\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},\n {\"after\":30,\"team\":\"all\"}\n ]},\n {\"key\":\"route\",\"type\":\"route\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" route\"},\"escalation\":{\"$output\":[\"Owners\",\"escalation\"]},\"working\":true,\"conditions\":[[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]}\n ]}\n ]\n}",
|
||||
"ru": "{\n \"format\": \"meguri/v2\",\n \"variables\": {\n \"on_call\": [\"alice@example.com\", \"bob@example.com\"],\n \"projects\": [{\"name\":\"alpha\",\"owners\":[\"alice@example.com\",\"bob@example.com\"],\"tag\":\"project:alpha\"}]\n },\n \"generators\": [\n {\"name\":\"General\",\"outputs\":[\n {\"key\":\"weekly\",\"type\":\"rotation\",\"name\":\"General on-call\",\"participants\":{\"$var\":\"on_call\"},\"anchor\":\"2026-09-28\",\"shift_days\":7,\"handover\":\"11:00\"}\n ]},\n {\"name\":\"Owners\",\"for\":[{\"as\":\"Project\",\"in\":{\"$var\":\"projects\"}}],\"outputs\":[\n {\"key\":\"primary\",\"type\":\"priority\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" owners\"},\"participants\":{\"$item\":[\"Project\",\"owners\"]},\"fallback\":{\"$output\":[\"General\",\"weekly\"]}},\n {\"key\":\"escalation\",\"type\":\"escalation\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" escalation\"},\"steps\":[\n {\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},\n {\"after\":30,\"team\":\"all\"}\n ]},\n {\"key\":\"route\",\"type\":\"route\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" route\"},\"escalation\":{\"$output\":[\"Owners\",\"escalation\"]},\"working\":true,\"conditions\":[[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]}\n ]}\n ]\n}",
|
||||
"fr": "{\n \"format\": \"meguri/v2\",\n \"variables\": {\n \"on_call\": [\"alice@example.com\", \"bob@example.com\"],\n \"projects\": [{\"name\":\"alpha\",\"owners\":[\"alice@example.com\",\"bob@example.com\"],\"tag\":\"project:alpha\"}]\n },\n \"generators\": [\n {\"name\":\"General\",\"outputs\":[\n {\"key\":\"weekly\",\"type\":\"rotation\",\"name\":\"General on-call\",\"participants\":{\"$var\":\"on_call\"},\"anchor\":\"2026-09-28\",\"shift_days\":7,\"handover\":\"11:00\"}\n ]},\n {\"name\":\"Owners\",\"for\":[{\"as\":\"Project\",\"in\":{\"$var\":\"projects\"}}],\"outputs\":[\n {\"key\":\"primary\",\"type\":\"priority\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" owners\"},\"participants\":{\"$item\":[\"Project\",\"owners\"]},\"fallback\":{\"$output\":[\"General\",\"weekly\"]}},\n {\"key\":\"escalation\",\"type\":\"escalation\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" escalation\"},\"steps\":[\n {\"schedule\":{\"$output\":[\"Owners\",\"primary\"]}},\n {\"after\":30,\"team\":\"all\"}\n ]},\n {\"key\":\"route\",\"type\":\"route\",\"name\":{\"$item\":[\"Project\",\"name\"],\"suffix\":\" route\"},\"escalation\":{\"$output\":[\"Owners\",\"escalation\"]},\"working\":true,\"conditions\":[[\"tags\",\"contains\",{\"$item\":[\"Project\",\"tag\"]}]]}\n ]}\n ]\n}"
|
||||
},
|
||||
"generation_recipient_conflict": {
|
||||
"en": "Choose exactly one escalation recipient type.",
|
||||
"ru": "Выберите ровно один тип получателя эскалации.",
|
||||
"fr": "Choisissez un seul type de destinataire d’escalade."
|
||||
},
|
||||
"enum_site.workspace_updated": {
|
||||
"en": "Workspace updated by site administrator",
|
||||
"ru": "Workspace изменён администратором сайта",
|
||||
"fr": "Espace modifié par l’administrateur du site"
|
||||
},
|
||||
"replacement_account_access": {
|
||||
"en": "Participants with login accounts need workspace access to approve.",
|
||||
"ru": "Участникам с аккаунтом нужен доступ к workspace для подтверждения.",
|
||||
"fr": "Les participants disposant d’un compte doivent avoir accès à l’espace pour approuver."
|
||||
},
|
||||
"account_email": {
|
||||
"en": "Email address",
|
||||
"ru": "Электронная почта",
|
||||
"fr": "Adresse e-mail"
|
||||
},
|
||||
"account_identity_taken": {
|
||||
"en": "This username or email address is already registered.",
|
||||
"ru": "Это имя пользователя или почта уже зарегистрированы.",
|
||||
"fr": "Ce nom d’utilisateur ou cette adresse e-mail est déjà enregistré."
|
||||
},
|
||||
"account_person_authoritative": {
|
||||
"en": "This person belongs to an account. Identity and profile details are managed in account settings.",
|
||||
"ru": "Этот человек связан с аккаунтом. Его данные управляются в настройках аккаунта.",
|
||||
"fr": "Cette personne appartient à un compte. Ses informations sont gérées dans les paramètres du compte."
|
||||
},
|
||||
"enum_person.profile_changed": {
|
||||
"en": "Person profile updated",
|
||||
"ru": "Профиль человека обновлён",
|
||||
"fr": "Profil de la personne mis à jour"
|
||||
},
|
||||
"enum_account.people_migrated": {
|
||||
"en": "Account people migrated",
|
||||
"ru": "Данные людей связаны с аккаунтами",
|
||||
"fr": "Profils des comptes migrés"
|
||||
},
|
||||
"enum_account.contacts_linked": {
|
||||
"en": "Planning contacts linked to accounts",
|
||||
"ru": "Контакты планирования связаны с аккаунтами",
|
||||
"fr": "Contacts de planification associés aux comptes"
|
||||
},
|
||||
"symbol_brace_open": {
|
||||
"en": "{",
|
||||
"ru": "{",
|
||||
"fr": "{"
|
||||
},
|
||||
"symbol_brace_close": {
|
||||
"en": "}",
|
||||
"ru": "}",
|
||||
"fr": "}"
|
||||
}
|
||||
}
|
||||
+127
-25
@@ -50,6 +50,9 @@ async fn try_sync(
|
||||
async fn sync(db: &Store, config: &Config, now: DateTime<Utc>) -> SyncReport {
|
||||
try_sync(db, config, now).await.unwrap()
|
||||
}
|
||||
fn person_mut(document: &mut WorkspaceDocument, id: Uuid) -> &mut Person {
|
||||
document.people.iter_mut().find(|p| p.id == id).unwrap()
|
||||
}
|
||||
fn set(permissions: Vec<Permission>) -> BTreeSet<Permission> {
|
||||
permissions.into_iter().collect()
|
||||
}
|
||||
@@ -127,12 +130,13 @@ async fn configuration_sync_creates_adopts_renames_disables_and_reenables_withou
|
||||
assert_eq!(workspace_count(db, employee.id).await, 1);
|
||||
|
||||
// Renaming in configuration renames the workspace through an ordinary revision.
|
||||
let before_rename = db.workspace(legacy.id).await.unwrap().revision;
|
||||
config.workspaces[0].name = "Legacy renamed".into();
|
||||
let report = sync(db, &config, at(10, 2)).await;
|
||||
assert_eq!(report.renamed, ["legacy-team"]);
|
||||
let renamed = db.workspace(legacy.id).await.unwrap();
|
||||
assert_eq!(renamed.document.name, "Legacy renamed");
|
||||
assert_eq!(renamed.revision, adopted.revision + 1);
|
||||
assert_eq!(renamed.revision, before_rename + 1);
|
||||
assert!(
|
||||
db.audit(legacy.id)
|
||||
.await
|
||||
@@ -198,16 +202,19 @@ async fn configuration_sync_creates_adopts_renames_disables_and_reenables_withou
|
||||
);
|
||||
|
||||
// Putting it back re-enables the same workspace with its members and a pending publication.
|
||||
let revision = query!(WorkspaceRow,$id==id.clone())
|
||||
.get(&db.db)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.revision;
|
||||
config.workspaces.push(kept);
|
||||
let report = sync(db, &config, at(10, 4)).await;
|
||||
assert_eq!(report.enabled, ["payments"]);
|
||||
assert_eq!(report.created.len(), 0);
|
||||
assert_eq!(db.workspace(payments.id).await.unwrap().revision, revision);
|
||||
assert_eq!(workspace_count(db, employee.id).await, 1);
|
||||
assert_eq!(
|
||||
db.sync_status(payments.id).await.unwrap()["state"],
|
||||
"pending"
|
||||
);
|
||||
assert_eq!(db.sync_status(payments.id).await.unwrap()["state"], "draft");
|
||||
|
||||
// Safety rails.
|
||||
let empty = config_with(vec![]);
|
||||
@@ -342,6 +349,7 @@ async fn groups_decide_roles_exactly_and_instance_administrators_see_everything(
|
||||
.iter()
|
||||
.any(|m| m.user.id == root.id)
|
||||
);
|
||||
let beta = db.workspace(beta.id).await.unwrap();
|
||||
let mut doc = beta.document.clone();
|
||||
doc.horizon_days = 30;
|
||||
let helped = service::update_document(db, root.id, beta.id, beta.revision, doc)
|
||||
@@ -507,7 +515,9 @@ async fn employee_permissions_belong_to_the_workspace_and_apply_to_everyone_at_o
|
||||
.unwrap()
|
||||
.contains(&Permission::ForceOverrides)
|
||||
);
|
||||
let applied = service::request_replacement(&f.db, bob, team, force(updated.revision))
|
||||
// Adding the newcomer also added their person to the workspace: take the current revision.
|
||||
let current = f.db.workspace(team).await.unwrap();
|
||||
let applied = service::request_replacement(&f.db, bob, team, force(current.revision))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -1231,7 +1241,7 @@ async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_acces
|
||||
sign_in(
|
||||
db,
|
||||
&config,
|
||||
profile("sd", "dana", Some("DANA@corp.test"), &["alpha-staff"]),
|
||||
profile("sd", "dana", Some("dana@corp.test"), &["alpha-staff"]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
@@ -1239,6 +1249,29 @@ async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_acces
|
||||
linked.revision
|
||||
);
|
||||
|
||||
// The linked person is an account person: with the next ordinary commit the account profile
|
||||
// (provider display name, default country) becomes authoritative, while the lead's contact
|
||||
// data was kept until then. The email, ID and alias stay.
|
||||
let committed = service::update_document(
|
||||
db,
|
||||
lead.id,
|
||||
alpha.id,
|
||||
linked.revision,
|
||||
linked.document.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let person = committed
|
||||
.document
|
||||
.people
|
||||
.iter()
|
||||
.find(|p| p.id == dana.id)
|
||||
.unwrap();
|
||||
assert_eq!(person.display_name, dana.display_name);
|
||||
assert_eq!(person.country.as_deref(), Some("CY"));
|
||||
assert_eq!(person.email.as_deref(), Some("dana@corp.test"));
|
||||
assert_eq!(person.aliases, vec![contact]);
|
||||
|
||||
// A colleague nobody planned still becomes a person with an email, ready for providers.
|
||||
let (erin, _) = sign_in(
|
||||
db,
|
||||
@@ -1254,18 +1287,19 @@ async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_acces
|
||||
.find(|p| p.id == erin.id)
|
||||
.unwrap();
|
||||
assert_eq!(person.email.as_deref(), Some("erin@corp.test"));
|
||||
// No email, no person entry, still a member.
|
||||
// No email: still a member, with a person of their own and nothing to link.
|
||||
let (frank, _) = sign_in(db, &config, profile("sf", "frank", None, &["alpha-staff"])).await;
|
||||
assert!(db.permissions(alpha.id, frank.id).await.is_ok());
|
||||
assert!(
|
||||
!db.workspace(alpha.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.document
|
||||
.people
|
||||
.iter()
|
||||
.any(|p| p.id == frank.id)
|
||||
);
|
||||
let frank_person = db
|
||||
.workspace(alpha.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.document
|
||||
.people
|
||||
.into_iter()
|
||||
.find(|p| p.id == frank.id)
|
||||
.unwrap();
|
||||
assert!(frank_person.email.is_none() && frank_person.user_id == Some(frank.id));
|
||||
iso.drop().await;
|
||||
}
|
||||
|
||||
@@ -1313,15 +1347,15 @@ async fn employees_plan_schedules_but_only_administrators_change_preferences_and
|
||||
),
|
||||
("horizon", &|d| d.horizon_days = 30),
|
||||
("person name", &|d| {
|
||||
d.people[0].display_name = "Someone else".into()
|
||||
person_mut(d, contact).display_name = "Someone else".into()
|
||||
}),
|
||||
("person email", &|d| {
|
||||
d.people[0].email = Some("other@corp.test".into())
|
||||
person_mut(d, contact).email = Some("other@corp.test".into())
|
||||
}),
|
||||
("person country", &|d| {
|
||||
d.people[0].country = Some("CY".into())
|
||||
person_mut(d, contact).country = Some("CY".into())
|
||||
}),
|
||||
("person removal", &|d| d.people.clear()),
|
||||
("person removal", &|d| d.people.retain(|p| p.id != contact)),
|
||||
] {
|
||||
assert!(
|
||||
matches!(try_edit(change).await, Err(Error::Forbidden)),
|
||||
@@ -1339,24 +1373,33 @@ async fn employees_plan_schedules_but_only_administrators_change_preferences_and
|
||||
country: None,
|
||||
aliases: vec![],
|
||||
});
|
||||
d.people[0].email = Some(" DANA@corp.test ".into());
|
||||
person_mut(d, contact).email = Some(" DANA@corp.test ".into());
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(planned.document.schedules[0].name, "Weekly rotation");
|
||||
assert_eq!(planned.document.name, record.document.name);
|
||||
assert_eq!(planned.document.people.len(), 2);
|
||||
assert!(
|
||||
planned
|
||||
.document
|
||||
.people
|
||||
.iter()
|
||||
.any(|p| p.id != contact && p.user_id.is_none())
|
||||
);
|
||||
// The workspace administrator still changes both.
|
||||
let mut doc = planned.document.clone();
|
||||
doc.name = "Administrators rename".into();
|
||||
doc.horizon_days = 45;
|
||||
doc.people[0].display_name = "Dana Renamed".into();
|
||||
person_mut(&mut doc, contact).display_name = "Dana Renamed".into();
|
||||
let changed = service::update_document(&f.db, lead, team, planned.revision, doc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(changed.document.name, "Administrators rename");
|
||||
assert_eq!(changed.document.horizon_days, 45);
|
||||
assert_eq!(changed.document.people[0].display_name, "Dana Renamed");
|
||||
assert_eq!(
|
||||
changed.document.person(contact).unwrap().display_name,
|
||||
"Dana Renamed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1437,3 +1480,62 @@ async fn the_instance_event_log_is_searchable_newest_first_and_failed_logins_are
|
||||
);
|
||||
iso.drop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
|
||||
async fn a_departed_member_keeps_a_valid_person_and_is_a_member_again_after_rejoining() {
|
||||
let f = support::Fixture::new().await;
|
||||
let (lead, bob) = (f.people[0].id, f.people[1].id);
|
||||
let team = f.team.id;
|
||||
let workspace = f.config.workspaces[0].clone();
|
||||
// The person of every member exists in the workspace once it is edited.
|
||||
let record =
|
||||
service::update_document(&f.db, lead, team, f.team.revision, f.team.document.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
record
|
||||
.document
|
||||
.people
|
||||
.iter()
|
||||
.any(|p| p.id == bob && p.user_id == Some(bob))
|
||||
);
|
||||
// Access follows groups: bob leaves; his person (and every schedule reference) stays valid.
|
||||
identity::apply_access(&f.db, &f.config, bob, Some(vec![]))
|
||||
.await
|
||||
.unwrap();
|
||||
let after =
|
||||
service::update_document(&f.db, lead, team, record.revision, record.document.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let person = after.document.people.iter().find(|p| p.id == bob).unwrap();
|
||||
assert_eq!(
|
||||
person.user_id,
|
||||
Some(bob),
|
||||
"the account link survives the departure"
|
||||
);
|
||||
assert!(
|
||||
f.db.member_ids(team)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|id| *id != bob)
|
||||
);
|
||||
assert!(f.db.permissions(team, bob).await.is_err());
|
||||
// Returning only restores access; the person is already linked.
|
||||
identity::apply_access(&f.db, &f.config, bob, Some(workspace.member_groups.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
let current = f.db.workspace(team).await.unwrap();
|
||||
let back = service::update_document(
|
||||
&f.db,
|
||||
lead,
|
||||
team,
|
||||
current.revision,
|
||||
current.document.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let person = back.document.people.iter().find(|p| p.id == bob).unwrap();
|
||||
assert_eq!(person.user_id, Some(bob));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
|
||||
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
|
||||
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
|
||||
(async()=>{
|
||||
const browser=await launchBrowser();
|
||||
try {
|
||||
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:1000}});
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
// Accounts are created by the instance administrator (the email is entered in mixed case and stored normalized).
|
||||
const email='account-person-'+Date.now()+'@example.com';
|
||||
const tester=await isolatedUser({sandbox:8,prefix:'account-person',display:'Account person',email:email.toUpperCase()});
|
||||
await signIn(page,tester.username,tester.password);
|
||||
const dialog=page.getByRole('dialog');
|
||||
await page.waitForFunction(()=>Alpine.$data(document.body).workspace);
|
||||
// A rotation that plans the account saves its Person (id = account id, email from the account) into the document.
|
||||
await page.evaluate(async()=>{const a=Alpine.$data(document.body),doc=structuredClone(Alpine.raw(a.doc)),me=a.people.find(p=>p.id===a.user.id);delete me.username;doc.people=[me];doc.schedules=[{id:crypto.randomUUID(),name:'Own rotation',type:'rotation',participants:[a.user.id],anchor:a.today(),shift_days:7,handover:'11:00:00'}];await a.api(a.endpoint('/config'),'PUT',{revision:a.workspace.revision,document:doc});await a.loadWorkspace();});
|
||||
const state=await page.evaluate(()=>{const a=Alpine.$data(document.body);return {user:a.user,person:a.doc.people.find(p=>p.id===a.user.id)};});
|
||||
assert.equal(state.user.email,email);
|
||||
assert.equal(state.user.user_id,state.user.id);
|
||||
assert.equal(state.person.email,email);
|
||||
assert.equal(state.person.id,state.user.id);
|
||||
await page.evaluate(()=>Alpine.$data(document.body).open('account'));
|
||||
assert.equal(await dialog.getByLabel('Email address',{exact:true}).inputValue(),email);
|
||||
assert.equal(await dialog.getByLabel('Email address',{exact:true}).isEditable(),false);
|
||||
await dialog.getByLabel('Display name',{exact:true}).fill('Updated profile name');
|
||||
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
|
||||
await dialog.waitFor({state:'hidden'});
|
||||
await page.reload();
|
||||
await page.waitForFunction(()=>Alpine.$data(document.body).doc?.people.length);
|
||||
assert.equal(await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.doc.people.find(p=>p.id===a.user.id).display_name;}),'Updated profile name');
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.open('rotation');a.editPerson(a.user.id);});
|
||||
const details=page.getByRole('region',{name:'Choose a person'});
|
||||
assert.equal(await details.getByLabel('Email for JSM lookup',{exact:true}).inputValue(),email);
|
||||
assert.equal(await details.getByLabel('Email for JSM lookup',{exact:true}).isEditable(),false);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
fs.mkdirSync('test-results',{recursive:true});
|
||||
await page.screenshot({path:'test-results/account-person-mobile.png'});
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: required email, normalized account-owned Person, profile persistence, readonly scheduling identity, mobile');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,43 @@
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
|
||||
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
|
||||
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
|
||||
(async()=>{
|
||||
const browser=await launchBrowser();
|
||||
try {
|
||||
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
// Sandbox 12 belongs to this suite; workspaces cannot be created, so it starts empty.
|
||||
const tester=await isolatedUser({sandbox:12,prefix:'binding-path',display:'Path tester'});
|
||||
await signIn(page,tester.username,tester.password);
|
||||
await page.getByLabel('Workspace',{exact:true}).waitFor();
|
||||
const dialog=page.getByRole('dialog');
|
||||
await page.evaluate(()=>{
|
||||
const a=Alpine.$data(document.body),root=a.newNode('dictionary'),owners=a.newNode('list',null,'owners');
|
||||
owners.entries=[a.newNode('text','First owner'),a.newNode('text','Second owner')];root.entries=[owners];
|
||||
const variable={id:crypto.randomUUID(),name:'Project',node:root};a.generationDraft.variables.push(variable);
|
||||
a.editGenerationRule();const block=a.newBlock('rotation');block.fields.name={kind:'variable',variable:variable.id,path:[]};a.form.blocks=[block];
|
||||
});
|
||||
const open=()=>page.evaluate(()=>{const a=Alpine.$data(document.body);a.editBinding(a.form.blocks[0].fields.name,'text','Name');});
|
||||
await open();
|
||||
const popup=page.locator('.binding-popover'),path=popup.getByLabel('Nested field or position',{exact:true});
|
||||
await path.pressSequentially('owners / ',{delay:30});
|
||||
assert.equal(await path.inputValue(),'owners / ');
|
||||
await path.pressSequentially('1',{delay:30});
|
||||
await page.waitForFunction(()=>Alpine.$data(document.body).bindingValuePreview().text.includes('First owner'));
|
||||
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).form.blocks[0].fields.name.path),['owners','1']);
|
||||
await path.press('Backspace');await path.pressSequentially('2');
|
||||
await page.waitForFunction(()=>Alpine.$data(document.body).bindingValuePreview().text.includes('Second owner'));
|
||||
await popup.getByRole('button',{name:'Done',exact:true}).click();await open();
|
||||
assert.equal(await path.inputValue(),'owners / 2');
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/binding-path-mobile.png'});
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
||||
await popup.getByLabel('Value source',{exact:true}).selectOption('literal');
|
||||
await popup.getByLabel('Value source',{exact:true}).selectOption('variable');
|
||||
assert.equal(await path.inputValue(),'');
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: typed nested path, live resolved values, edits, reopening, mobile and source reset');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -63,8 +63,15 @@ fs.mkdirSync('test-results',{recursive:true});
|
||||
await order(['Third','First','Second']);
|
||||
await page.getByRole('button',{name:'Move Third down',exact:true}).click();await order(['First','Third','Second']);
|
||||
await route('Second').getByRole('button',{name:'Edit',exact:true}).click();
|
||||
await dialog().getByLabel('Field',{exact:true}).selectOption('priority');
|
||||
await dialog().getByLabel('Operation',{exact:true}).selectOption('less-than');
|
||||
await dialog().getByLabel('Value',{exact:true}).fill('P3');
|
||||
await dialog().getByRole('checkbox',{name:'Not',exact:true}).check();
|
||||
|
||||
await page.screenshot({path:'test-results/routing-priority.png',fullPage:true});
|
||||
assert.equal(await dialog().getByLabel('Position',{exact:true}).inputValue(),'3');
|
||||
await dialog().getByLabel('Position',{exact:true}).selectOption('1');await save();await order(['Second','First','Third']);
|
||||
assert.deepEqual((await state()).document.routes.find(r=>r.name==='Second').conditions,[{field:'priority',operation:'less-than',value:'P3',negate:true}]);
|
||||
await page.getByRole('button',{name:'+ Routing rule',exact:true}).click();
|
||||
await dialog().getByLabel('Rule name',{exact:true}).fill('Fourth');
|
||||
await dialog().getByLabel('Position',{exact:true}).selectOption('2');await save();await order(['Second','Fourth','First','Third']);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
|
||||
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
|
||||
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
|
||||
(async()=>{
|
||||
const browser=await launchBrowser();
|
||||
try {
|
||||
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:1000}});
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
// Sandbox 9 belongs to this suite; workspaces cannot be created, so the fixture configures it.
|
||||
const tester=await isolatedUser({sandbox:9,prefix:'contact-approval',display:'Contact planner'});
|
||||
await signIn(page,tester.username,tester.password);
|
||||
await page.getByLabel('Workspace',{exact:true}).waitFor();
|
||||
const fixture=await page.evaluate(async()=>{
|
||||
const a=Alpine.$data(document.body);
|
||||
const r=(await a.api('/api/workspaces/'+a.workspace.id)).workspace;
|
||||
const start=a.addDays(a.today(),2),contact=crypto.randomUUID();
|
||||
const doc=r.document;
|
||||
doc.people.push({id:contact,user_id:null,display_name:'Email colleague',email:'colleague@example.com',country:'CY'});
|
||||
doc.schedules=[{id:crypto.randomUUID(),name:'Contact duty',type:'rotation',participants:[a.user.id],anchor:start,shift_days:7,handover:'11:00:00'}];
|
||||
await a.api('/api/workspaces/'+r.id+'/config','PUT',{revision:r.revision,document:doc});
|
||||
return {workspace:r.id,start,contact};
|
||||
});
|
||||
await page.goto(base+'/?workspace='+fixture.workspace);
|
||||
await page.locator('[data-duty-date="'+fixture.start+'"]').click();
|
||||
const dialog=page.getByRole('dialog');
|
||||
await dialog.getByLabel('Search colleagues').fill('colleague@example.com');
|
||||
await dialog.getByRole('button',{name:'Email colleague',exact:true}).click();
|
||||
const apply=dialog.getByRole('button',{name:'Apply now',exact:true});
|
||||
await apply.waitFor();
|
||||
await page.waitForFunction(()=>Alpine.$data(document.body).canSendRequest);
|
||||
assert.equal(await apply.isEnabled(),true);
|
||||
await dialog.getByText('Applies immediately. No approval needed.',{exact:true}).waitFor();
|
||||
fs.mkdirSync('test-results',{recursive:true});
|
||||
await page.screenshot({path:'test-results/contact-approval.png'});
|
||||
await apply.click();
|
||||
await dialog.waitFor({state:'hidden'});
|
||||
const request=await page.evaluate(()=>Alpine.$data(document.body).doc.requests.at(-1));
|
||||
assert.equal(request.status,'applied');
|
||||
assert.equal(request.forced_by,null);
|
||||
assert.deepEqual(request.required_approvers,[]);
|
||||
await page.reload();
|
||||
await page.waitForFunction(()=>Alpine.$data(document.body).doc?.requests.length);
|
||||
assert.match(await page.locator('[data-duty-date="'+fixture.start+'"]').innerText(),/Email/);
|
||||
assert.equal(await page.evaluate(id=>Alpine.$data(document.body).doc.requests.at(-1).legs[0].to_user,fixture.contact),fixture.contact);
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: email contact preview, immediate unforced save, calendar persistence, no JavaScript errors');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -1,13 +1,87 @@
|
||||
const assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await require('./support/browser.cjs').launchBrowser();try{const page=await browser.newPage({viewport:{width:1500,height:1050}});page.setDefaultTimeout(10000);const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill(await require('./support/browser.cjs').demoPassword());await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Map',exact:true}).click();const d=page.locator('dialog.generation-map');await d.waitFor();await page.waitForFunction(()=>Alpine.$data(document.body).mapNodes.length>0&&!Alpine.$data(document.body).mapBusy);assert.equal(await d.locator('canvas').count()>0,true);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Off hours and unmatched alerts'});await page.waitForFunction(()=>document.querySelector('.map-source-list')?.textContent.includes('Time off'));await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>document.querySelector('.map-input-content')?.innerText.includes('Otherwise'));const inputs=await d.locator('.map-input-content').innerText();assert(inputs.includes('Time off'));assert(inputs.includes('All alerts'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(inputs));assert(!inputs.includes('iteration'));assert(!inputs.includes('null'));assert(await d.locator('.map-input-content .map-reference').count()>0);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('.map-source-card').length===2);const sources=await d.locator('.map-source-list').innerText();assert(sources.includes('projects'));assert(sources.includes('Time off'));assert(sources.includes('responsible'));assert(!sources.includes('Alice'));assert(!sources.includes('different values'));await page.screenshot({path:'test-results/generation-map-inputs.png'});const layout=await page.evaluate(()=>{const a=Alpine.$data(document.body),rules=a.mapNodes.filter(n=>n.step);return {rules,children:a.mapNodes.filter(n=>n.rule&&!n.step)};});assert(layout.rules.every((n,i)=>n.step===i+1 && (!i || n.position.x>layout.rules[i-1].position.x)));for(const n of layout.children){const r=layout.rules.find(r=>r.rule===n.rule);assert.equal(n.position.x,r.position.x);assert(n.position.y>r.position.y);}assert.equal(await d.locator('.map-canvas').evaluate(el=>el._cyreg.cy.edges('[role="order"]').length),layout.rules.length-1);const stack=await d.locator('.map-canvas').evaluate(el=>{const cy=el._cyreg.cy,n=cy.nodes('[kind="batch"]').first();n.position({x:n.position('x')+20,y:n.position('y')+30});const p=n.position(),sheet=cy.getElementById(n.id()+':sheet:2').position();return {x:sheet.x-p.x,y:sheet.y-p.y};});assert.deepEqual(stack,{x:16,y:-16});const canvas=d.locator('.map-canvas'),box=await canvas.boundingBox();const point=await canvas.evaluate(el=>el._cyreg.cy.nodes().filter(n=>n.data('kind')==='variable').first().renderedPosition());await page.mouse.click(box.x+point.x,box.y+point.y);await page.waitForFunction(()=>!!Alpine.$data(document.body).mapCurrent()?.variable);const before=await canvas.evaluate(el=>el._cyreg.cy.pan());await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+70,box.y+65,{steps:6});await page.mouse.up();const after=await canvas.evaluate(el=>el._cyreg.cy.pan());assert.notDeepEqual(after,before);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project · Iteration · 6 passes'});assert.equal(await d.locator('.map-resource').count(),18);const owners=d.locator('.map-resource').filter({has:page.locator('summary > strong').filter({hasText:/^Payments · owners$/})});await owners.locator('summary').click();const ownerFields=owners.locator('.map-resource-fields');await ownerFields.waitFor();const ownerText=await ownerFields.innerText();assert(/Alice Morgan[\s\S]*Bob Chen[\s\S]*Dave Wilson/.test(ownerText));assert(ownerText.includes('Time off'));assert(ownerText.includes('11:00'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(ownerText));assert.equal(await owners.locator('pre').count(),0);await page.screenshot({path:'test-results/generation-map-resource-fields.png'});await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===3);await d.getByRole('button',{name:'Expand outputs',exact:true}).click();assert(await d.getByLabel('Selected node',{exact:true}).locator('option').count()>10);await d.getByRole('button',{name:'Zoom in',exact:true}).click();await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-desktop.png'});await page.setViewportSize({width:390,height:844});await d.getByRole('button',{name:'Fit all',exact:true})Line truncated
|
||||
const revision=await page.evaluate(()=>Alpine.$data(document.body).workspace.revision);
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.generationDraft.rules[1].enabled=false;});
|
||||
await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('dialog.generation-map .map-resource').length===0);await d.getByRole('button',{name:'Close dialog',exact:true}).click();
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.generationDraft.rules[1].enabled=true;a.generationDraft.variables.find(v=>v.name==='projects').node.entries[0].entries.find(n=>n.key==='responsible').entries[0].value='not-an-email';});
|
||||
await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);assert(await d.locator('.map-status-error').count());await page.waitForFunction(()=>document.querySelectorAll('dialog.generation-map .map-resource').length===0);await d.getByRole('button',{name:'Close dialog',exact:true}).click();assert.equal(await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).workspace.revision}),revision);
|
||||
await page.getByLabel('Workspace',{exact:true}).selectOption({label:'Regional Platform'});await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Service · Iteration · 4 passes'});const rows=await page.evaluate(()=>Alpine.$data(document.body).mapRows());const filtered=rows.findIndex(r=>r.status==='filtered');assert(filtered>=0);await d.getByLabel('Iteration pass',{exact:true}).selectOption(String(filtered));await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===0);await page.screenshot({path:'test-results/generation-map-filtered.png'});await d.getByRole('button',{name:'Close dialog',exact:true}).click();
|
||||
assert.deepEqual(errors,[]);console.log('PASS generation map, actual iteration outputs, pass filtering, group expansion, zoom, source editor, mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
|
||||
|
||||
const fs=require('node:fs');
|
||||
const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
|
||||
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
|
||||
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
|
||||
(async()=>{
|
||||
const browser=await launchBrowser();
|
||||
try {
|
||||
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
|
||||
page.setDefaultTimeout(12000);
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
const tester=await isolatedUser({sandbox:7,prefix:'map-origin',display:'Path tester'});
|
||||
await signIn(page,tester.username,tester.password);
|
||||
await page.getByLabel('Workspace',{exact:true}).waitFor();
|
||||
|
||||
await page.evaluate(async()=>{
|
||||
const a=Alpine.$data(document.body),doc=structuredClone(Alpine.raw(a.doc));
|
||||
const manual={id:crypto.randomUUID(),name:'Manual fallback',type:'rotation',participants:[a.user.id],anchor:a.today(),handover:'11:00:00',shift_days:7};doc.schedules.push(manual);
|
||||
await a.api(a.endpoint('/config'),'PUT',{revision:a.workspace.revision,document:doc});await a.loadWorkspace();
|
||||
const projects=a.newNode('list');for(const name of ['cse','opd']){const row=a.newNode('dictionary'),owners=a.newNode('list',null,'owners');owners.entries=[a.newNode('person',a.user.id)];row.entries=[a.newNode('text',name,'name'),owners];projects.entries.push(row);}
|
||||
const v={id:crypto.randomUUID(),name:'projects',node:projects},later={id:crypto.randomUUID(),name:'later',node:a.newNode('text','Later schedule')};
|
||||
a.generationDraft.variables=[later,v];
|
||||
const loop={id:crypto.randomUUID(),name:'Project',source:{kind:'variable',variable:v.id,path:[]}};
|
||||
const block=a.newBlock('priority');block.fields.name={kind:'item',iteration:loop.id,path:['name'],prefix:'',suffix:' daytime'};block.fields.participants={kind:'item',iteration:loop.id,path:['owners']};block.fields.fallback=a.literal(manual.id);
|
||||
const policy=a.newBlock('escalation');policy.fields.name={kind:'item',iteration:loop.id,path:['name'],suffix:' escalation'};policy.steps[0].recipient={kind:'output',output:block.id};
|
||||
const route=a.newBlock('route');route.fields.name={kind:'item',iteration:loop.id,path:['name'],suffix:' route'};route.fields.escalation={kind:'output',output:policy.id};
|
||||
const second=a.newBlock('rotation');second.fields.name={kind:'variable',variable:later.id,path:[]};
|
||||
a.generationDraft.rules=[{id:crypto.randomUUID(),name:'Project owners',enabled:true,iterations:[loop],when:null,blocks:[block,policy,route]},{id:crypto.randomUUID(),name:'Later generator',enabled:true,iterations:[],when:null,blocks:[second]}];
|
||||
await a.openGenerationMap();
|
||||
});
|
||||
await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);
|
||||
const map=page.locator('dialog.generation-map'),canvas=map.locator('.map-canvas');
|
||||
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).mapSnapshot.report.issues),[]);
|
||||
const layout=await page.evaluate(()=>Alpine.$data(document.body).mapNodes.map(n=>({id:n.id,lane:n.lane,title:n.title,x:n.position.x,y:n.position.y,manual:n.manual,step:n.step})));
|
||||
assert(layout.find(n=>n.title==='projects').x<layout.find(n=>n.title==='later').x);
|
||||
assert(Math.max(...layout.filter(n=>n.lane===0).map(n=>n.y))<Math.min(...layout.filter(n=>n.lane===1).map(n=>n.y)));
|
||||
assert(Math.max(...layout.filter(n=>n.lane===1).map(n=>n.y))<Math.min(...layout.filter(n=>n.lane===2).map(n=>n.y)));
|
||||
assert(layout.some(n=>n.manual&&n.title==='Manual fallback'));
|
||||
const shapes=await canvas.evaluate(el=>{const cy=el._cyreg.cy;return ['variable','rule','output'].map(k=>cy.nodes('[kind="'+k+'"]').first().style('shape'));});
|
||||
assert.equal(new Set(shapes).size,3);
|
||||
assert.equal(await canvas.evaluate(el=>new Set(['schedule','escalation','route'].map(k=>el._cyreg.cy.nodes('[resourceKind="'+k+'"]').first().style('background-color'))).size),3);
|
||||
const address=map.locator('.map-source-addresses .map-address').filter({has:page.getByRole('button',{name:'name',exact:true})});
|
||||
await address.getByRole('button',{name:'name',exact:true}).click();
|
||||
const probe=page.locator('.map-probe');await probe.waitFor();
|
||||
assert((await probe.innerText()).includes('cse'));assert((await probe.innerText()).includes('opd'));
|
||||
const chain=probe.locator('.map-probe-chain');
|
||||
assert.equal(await chain.getByRole('button',{name:'View source',exact:true}).count(),0);
|
||||
await chain.getByRole('button',{name:'Project.value',exact:true}).click();
|
||||
assert((await probe.locator('.map-probe-example').first().innerText()).includes('Path tester'));
|
||||
await chain.getByRole('button',{name:'name',exact:true}).click();
|
||||
assert(!(await probe.locator('.map-probe-example').first().innerText()).includes('Path tester'));
|
||||
assert.equal(await chain.locator('[aria-current="step"]').innerText(),'name');
|
||||
assert((await chain.getByRole('button',{name:'name',exact:true}).evaluate(el=>getComputedStyle(el).fontFamily)).includes('monospace'));
|
||||
|
||||
await probe.getByRole('button',{name:'Done',exact:true}).click();
|
||||
const resource=map.locator('.map-resource').filter({has:page.locator('.map-resource-name').filter({hasText:'cse daytime'})});
|
||||
await resource.locator('.map-resource-name button').filter({hasText:'cse'}).click();await probe.waitFor();
|
||||
assert((await probe.locator('.map-address').textContent()).includes('Project.value.name'));
|
||||
await probe.locator('.map-probe-actions').getByRole('button',{name:'View source',exact:true}).click();
|
||||
await probe.waitFor({state:'hidden'});
|
||||
await resource.locator('summary').click();
|
||||
await resource.getByRole('button',{name:'Path tester',exact:true}).click();await probe.waitFor();
|
||||
assert((await probe.locator('.map-address').textContent()).includes('Project.value.owners'));
|
||||
await probe.locator('.map-probe-chain').getByRole('button',{name:'var.projects',exact:true}).click();
|
||||
await probe.getByRole('button',{name:'Back',exact:true}).click();
|
||||
assert((await probe.locator('.map-address').textContent()).includes('Project.value.owners'));
|
||||
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/map-provenance-desktop.png'});
|
||||
await probe.getByRole('button',{name:'Done',exact:true}).click();
|
||||
const policyResource=map.locator('.map-resource').filter({has:page.locator('.map-resource-name').filter({hasText:'cse escalation'})});
|
||||
await policyResource.locator('summary').click();
|
||||
await policyResource.getByRole('button',{name:'cse daytime',exact:true}).click();
|
||||
await probe.locator('.map-probe-fields').getByRole('button',{name:'Name',exact:true}).click();
|
||||
assert((await probe.innerText()).includes('cse daytime'));
|
||||
await probe.getByRole('button',{name:'Done',exact:true}).click();
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.mapSelect(a.mapNodes.find(n=>n.manual).id);});
|
||||
await map.getByText('Manual resources · 1',{exact:true}).waitFor();
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.mapSelect(a.mapNodes.find(n=>n.kind==='iteration').id);a.mapToggle();});
|
||||
assert(await page.evaluate(()=>Alpine.$data(document.body).mapNodes.some(n=>n.id.startsWith('o:'))));
|
||||
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).mapAddressParts('{ var["a.b"].owners[1] }').map(p=>p.text)),['var["a.b"]','.','owners[1]']);
|
||||
await map.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-desktop.png'});
|
||||
await page.setViewportSize({width:390,height:844});await map.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-mobile.png'});
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
||||
await page.setViewportSize({width:1280,height:900});await page.mouse.click(2,2);await map.waitFor({state:'hidden'});
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: layers, first-use ordering, shapes, manual resources, provenance segments, people, history, groups, mobile and backdrop');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,40 @@
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
|
||||
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
|
||||
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
|
||||
(async()=>{
|
||||
const browser=await launchBrowser();
|
||||
try {
|
||||
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
// Sandbox 11 belongs to this suite; workspaces cannot be created, so it starts empty.
|
||||
const tester=await isolatedUser({sandbox:11,prefix:'generation-team',display:'Path tester'});
|
||||
await signIn(page,tester.username,tester.password);
|
||||
await page.getByLabel('Workspace',{exact:true}).waitFor();
|
||||
const dialog=page.getByRole('dialog');
|
||||
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.editGenerationRule();a.form.name='Team escalation';a.form.blocks=[a.newBlock('escalation')];});
|
||||
await dialog.getByLabel('Recipient type',{exact:true}).selectOption('team_all');
|
||||
await dialog.getByLabel('Delay in minutes',{exact:true}).fill('30');
|
||||
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading && a.generationResult?.result?.escalations.some(e=>e.steps.some(s=>s.recipient.type==='team_all' && s.delay_minutes===30));});
|
||||
assert.equal(await dialog.locator('.generation-step .binding-button.grow:visible').count(),0);
|
||||
await dialog.getByLabel('Recipient type',{exact:true}).selectOption('team_random');
|
||||
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading && a.generationResult?.result?.escalations.some(e=>e.steps.some(s=>s.recipient.type==='team_random' && s.delay_minutes===30));});
|
||||
assert.equal(await dialog.locator('.generation-step .binding-button.grow:visible').count(),0);
|
||||
await dialog.getByRole('button',{name:'Use in draft',exact:true}).click();
|
||||
await dialog.waitFor({state:'hidden'});
|
||||
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationSaving && !a.generationUnsaved();});
|
||||
await page.reload();
|
||||
await page.waitForFunction(()=>Alpine.$data(document.body).generationDraft?.rules.length);
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.editGenerationRule(a.generationDraft.rules[0]);});
|
||||
assert.equal(await dialog.getByLabel('Recipient type',{exact:true}).inputValue(),'team_random');
|
||||
assert.equal(await dialog.getByLabel('Delay in minutes',{exact:true}).inputValue(),'30');
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await dialog.getByLabel('Recipient type',{exact:true}).scrollIntoViewIfNeeded();
|
||||
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/generation-team-mobile.png'});
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: generated team recipient, 30-minute delay, server expansion, shared persistence and mobile');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -58,9 +58,9 @@ fs.mkdirSync('test-results',{recursive:true});
|
||||
if(await status.locator('.validation-toggle').getAttribute('aria-expanded')==='false')await status.locator('.validation-toggle').click();assert.match(await status.locator('.validation-details').innerText(),/shift_days/);
|
||||
await dialog().getByRole('button',{name:'Bind Shift length (days)',exact:true}).click();await dialog().getByLabel('Fixed number',{exact:true}).waitFor();assert.equal(await dialog().getByLabel('Rule name',{exact:true}).isVisible(),true,'The value picker preserves the constructor');
|
||||
await dialog().getByLabel('Fixed number',{exact:true}).fill('1');await dialog().getByRole('button',{name:'Done',exact:true}).click();await dialog().getByLabel('Rule name',{exact:true}).waitFor();await checked();assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);await page.screenshot({path:'test-results/generation-rule-editor.png',fullPage:true});await save('Use in draft');await checked();
|
||||
await page.getByRole('button',{name:'Apply checked changes',exact:true}).click();await page.waitForFunction(rev=>Alpine.$data(document.body).workspace.revision>rev,revision);await checked();
|
||||
await page.getByRole('button',{name:'Save checked changes',exact:true}).click();await page.waitForFunction(rev=>Alpine.$data(document.body).workspace.revision>rev,revision);await checked();
|
||||
const ids=await page.evaluate(()=>Alpine.$data(document.body).doc.schedules.map(s=>s.id));assert.equal(ids.length,2);assert.equal(await page.evaluate(()=>Alpine.$data(document.body).doc.schedules[0].shift_days),1);
|
||||
await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();await dialog().getByLabel('Variable name',{exact:true}).fill('services');await save('Use in draft');await checked();await page.getByRole('button',{name:'Apply checked changes',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).busy);await page.reload();await openSetup(page,'Variables & rules');await checked();assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).doc.schedules.map(s=>s.id)),ids);
|
||||
await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();await dialog().getByLabel('Variable name',{exact:true}).fill('services');await save('Use in draft');await checked();await page.getByRole('button',{name:'Save checked changes',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).busy);await page.reload();await openSetup(page,'Variables & rules');await checked();assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).doc.schedules.map(s=>s.id)),ids);
|
||||
await openSetup(page,'Schedules');assert.equal(await page.locator('[data-resource-kind="schedules"] .source-rule-link').count(),2);
|
||||
// The guided editor resolves the ordinary project/dictionary case without opening a binding editor.
|
||||
await page.getByRole('button',{name:'+ Generation rule',exact:true}).click();await checked();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const {launchBrowser,openSetup,isolatedUser,createUser,signIn}=require('./support/browser.cjs');
|
||||
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
|
||||
assert(['127.0.0.1','localhost'].includes(new URL(base).hostname));
|
||||
fs.mkdirSync('test-results',{recursive:true});
|
||||
(async()=>{
|
||||
const browser=await launchBrowser();
|
||||
const context=await browser.newContext({locale:'en-US',viewport:{width:1440,height:1000}});
|
||||
const page=await context.newPage();page.setDefaultTimeout(15000);
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
try{
|
||||
// Sandbox 13 belongs to this suite: two administrators (the owner and a colleague with the same access).
|
||||
const owner=await isolatedUser({sandbox:13,prefix:'sync-owner',display:'Workflow owner'});
|
||||
const colleague=await createUser(owner.admin,{sandbox:13,role:'admin',prefix:'sync-colleague',display:'Workflow colleague'});
|
||||
const username=owner.username,password=owner.password;
|
||||
await signIn(page,username,password);
|
||||
await page.getByLabel('Workspace',{exact:true}).waitFor();
|
||||
const workspace=await page.evaluate(()=>Alpine.$data(document.body).workspace.id);
|
||||
const secondContext=await browser.newContext({locale:'en-US'});
|
||||
await page.evaluate(async()=>{const a=Alpine.$data(document.body);const doc=structuredClone(Alpine.raw(a.doc));doc.schedules.push({id:crypto.randomUUID(),name:'Awaiting publication',type:'rotation',participants:[a.user.id],anchor:new Date().toISOString().slice(0,10),shift_days:7,handover:'11:00:00'});await a.api(a.endpoint('/config'),'PUT',{revision:a.workspace.revision,document:doc});await a.loadWorkspace();});
|
||||
const review=page.locator('[data-publication-review]');
|
||||
await review.getByText('+1 new',{exact:true}).waitFor();
|
||||
const initialSync=await page.evaluate(()=>structuredClone(Alpine.raw(Alpine.$data(document.body).detail.sync)));
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.detail.sync={...a.detail.sync,state:'publishing',publication_busy:true,lease_until:new Date(Date.now()+300000).toISOString()};});
|
||||
assert(await review.locator('.publication-sync').isDisabled());
|
||||
await page.evaluate(()=>{Alpine.$data(document.body).detail.sync.publication_busy=false;});
|
||||
await page.waitForFunction(()=>!document.querySelector('.publication-sync').disabled);
|
||||
await review.getByText('The publication lock has expired. The worker can resume; you can also request synchronization again.',{exact:true}).waitFor();
|
||||
await page.screenshot({path:'test-results/manual-sync-expired-lease.png',fullPage:true});
|
||||
await page.evaluate(sync=>{Alpine.$data(document.body).detail.sync=sync;},initialSync);
|
||||
|
||||
assert(await review.locator('.publication-sync').evaluate(el=>el.classList.contains('sync-needed')));
|
||||
await openSetup(page,'Variables & rules');
|
||||
await page.getByRole('button',{name:'+ Variable',exact:true}).click();
|
||||
await page.getByLabel('Variable name',{exact:true}).fill('Shared unsynchronized draft');
|
||||
await page.getByRole('button',{name:'Use in draft',exact:true}).click();
|
||||
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.detail.generation_draft?.revision>0 && !a.generationSaving && !a.generationLoading;});
|
||||
await review.getByRole('button',{name:'Reload saved state',exact:true}).waitFor();
|
||||
await review.getByRole('button',{name:'Save checked changes',exact:true}).waitFor();
|
||||
page.once('dialog',dialog=>dialog.dismiss());
|
||||
await page.getByRole('button',{name:'Remove variable Shared unsynchronized draft',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Edit variable Shared unsynchronized draft',exact:true}).waitFor();
|
||||
await page.getByRole('button',{name:'+ Variable',exact:true}).click();
|
||||
await page.getByLabel('Variable name',{exact:true}).fill('Delete confirmation test');
|
||||
await page.getByRole('button',{name:'Use in draft',exact:true}).click();
|
||||
page.once('dialog',dialog=>dialog.accept());
|
||||
await page.getByRole('button',{name:'Remove variable Delete confirmation test',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Edit variable Delete confirmation test',exact:true}).waitFor({state:'hidden'});
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body),block=a.newBlock('rotation');block.fields.name=a.literal('Generated pending schedule');a.generationDraft.rules.push({id:crypto.randomUUID(),name:'Shared rotation rule',enabled:true,iterations:[],when:null,blocks:[block]});});
|
||||
await review.getByText('+2 new',{exact:true}).waitFor();
|
||||
// More than one worker tick passes; saving has not authorized a publication.
|
||||
await page.waitForTimeout(6000);
|
||||
const before=await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).sync;});assert.equal(before.published_revision,null);assert.equal(before.authorized_revision,null);
|
||||
await page.getByRole('button',{name:'Sign out',exact:true}).click();
|
||||
await page.getByLabel('Username',{exact:true}).fill(username);await page.getByLabel('Password',{exact:true}).fill(password);
|
||||
await page.getByRole('button',{name:'Sign in',exact:true}).click();await openSetup(page,'Variables & rules');
|
||||
await page.getByRole('button',{name:'Edit variable Shared unsynchronized draft',exact:true}).waitFor();
|
||||
const second=await secondContext.newPage();second.on('pageerror',e=>errors.push(e.message));
|
||||
await signIn(second,colleague.username,colleague.password);
|
||||
await second.goto(base+'/?workspace='+workspace+'&view=configuration');
|
||||
await second.getByRole('button',{name:'Edit variable Shared unsynchronized draft',exact:true}).waitFor();
|
||||
await second.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);
|
||||
await second.screenshot({path:'test-results/manual-sync-shared-desktop.png',fullPage:true});
|
||||
const secondReview=second.locator('[data-publication-review]');
|
||||
let holdQueued=true;
|
||||
const workspaceUrl=base+'/api/workspaces/'+workspace;
|
||||
await second.route(workspaceUrl,async route=>{const response=await route.fetch();const data=await response.json();if(holdQueued)data.sync={...data.sync,state:'pending',authorized_revision:data.workspace?.revision||1,publication_busy:false,lease_until:null};await route.fulfill({response,json:data});});
|
||||
await secondReview.locator('.publication-sync').click();
|
||||
await second.waitForFunction(()=>!Alpine.$data(document.body).busy);
|
||||
assert.equal(await second.evaluate(()=>Alpine.$data(document.body).error),'');
|
||||
assert(await secondReview.locator('.publication-sync').isDisabled());
|
||||
await secondReview.getByRole('button',{name:'Queued…',exact:true}).waitFor();
|
||||
await secondReview.getByText('Synchronization requested. Waiting for the worker; status updates automatically.',{exact:true}).waitFor();
|
||||
await second.screenshot({path:'test-results/manual-sync-queued.png',fullPage:true});
|
||||
holdQueued=false;
|
||||
await second.waitForFunction(()=>Alpine.$data(document.body).detail.sync.state==='synced');
|
||||
|
||||
const after=await second.evaluate(()=>Alpine.$data(document.body).detail.sync);
|
||||
assert.equal(after.state,'synced');assert.equal(after.has_changes,false);
|
||||
assert(!await secondReview.locator('.publication-sync').evaluate(el=>el.classList.contains('sync-needed')));
|
||||
assert.equal(await second.evaluate(()=>Alpine.$data(document.body).doc.schedules.length),2);
|
||||
await second.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);
|
||||
await second.setViewportSize({width:390,height:844});
|
||||
await second.evaluate(()=>{Alpine.$data(document.body).setupOpen=false;});
|
||||
await second.waitForFunction(()=>getComputedStyle(document.querySelector('.publication-sync')).backgroundColor==='rgb(255, 255, 255)');
|
||||
await second.screenshot({path:'test-results/manual-sync-mobile.png',fullPage:true});
|
||||
assert(await second.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('Shared draft, no automatic publication, colleague sync, published review and mobile layout passed.');
|
||||
await secondContext.close();
|
||||
}catch(e){await page.screenshot({path:'test-results/manual-sync-failure.png',fullPage:true});console.error('Browser errors:',errors);console.error(await page.locator('body').innerText());throw e;}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exit(1);});
|
||||
@@ -0,0 +1,68 @@
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const {launchBrowser,isolatedUser,signIn}=require('./support/browser.cjs');
|
||||
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
|
||||
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
|
||||
(async()=>{
|
||||
const browser=await launchBrowser();
|
||||
try {
|
||||
const page=await browser.newPage({locale:'en-US',viewport:{width:1280,height:900}});
|
||||
page.setDefaultTimeout(12000);
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
// Sandbox 10 belongs to this suite; workspaces cannot be created, so it starts empty.
|
||||
const tester=await isolatedUser({sandbox:10,prefix:'config-transfer',display:'Path tester'});
|
||||
await signIn(page,tester.username,tester.password);
|
||||
await page.getByLabel('Workspace',{exact:true}).waitFor();
|
||||
const dialog=page.getByRole('dialog');
|
||||
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.view='generation';});
|
||||
const initial=await page.evaluate(()=>({revision:Alpine.$data(document.body).workspace.revision,workspace:Alpine.$data(document.body).workspace.id}));
|
||||
await page.getByRole('button',{name:'Import / Export',exact:true}).click();
|
||||
const modal=page.getByRole('dialog');
|
||||
await modal.getByLabel('Configuration · meguri/v2 JSON').waitFor();
|
||||
await page.waitForFunction(()=>!!Alpine.$data(document.body).transferExport);
|
||||
await modal.getByRole('button',{name:'Guide',exact:true}).click();
|
||||
const guide=modal.locator('.config-guide');await guide.waitFor({state:'visible'});
|
||||
await page.context().grantPermissions(['clipboard-read','clipboard-write'],{origin:base});
|
||||
await modal.getByRole('button',{name:'Copy full guide',exact:true}).click();
|
||||
const copiedGuide=await page.evaluate(()=>navigator.clipboard.readText());
|
||||
assert(copiedGuide.includes('$lookup'));assert(copiedGuide.includes('not_equals'));assert(copiedGuide.includes('ends-with'));
|
||||
const sample=JSON.parse(copiedGuide.match(/```json\n([\s\S]*?)\n```/)[1]);
|
||||
const guidePreview=await page.evaluate(async text=>{const a=Alpine.$data(document.body);return a.api(a.endpoint('/generation-import-preview'),'POST',{...a.transferBase,text});},JSON.stringify(sample));
|
||||
assert.deepEqual(guidePreview.preview.issues,[]);assert.equal(guidePreview.preview.changes.filter(c=>c.action==='create').length,4);
|
||||
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/config-guide-desktop.png'});
|
||||
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/config-guide-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.setViewportSize({width:1280,height:900});
|
||||
await modal.getByRole('button',{name:'Import',exact:true}).click();
|
||||
const input=modal.getByLabel('Paste or edit configuration'),apply=modal.getByRole('button',{name:'Apply to shared draft',exact:true});
|
||||
await input.fill('{ broken');
|
||||
await modal.getByRole('button',{name:'Guide',exact:true}).click();await modal.getByRole('button',{name:'Import',exact:true}).click();assert.equal(await input.inputValue(),'{ broken');
|
||||
await modal.getByRole('alert').filter({hasText:'Invalid configuration'}).waitFor();assert(await apply.isDisabled());
|
||||
const config={format:'meguri/v1',variables:[{name:'projects',node:{kind:'list',entries:['cse','opd'].map(name=>({id:name,kind:'dictionary',entries:[{kind:'text',key:'name',value:name},{kind:'list',key:'owners',entries:[{kind:'person',value:'owner@example.com'}]}]}))}},{name:'keep me',node:{kind:'text',value:'retained'}}],generators:[{name:'Weekly projects',iterations:[{name:'Project',source:{kind:'variable',variable:'projects'}}],blocks:[{id:'weekly',kind:'rotation',fields:{name:{kind:'item',iteration:'Project',path:['name'],suffix:' on-call'},participants:{kind:'item',iteration:'Project',path:['owners']},anchor:{kind:'literal',value:'2026-09-28'},shift_days:{kind:'literal',value:7},handover:{kind:'literal',value:'11:00'}}}]}]};
|
||||
await input.fill(JSON.stringify(config,null,2));
|
||||
await page.waitForFunction(()=>Alpine.$data(document.body).transferCanApply());
|
||||
assert((await modal.locator('.transfer-preview').innerText()).includes('cse on-call'));
|
||||
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).transferResult.contacts.map(p=>p.email)),['owner@example.com']);
|
||||
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).workspace.revision),initial.revision);
|
||||
const before=await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).generation_draft;});assert(!before.payload||!before.payload.rules.length);
|
||||
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/config-import-desktop.png'});
|
||||
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/config-import-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.setViewportSize({width:1280,height:900});
|
||||
await apply.click();await modal.waitFor({state:'hidden'});
|
||||
await page.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);
|
||||
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).workspace.revision),initial.revision);
|
||||
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).doc.schedules.length),0);
|
||||
const ids=await page.evaluate(()=>Alpine.$data(document.body).generationDraft.rules.map(r=>r.blocks.map(b=>b.id)));
|
||||
await page.getByRole('button',{name:'Import / Export',exact:true}).click();await page.waitForFunction(()=>!!Alpine.$data(document.body).transferExport);
|
||||
const exported=await modal.getByLabel('Configuration · meguri/v2 JSON').inputValue();assert(exported.includes('owner@example.com'));assert.equal(JSON.parse(exported).format,'meguri/v2');assert(!exported.includes(ids[0][0]));await page.screenshot({path:'test-results/config-v2-export.png'});
|
||||
const downloaded=page.waitForEvent('download');await modal.getByRole('button',{name:'Download JSON',exact:true}).click();assert.equal((await downloaded).suggestedFilename(),'meguri-config.json');
|
||||
const edited=JSON.parse(exported);delete edited.variables['keep me'];edited.generators[0].outputs[0].shift_days=14;
|
||||
await modal.getByRole('button',{name:'Import',exact:true}).click();await input.fill(JSON.stringify(edited));await page.waitForFunction(()=>Alpine.$data(document.body).transferCanApply());
|
||||
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).transferResult.preview.changes.filter(c=>c.action==='create').length),2);
|
||||
await apply.click();await modal.waitFor({state:'hidden'});
|
||||
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.rules.map(r=>r.blocks.map(b=>b.id))),ids);
|
||||
assert(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.some(v=>v.name==='keep me')));
|
||||
await page.reload();await page.waitForFunction(()=>Alpine.$data(document.body).generationDraft?.rules.length===1);
|
||||
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.rules[0].blocks[0].fields.shift_days.value),14);
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: portable config live preview, parse errors, no writes before apply, shared draft persistence, identities, merge, export, download and mobile');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
Vendored
+437
@@ -0,0 +1,437 @@
|
||||
{
|
||||
"format": "meguri/v1",
|
||||
"generators": [
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"fields": {
|
||||
"anchor": {
|
||||
"kind": "literal",
|
||||
"value": "2026-09-28"
|
||||
},
|
||||
"handover": {
|
||||
"kind": "literal",
|
||||
"value": "11:00"
|
||||
},
|
||||
"name": {
|
||||
"kind": "literal",
|
||||
"value": "Time off default"
|
||||
},
|
||||
"participants": {
|
||||
"kind": "variable",
|
||||
"value": [
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"00000000-0000-0000-0000-000000000002",
|
||||
"00000000-0000-0000-0000-000000000003"
|
||||
],
|
||||
"variable": "on_call"
|
||||
},
|
||||
"shift_days": {
|
||||
"kind": "literal",
|
||||
"value": 7
|
||||
}
|
||||
},
|
||||
"id": "00000000-0000-0000-0000-000000000004",
|
||||
"kind": "rotation"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"name": "Time off"
|
||||
},
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"fields": {
|
||||
"fallback": {
|
||||
"kind": "output",
|
||||
"output": [
|
||||
"Time off",
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
],
|
||||
"value": null
|
||||
},
|
||||
"handover": {
|
||||
"kind": "literal",
|
||||
"value": "11:00"
|
||||
},
|
||||
"name": {
|
||||
"iteration": "Project",
|
||||
"kind": "item",
|
||||
"path": [
|
||||
"name"
|
||||
],
|
||||
"suffix": " daytime primary",
|
||||
"value": "New First available"
|
||||
},
|
||||
"participants": {
|
||||
"iteration": "Project",
|
||||
"kind": "item",
|
||||
"path": [
|
||||
"primary"
|
||||
],
|
||||
"value": [
|
||||
"00000000-0000-0000-0000-000000000001"
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "00000000-0000-0000-0000-000000000005",
|
||||
"kind": "priority"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"fallback": {
|
||||
"kind": "output",
|
||||
"output": [
|
||||
"Time off",
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
],
|
||||
"value": null
|
||||
},
|
||||
"handover": {
|
||||
"kind": "literal",
|
||||
"value": "11:00"
|
||||
},
|
||||
"name": {
|
||||
"iteration": "Project",
|
||||
"kind": "item",
|
||||
"path": [
|
||||
"name"
|
||||
],
|
||||
"suffix": " daytime secondary",
|
||||
"value": "New First available"
|
||||
},
|
||||
"participants": {
|
||||
"iteration": "Project",
|
||||
"kind": "item",
|
||||
"path": [
|
||||
"secondary"
|
||||
],
|
||||
"value": [
|
||||
"00000000-0000-0000-0000-000000000001"
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "00000000-0000-0000-0000-000000000006",
|
||||
"kind": "priority"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"iteration": "Project",
|
||||
"kind": "item",
|
||||
"path": [
|
||||
"name"
|
||||
],
|
||||
"suffix": " - daytime",
|
||||
"value": "MG: { iteration[\"Item 1\"].value.name } - owners"
|
||||
},
|
||||
"repeat_count": {
|
||||
"kind": "literal",
|
||||
"value": 0
|
||||
},
|
||||
"repeat_wait": {
|
||||
"kind": "literal",
|
||||
"value": 0
|
||||
}
|
||||
},
|
||||
"id": "00000000-0000-0000-0000-000000000007",
|
||||
"kind": "escalation",
|
||||
"steps": [
|
||||
{
|
||||
"condition": "if-not-acked",
|
||||
"delay": {
|
||||
"kind": "literal",
|
||||
"value": 0
|
||||
},
|
||||
"recipient": {
|
||||
"kind": "output",
|
||||
"output": [
|
||||
"Owners during working hours",
|
||||
"00000000-0000-0000-0000-000000000005"
|
||||
],
|
||||
"value": null
|
||||
},
|
||||
"team_all": false,
|
||||
"user": false
|
||||
},
|
||||
{
|
||||
"condition": "if-not-acked",
|
||||
"delay": {
|
||||
"kind": "literal",
|
||||
"value": 10
|
||||
},
|
||||
"recipient": {
|
||||
"kind": "output",
|
||||
"output": [
|
||||
"Owners during working hours",
|
||||
"00000000-0000-0000-0000-000000000006"
|
||||
],
|
||||
"value": null
|
||||
},
|
||||
"team_all": false,
|
||||
"user": false
|
||||
},
|
||||
{
|
||||
"condition": "if-not-acked",
|
||||
"delay": {
|
||||
"kind": "literal",
|
||||
"value": 30
|
||||
},
|
||||
"recipient": {
|
||||
"key": {
|
||||
"iteration": "Project",
|
||||
"kind": "item",
|
||||
"value": null
|
||||
},
|
||||
"kind": "literal",
|
||||
"output": [
|
||||
"Time off",
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
],
|
||||
"value": null
|
||||
},
|
||||
"team_all": true,
|
||||
"user": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"field": "tags",
|
||||
"negate": false,
|
||||
"operation": "contains",
|
||||
"value": {
|
||||
"iteration": "Project",
|
||||
"kind": "item",
|
||||
"path": [
|
||||
"tags"
|
||||
],
|
||||
"value": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"field": "priority",
|
||||
"negate": true,
|
||||
"operation": "less-than",
|
||||
"value": {
|
||||
"iteration": "Project",
|
||||
"kind": "literal",
|
||||
"value": "P3"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fields": {
|
||||
"escalation": {
|
||||
"kind": "output",
|
||||
"output": [
|
||||
"Owners during working hours",
|
||||
"00000000-0000-0000-0000-000000000007"
|
||||
],
|
||||
"value": null
|
||||
},
|
||||
"name": {
|
||||
"iteration": "Project",
|
||||
"kind": "item",
|
||||
"path": [
|
||||
"name"
|
||||
],
|
||||
"suffix": " - daytime route",
|
||||
"value": "New Routing rule"
|
||||
},
|
||||
"working": {
|
||||
"kind": "literal",
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
"id": "00000000-0000-0000-0000-000000000008",
|
||||
"kind": "route"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"iterations": [
|
||||
{
|
||||
"name": "Project",
|
||||
"source": {
|
||||
"kind": "variable",
|
||||
"value": null,
|
||||
"variable": "projects"
|
||||
}
|
||||
}
|
||||
],
|
||||
"name": "Owners during working hours"
|
||||
}
|
||||
],
|
||||
"variables": [
|
||||
{
|
||||
"name": "on_call",
|
||||
"node": {
|
||||
"entries": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000009",
|
||||
"kind": "person",
|
||||
"value": "alice@example.com"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000000a",
|
||||
"kind": "person",
|
||||
"value": "bob@example.com"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000000b",
|
||||
"kind": "person",
|
||||
"value": "carol@example.com"
|
||||
}
|
||||
],
|
||||
"id": "00000000-0000-0000-0000-00000000000c",
|
||||
"kind": "list",
|
||||
"value": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "projects",
|
||||
"node": {
|
||||
"entries": [
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000000d",
|
||||
"key": "name",
|
||||
"kind": "text",
|
||||
"value": "alpha"
|
||||
},
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000000e",
|
||||
"kind": "person",
|
||||
"value": "bob@example.com"
|
||||
}
|
||||
],
|
||||
"id": "00000000-0000-0000-0000-00000000000f",
|
||||
"key": "primary",
|
||||
"kind": "list",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000010",
|
||||
"kind": "person",
|
||||
"value": "alice@example.com"
|
||||
}
|
||||
],
|
||||
"id": "00000000-0000-0000-0000-000000000011",
|
||||
"key": "secondary",
|
||||
"kind": "list",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000012",
|
||||
"key": "tags",
|
||||
"kind": "text",
|
||||
"value": "alpha"
|
||||
}
|
||||
],
|
||||
"id": "00000000-0000-0000-0000-000000000013",
|
||||
"kind": "dictionary",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000014",
|
||||
"key": "name",
|
||||
"kind": "text",
|
||||
"value": "beta"
|
||||
},
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000015",
|
||||
"kind": "person",
|
||||
"value": "alice@example.com"
|
||||
}
|
||||
],
|
||||
"id": "00000000-0000-0000-0000-000000000016",
|
||||
"key": "primary",
|
||||
"kind": "list",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000017",
|
||||
"kind": "person",
|
||||
"value": "carol@example.com"
|
||||
}
|
||||
],
|
||||
"id": "00000000-0000-0000-0000-000000000018",
|
||||
"key": "secondary",
|
||||
"kind": "list",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000019",
|
||||
"key": "tags",
|
||||
"kind": "text",
|
||||
"value": "beta"
|
||||
}
|
||||
],
|
||||
"id": "00000000-0000-0000-0000-00000000001a",
|
||||
"kind": "dictionary",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000001b",
|
||||
"key": "name",
|
||||
"kind": "text",
|
||||
"value": "gamma"
|
||||
},
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000001c",
|
||||
"kind": "person",
|
||||
"value": "alice@example.com"
|
||||
}
|
||||
],
|
||||
"id": "00000000-0000-0000-0000-00000000001d",
|
||||
"key": "primary",
|
||||
"kind": "list",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000001e",
|
||||
"kind": "person",
|
||||
"value": "carol@example.com"
|
||||
}
|
||||
],
|
||||
"id": "00000000-0000-0000-0000-00000000001f",
|
||||
"key": "secondary",
|
||||
"kind": "list",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000020",
|
||||
"key": "tags",
|
||||
"kind": "text",
|
||||
"value": "^(gamma|project:gamma)(-[\\w-]+)?$"
|
||||
}
|
||||
],
|
||||
"id": "00000000-0000-0000-0000-000000000021",
|
||||
"kind": "dictionary",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"id": "00000000-0000-0000-0000-000000000022",
|
||||
"kind": "list",
|
||||
"value": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -690,3 +690,107 @@ fn removed_fixed_assignee_is_rejected_in_documents_and_generation() {
|
||||
example.rules[0].blocks[0].kind = "backup".into();
|
||||
assert!(!example.expand().issues.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_team_step_uses_workspace_team_without_resolving_person() {
|
||||
let mut e = Example::new();
|
||||
let block = e
|
||||
.rules
|
||||
.iter_mut()
|
||||
.flat_map(|r| &mut r.blocks)
|
||||
.find(|b| b.kind == "escalation")
|
||||
.unwrap();
|
||||
let mut step = block.steps[0].clone();
|
||||
assert!(
|
||||
!step.team_all,
|
||||
"Legacy steps keep their schedule or person recipient"
|
||||
);
|
||||
step.team_all = true;
|
||||
step.delay = serde_json::from_value(json!({"kind":"literal","value":30})).unwrap();
|
||||
step.recipient =
|
||||
serde_json::from_value(json!({"kind":"variable","variable":Uuid::new_v4()})).unwrap();
|
||||
step.condition = "if-not-acked".into();
|
||||
block.steps.push(step);
|
||||
let out = e.expand();
|
||||
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
||||
let steps: Vec<_> = out
|
||||
.document
|
||||
.escalations
|
||||
.iter()
|
||||
.flat_map(|e| &e.steps)
|
||||
.filter(|s| matches!(s.recipient, Recipient::TeamAll))
|
||||
.collect();
|
||||
assert!(!steps.is_empty());
|
||||
for step in steps {
|
||||
assert_eq!(step.delay_minutes, 30);
|
||||
assert!(matches!(step.condition, EscalationCondition::IfNotAcked));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provenance_preserves_iteration_paths_affixes_and_source_nodes() {
|
||||
let mut e = Example::new();
|
||||
(e.variables, e.rules) = demo::regional_platform(e.day, e.people[0], e.people[1]);
|
||||
let out = e.expand();
|
||||
assert!(out.issues.is_empty());
|
||||
let rows = out.trace.iter().flat_map(|t| t["rows"].as_array().unwrap());
|
||||
let mut saw_path = false;
|
||||
for row in rows {
|
||||
for trace in row["bindings"].as_array().unwrap() {
|
||||
let binding = &trace["binding"];
|
||||
if binding["kind"] == "item" && !binding["path"].as_array().unwrap().is_empty() {
|
||||
saw_path = true;
|
||||
assert!(!trace["parents"].as_array().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
trace["stages"].as_array().unwrap().len(),
|
||||
binding["path"].as_array().unwrap().len() + 1
|
||||
);
|
||||
let last = trace["stages"].as_array().unwrap().last().unwrap();
|
||||
assert!(last["result"]["node_id"].is_string());
|
||||
if let Some(text) = last["result"]["value"].as_str() {
|
||||
assert_eq!(
|
||||
trace["result"]["value"],
|
||||
format!(
|
||||
"{}{}{}",
|
||||
binding["prefix"].as_str().unwrap(),
|
||||
text,
|
||||
binding["suffix"].as_str().unwrap()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(saw_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_team_step_ignores_binding_and_rejects_conflicting_types() {
|
||||
let mut e = Example::new();
|
||||
let block = e
|
||||
.rules
|
||||
.iter_mut()
|
||||
.flat_map(|r| &mut r.blocks)
|
||||
.find(|b| b.kind == "escalation")
|
||||
.unwrap();
|
||||
block.steps[0].team_random = true;
|
||||
block.steps[0].user = false;
|
||||
block.steps[0].recipient =
|
||||
serde_json::from_value(json!({"kind":"variable","variable":Uuid::new_v4()})).unwrap();
|
||||
let out = e.expand();
|
||||
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
||||
assert!(
|
||||
out.document
|
||||
.escalations
|
||||
.iter()
|
||||
.any(|p| matches!(p.steps[0].recipient, Recipient::TeamRandom))
|
||||
);
|
||||
e.rules
|
||||
.iter_mut()
|
||||
.flat_map(|r| &mut r.blocks)
|
||||
.find(|b| b.kind == "escalation")
|
||||
.unwrap()
|
||||
.steps[0]
|
||||
.team_all = true;
|
||||
assert!(!e.expand().issues.is_empty());
|
||||
}
|
||||
+1270
-3
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,460 @@
|
||||
use meguri::{demo, domain::*, generation, generation_service::Draft, portable};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
fn fixture() -> (Uuid, WorkspaceDocument, Draft) {
|
||||
let workspace = Uuid::new_v4();
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
let (variables, rules) = demo::project_matrix("2026-09-28".parse().unwrap(), a, b);
|
||||
let mut doc = WorkspaceDocument::new("Portable".into());
|
||||
for (id, email) in [(a, "alice@example.com"), (b, "bob@example.com")] {
|
||||
doc.people.push(Person {
|
||||
id,
|
||||
user_id: None,
|
||||
display_name: email.into(),
|
||||
email: Some(email.into()),
|
||||
country: Some("CY".into()),
|
||||
aliases: vec![],
|
||||
});
|
||||
}
|
||||
(
|
||||
workspace,
|
||||
doc,
|
||||
Draft {
|
||||
revision: 1,
|
||||
shared_revision: Some(0),
|
||||
variables,
|
||||
rules,
|
||||
route_position: 0,
|
||||
review_id: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn export_roundtrip_preserves_ids_people_bindings_and_omitted_inputs() {
|
||||
let (ws, doc, draft) = fixture();
|
||||
let source = portable::export_v1(&doc, &draft).unwrap();
|
||||
let text = source.to_string();
|
||||
for p in &doc.people {
|
||||
assert!(!text.contains(&p.id.to_string()));
|
||||
}
|
||||
assert!(text.contains("alice@example.com"));
|
||||
let (imported, changes) = portable::import(ws, &doc, &draft, &text).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&draft).unwrap(),
|
||||
serde_json::to_value(&imported).unwrap()
|
||||
);
|
||||
assert!(
|
||||
changes
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|c| c["action"] == "unchanged")
|
||||
);
|
||||
let empty = json!({"format":"meguri/v1","variables":[],"generators":[]});
|
||||
let (retained, _) = portable::import(ws, &doc, &draft, &empty.to_string()).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(retained).unwrap(),
|
||||
serde_json::to_value(draft).unwrap()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn transfer_between_workspaces_remaps_and_repeat_import_is_stable() {
|
||||
let (_, doc, draft) = fixture();
|
||||
let source = portable::export_v1(&doc, &draft).unwrap().to_string();
|
||||
let target = Uuid::new_v4();
|
||||
let mut target_doc = doc.clone();
|
||||
for p in &mut target_doc.people {
|
||||
p.id = Uuid::new_v4();
|
||||
}
|
||||
let base = Draft {
|
||||
variables: vec![],
|
||||
rules: vec![],
|
||||
..draft.clone()
|
||||
};
|
||||
let (first, _) = portable::import(target, &target_doc, &base, &source).unwrap();
|
||||
assert_ne!(first.variables[0].id, draft.variables[0].id);
|
||||
assert_ne!(first.rules[0].blocks[0].id, draft.rules[0].blocks[0].id);
|
||||
let (second, changes) = portable::import(target, &target_doc, &first, &source).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&first).unwrap(),
|
||||
serde_json::to_value(&second).unwrap()
|
||||
);
|
||||
assert!(
|
||||
changes
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|c| c["action"] == "unchanged")
|
||||
);
|
||||
let result = generation::expand(
|
||||
&target_doc,
|
||||
target,
|
||||
&first.variables,
|
||||
&first.rules,
|
||||
0,
|
||||
&target_doc.working_interval,
|
||||
&Default::default(),
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
assert!(result.issues.is_empty(), "{:?}", result.issues);
|
||||
assert!(result.document.schedules.iter().all(|s| {
|
||||
match &s.kind {
|
||||
ScheduleKind::Rotation { participants, .. }
|
||||
| ScheduleKind::Priority { participants, .. } => participants.iter().all(|id| {
|
||||
result.document.people.iter().any(|p| p.id == *id)
|
||||
&& !doc.people.iter().any(|p| p.id == *id)
|
||||
}),
|
||||
}
|
||||
}));
|
||||
}
|
||||
#[test]
|
||||
fn malformed_ambiguous_unknown_fields_and_missing_sources_are_rejected() {
|
||||
let (ws, doc, draft) = fixture();
|
||||
assert!(portable::import(ws, &doc, &draft, "{").is_err());
|
||||
let mut v = portable::export_v1(&doc, &draft).unwrap();
|
||||
v["integration"] = json!({"token":"never-accepted"});
|
||||
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
|
||||
let mut v = portable::export_v1(&doc, &draft).unwrap();
|
||||
v["format"] = json!("meguri/v999");
|
||||
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
|
||||
let mut v = portable::export_v1(&doc, &draft).unwrap();
|
||||
let duplicate = v["variables"][0].clone();
|
||||
v["variables"].as_array_mut().unwrap().push(duplicate);
|
||||
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
|
||||
let mut v = portable::export_v1(&doc, &draft).unwrap();
|
||||
v["generators"][0]["blocks"][0]["fields"]["participants"] =
|
||||
json!({"kind":"variable","variable":"missing"});
|
||||
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
|
||||
let mut v = portable::export_v1(&doc, &draft).unwrap();
|
||||
v["generators"][0]["blocks"][0]["typo"] = json!(true);
|
||||
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn contacts_are_email_only_and_missing_export_email_blocks() {
|
||||
let (ws, mut doc, draft) = fixture();
|
||||
doc.people[0].email = None;
|
||||
assert!(portable::export_v1(&doc, &draft).is_err());
|
||||
let text = json!({"format":"meguri/v1","variables":[{"name":"new contact","node":{"kind":"person","value":"new@example.com"}}],"generators":[]});
|
||||
let (out, _) = portable::import(ws, &doc, &draft, &text.to_string()).unwrap();
|
||||
assert_eq!(out.variables.last().unwrap().node.value, "new@example.com");
|
||||
assert_eq!(doc.people.len(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn manual_reference_is_mapped_by_exact_name_and_is_not_created() {
|
||||
let (ws, mut doc, draft) = fixture();
|
||||
let id = Uuid::new_v4();
|
||||
doc.schedules.push(Schedule {
|
||||
id,
|
||||
name: "Fallback".into(),
|
||||
handover: default_handover(),
|
||||
kind: ScheduleKind::Rotation {
|
||||
participants: vec![doc.people[0].id],
|
||||
anchor: "2026-09-28".parse().unwrap(),
|
||||
shift_days: 7,
|
||||
},
|
||||
});
|
||||
let config = json!({"format":"meguri/v1","variables":[],"generators":[{"name":"With fallback","blocks":[{"id":"owners","kind":"priority","fields":{"name":{"kind":"literal","value":"Owners"},"participants":{"kind":"literal","value":["alice@example.com"]},"fallback":{"kind":"literal","value":"Fallback"}}}]}]});
|
||||
let (imported, _) = portable::import(ws, &doc, &draft, &config.to_string()).unwrap();
|
||||
assert_eq!(
|
||||
imported.rules.last().unwrap().blocks[0].fields["fallback"].value,
|
||||
json!(id)
|
||||
);
|
||||
doc.schedules.clear();
|
||||
assert!(portable::import(ws, &doc, &draft, &config.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_text_survives_export_import() {
|
||||
let (ws, doc, mut draft) = fixture();
|
||||
draft.variables.push(generation::Variable {
|
||||
id: Uuid::new_v4(),
|
||||
name: "blank".into(),
|
||||
node: generation::Node {
|
||||
id: Uuid::new_v4(),
|
||||
kind: "text".into(),
|
||||
key: String::new(),
|
||||
value: json!(""),
|
||||
entries: vec![],
|
||||
},
|
||||
});
|
||||
let text = portable::export_v1(&doc, &draft).unwrap().to_string();
|
||||
let (out, _) = portable::import(ws, &doc, &draft, &text).unwrap();
|
||||
assert_eq!(out.variables.last().unwrap().node.value, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complex_lookup_conditions_and_nested_paths_roundtrip() {
|
||||
let (ws, doc, mut draft) = fixture();
|
||||
for create in [demo::regional_platform, demo::service_tiers] {
|
||||
let (variables, rules) = create(
|
||||
"2026-09-28".parse().unwrap(),
|
||||
doc.people[0].id,
|
||||
doc.people[1].id,
|
||||
);
|
||||
draft.variables = variables;
|
||||
draft.rules = rules;
|
||||
let source = portable::export_v1(&doc, &draft).unwrap();
|
||||
let (out, _) = portable::import(ws, &doc, &draft, &source.to_string()).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(out).unwrap(),
|
||||
serde_json::to_value(&draft).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn person_predicate_constants_transfer_by_email() {
|
||||
let (ws, doc, mut draft) = fixture();
|
||||
draft.rules[0].when = Some(generation::Predicate {
|
||||
left: generation::Binding {
|
||||
kind: "literal".into(),
|
||||
value: json!(doc.people[0].id),
|
||||
..Default::default()
|
||||
},
|
||||
operation: "equals".into(),
|
||||
right: generation::Binding {
|
||||
kind: "literal".into(),
|
||||
value: json!(doc.people[1].id),
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
let source = portable::export_v1(&doc, &draft).unwrap();
|
||||
assert_eq!(
|
||||
source["generators"][0]["when"]["left"]["value"]["$person"],
|
||||
"alice@example.com"
|
||||
);
|
||||
let (out, _) = portable::import(ws, &doc, &draft, &source.to_string()).unwrap();
|
||||
assert_eq!(
|
||||
out.rules[0].when.as_ref().unwrap().left.value,
|
||||
json!(doc.people[0].id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_sample_preserves_resources_without_uuids_or_cached_binding_values() {
|
||||
let (ws, doc, mut base) = fixture();
|
||||
base.variables.clear();
|
||||
base.rules.clear();
|
||||
let source = include_str!("fixtures/portable-v1.json");
|
||||
let (original, _) = portable::import(ws, &doc, &base, source).unwrap();
|
||||
let compact = portable::export(&doc, &original).unwrap();
|
||||
let text = serde_json::to_string_pretty(&compact).unwrap();
|
||||
assert!(
|
||||
text.len() * 3 < source.len(),
|
||||
"{} vs {}",
|
||||
text.len(),
|
||||
source.len()
|
||||
);
|
||||
assert!(!text.contains("14e49"));
|
||||
for r in &original.rules {
|
||||
for b in &r.blocks {
|
||||
assert!(!text.contains(&b.id.to_string()));
|
||||
}
|
||||
}
|
||||
assert!(!text.contains("New First available"));
|
||||
assert!(!text.contains("team_all"));
|
||||
assert!(!text.contains("\"kind\""));
|
||||
let (imported, _) = portable::import(ws, &doc, &original, &text).unwrap();
|
||||
let now = chrono::Utc::now();
|
||||
let expand = |draft: &Draft| {
|
||||
generation::expand(
|
||||
&doc,
|
||||
ws,
|
||||
&draft.variables,
|
||||
&draft.rules,
|
||||
0,
|
||||
&doc.working_interval,
|
||||
&Default::default(),
|
||||
now,
|
||||
)
|
||||
};
|
||||
let before = expand(&original);
|
||||
let after = expand(&imported);
|
||||
assert!(before.issues.is_empty(), "{:?}", before.issues);
|
||||
assert!(after.issues.is_empty(), "{:?}", after.issues);
|
||||
for (a, b) in [
|
||||
(
|
||||
json!(before.document.schedules),
|
||||
json!(after.document.schedules),
|
||||
),
|
||||
(
|
||||
json!(before.document.escalations),
|
||||
json!(after.document.escalations),
|
||||
),
|
||||
(json!(before.document.routes), json!(after.document.routes)),
|
||||
] {
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
let (again, _) = portable::import(ws, &doc, &imported, &text).unwrap();
|
||||
assert_eq!(json!(imported), json!(again));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_types_order_escapes_and_output_keys_survive_reordering() {
|
||||
let (ws, doc, base) = fixture();
|
||||
let value = json!({"format":"meguri/v2","variables":{"typed":{"$object":[["z",{"$date":"2026-10-01"}],["a",{"$time":"09:00"}],["duration",{"$duration":30}],["text",{"$text":"literal@example.com"}],["$item","plain"]]}},"generators":[]});
|
||||
let (draft, _) = portable::import(ws, &doc, &base, &value.to_string()).unwrap();
|
||||
let out = portable::export(&doc, &draft).unwrap();
|
||||
assert_eq!(out["variables"]["typed"], value["variables"]["typed"]);
|
||||
let mut compact = portable::export(&doc, &base).unwrap();
|
||||
let (first, _) = portable::import(ws, &doc, &base, &compact.to_string()).unwrap();
|
||||
compact["generators"][0]["outputs"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.reverse();
|
||||
let (reordered, _) = portable::import(ws, &doc, &first, &compact.to_string()).unwrap();
|
||||
let mut a: Vec<_> = first.rules[0].blocks.iter().map(|b| b.id).collect();
|
||||
let mut b: Vec<_> = reordered.rules[0].blocks.iter().map(|b| b.id).collect();
|
||||
a.sort();
|
||||
b.sort();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_named_list_reordering_and_field_edit_preserve_entry_ids() {
|
||||
let (ws, doc, base) = fixture();
|
||||
let mut v = json!({"format":"meguri/v2","variables":{"projects":[{"name":"a","people":["alice@example.com"]},{"name":"b","people":["bob@example.com"]}]},"generators":[]});
|
||||
let (first, _) = portable::import(ws, &doc, &base, &v.to_string()).unwrap();
|
||||
let old = first
|
||||
.variables
|
||||
.iter()
|
||||
.find(|v| v.name == "projects")
|
||||
.unwrap();
|
||||
v["variables"]["projects"].as_array_mut().unwrap().reverse();
|
||||
v["variables"]["projects"][0]["people"] = json!(["carol@example.com"]);
|
||||
let (second, _) = portable::import(ws, &doc, &first, &v.to_string()).unwrap();
|
||||
let new = second
|
||||
.variables
|
||||
.iter()
|
||||
.find(|v| v.name == "projects")
|
||||
.unwrap();
|
||||
assert_eq!(old.node.entries[0].id, new.node.entries[1].id);
|
||||
assert_eq!(old.node.entries[1].id, new.node.entries[0].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_cross_workspace_and_complex_bindings_keep_effective_configuration() {
|
||||
let (ws, doc, mut draft) = fixture();
|
||||
for create in [demo::regional_platform, demo::service_tiers] {
|
||||
(draft.variables, draft.rules) = create(
|
||||
"2026-09-28".parse().unwrap(),
|
||||
doc.people[0].id,
|
||||
doc.people[1].id,
|
||||
);
|
||||
let value = portable::export(&doc, &draft).unwrap();
|
||||
let text = portable::format_config(&value);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&text).unwrap(),
|
||||
value
|
||||
);
|
||||
let (same, _) = portable::import(ws, &doc, &draft, &text).unwrap();
|
||||
let now = chrono::Utc::now();
|
||||
let expand = |d: &Draft, workspace| {
|
||||
generation::expand(
|
||||
&doc,
|
||||
workspace,
|
||||
&d.variables,
|
||||
&d.rules,
|
||||
0,
|
||||
&doc.working_interval,
|
||||
&Default::default(),
|
||||
now,
|
||||
)
|
||||
};
|
||||
let before = expand(&draft, ws);
|
||||
let after = expand(&same, ws);
|
||||
assert!(after.issues.is_empty(), "{:?}", after.issues);
|
||||
assert_eq!(
|
||||
json!(before.document.schedules),
|
||||
json!(after.document.schedules)
|
||||
);
|
||||
assert_eq!(
|
||||
json!(before.document.escalations),
|
||||
json!(after.document.escalations)
|
||||
);
|
||||
assert_eq!(json!(before.document.routes), json!(after.document.routes));
|
||||
let target = Uuid::new_v4();
|
||||
let empty = Draft {
|
||||
variables: vec![],
|
||||
rules: vec![],
|
||||
..draft.clone()
|
||||
};
|
||||
let (first, _) = portable::import(target, &doc, &empty, &text).unwrap();
|
||||
let (again, _) = portable::import(target, &doc, &first, &text).unwrap();
|
||||
assert_eq!(json!(first), json!(again));
|
||||
assert!(expand(&first, target).issues.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_example_compiles_to_all_four_output_types() {
|
||||
let catalog: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../static/locales/messages.json")).unwrap();
|
||||
let (ws, doc, mut base) = fixture();
|
||||
base.variables.clear();
|
||||
base.rules.clear();
|
||||
for language in ["en", "ru", "fr"] {
|
||||
let source = catalog["transfer_guide_example"][language]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
let (draft, _) = portable::import(ws, &doc, &base, source).unwrap();
|
||||
let expanded = generation::expand(
|
||||
&doc,
|
||||
ws,
|
||||
&draft.variables,
|
||||
&draft.rules,
|
||||
0,
|
||||
&doc.working_interval,
|
||||
&Default::default(),
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
assert!(
|
||||
expanded.issues.is_empty(),
|
||||
"{language}: {:?}",
|
||||
expanded.issues
|
||||
);
|
||||
assert_eq!(expanded.document.schedules.len(), 2);
|
||||
assert_eq!(expanded.document.escalations.len(), 1);
|
||||
assert_eq!(expanded.document.routes.len(), 1);
|
||||
assert!(matches!(
|
||||
expanded.document.escalations[0].steps[1].recipient,
|
||||
Recipient::TeamAll
|
||||
));
|
||||
assert_eq!(expanded.document.escalations[0].steps[1].delay_minutes, 30);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_team_import_roundtrips_and_rejects_unknown_modes() {
|
||||
let (ws, doc, mut base) = fixture();
|
||||
base.variables.clear();
|
||||
base.rules.clear();
|
||||
let mut source = json!({"format":"meguri/v2","variables":{},"generators":[{"name":"Random","outputs":[{"key":"policy","type":"escalation","name":"Random","steps":[{"after":30,"team":"random"}]}]}]});
|
||||
let (draft, _) = portable::import(ws, &doc, &base, &source.to_string()).unwrap();
|
||||
let out = generation::expand(
|
||||
&doc,
|
||||
ws,
|
||||
&draft.variables,
|
||||
&draft.rules,
|
||||
0,
|
||||
&doc.working_interval,
|
||||
&Default::default(),
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
||||
assert!(matches!(
|
||||
out.document.escalations[0].steps[0].recipient,
|
||||
Recipient::TeamRandom
|
||||
));
|
||||
assert_eq!(out.document.escalations[0].steps[0].delay_minutes, 30);
|
||||
for export in [
|
||||
portable::export(&doc, &draft).unwrap(),
|
||||
portable::export_v1(&doc, &draft).unwrap(),
|
||||
] {
|
||||
let (roundtrip, _) = portable::import(ws, &doc, &draft, &export.to_string()).unwrap();
|
||||
assert!(roundtrip.rules[0].blocks[0].steps[0].team_random);
|
||||
assert!(!roundtrip.rules[0].blocks[0].steps[0].team_all);
|
||||
}
|
||||
source["generators"][0]["outputs"][0]["steps"][0]["team"] = json!("sequential");
|
||||
assert!(portable::import(ws, &doc, &base, &source.to_string()).is_err());
|
||||
}
|
||||
@@ -15,12 +15,26 @@ const baseUrl = () => process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
|
||||
exports.baseUrl = baseUrl;
|
||||
|
||||
// The demo generates one password per server start and publishes it to the login screen.
|
||||
// Generator drafts are shared and persisted per workspace, so a suite that edited a demo
|
||||
// workspace without applying leaves them behind; discard them when a suite starts.
|
||||
let password;
|
||||
exports.demoPassword = () => password ||= fetch(baseUrl() + '/api/session').then(r => r.json()).then(session => {
|
||||
exports.demoPassword = () => password ||= fetch(baseUrl() + '/api/session').then(r => r.json()).then(async session => {
|
||||
if (!session.demo_lifecycle?.password) throw new Error('Browser tests require a disposable demo instance');
|
||||
await discardDemoDrafts(session.demo_lifecycle.password);
|
||||
return session.demo_lifecycle.password;
|
||||
});
|
||||
|
||||
async function discardDemoDrafts(secret) {
|
||||
const lead = await exports.apiLogin('alice', secret);
|
||||
for (const { id } of (await lead.get('/api/workspaces')).workspaces) {
|
||||
const detail = await lead.get('/api/workspaces/' + id);
|
||||
const draft = detail.generation_draft;
|
||||
if (!draft?.payload) continue;
|
||||
const saved = detail.workspace.document.generation || {};
|
||||
await lead.put(`/api/workspaces/${id}/generation-draft`, { revision: draft.revision, payload: { revision: detail.workspace.revision, variables: saved.variables || [], rules: saved.rules || [], route_position: saved.route_position || 0 } });
|
||||
}
|
||||
}
|
||||
|
||||
exports.signIn = async (page, username, secret) => {
|
||||
await page.goto(baseUrl());
|
||||
await page.getByLabel('Username', { exact: true }).fill(username);
|
||||
@@ -54,21 +68,21 @@ exports.adminApi = () => exports.apiLogin('admin');
|
||||
// Mutating suites each own one empty sandbox workspace ("Sandbox N", demo started with
|
||||
// MEGURI_DEMO_SANDBOXES) whose groups are sandbox-N-leads (administrators) and sandbox-N-staff (employees).
|
||||
// Nobody is in a sandbox except accounts created here, so suites do not depend on each other or on order.
|
||||
exports.createUser = async (admin, { sandbox, role = 'admin', prefix, display }) => {
|
||||
exports.createUser = async (admin, { sandbox, role = 'admin', prefix, display, email }) => {
|
||||
const overview = await admin.get('/api/admin/overview');
|
||||
const target = overview.workspaces.find(w => w.name === `Sandbox ${sandbox}` && !w.disabled_at);
|
||||
if (!target) throw new Error(`Start the demo with MEGURI_DEMO_SANDBOXES>=${sandbox}: Sandbox ${sandbox} not found`);
|
||||
const groups = role === 'admin' ? target.admin_groups : target.member_groups;
|
||||
const username = `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.toLowerCase();
|
||||
const secret = 'local-browser-test-password';
|
||||
await admin.post('/api/admin/users', { username, display_name: display, password: secret, groups });
|
||||
return { username, password: secret, display, workspace: target };
|
||||
await admin.post('/api/admin/users', { username, display_name: display, password: secret, groups, ...(email ? { email } : {}) });
|
||||
return { username, password: secret, display, email, workspace: target };
|
||||
};
|
||||
|
||||
// An administrator of exactly one sandbox, plus any employees (display names) created beside them.
|
||||
exports.isolatedUser = async ({ sandbox, prefix, display, employees = [] }) => {
|
||||
exports.isolatedUser = async ({ sandbox, prefix, display, email, employees = [] }) => {
|
||||
const admin = await exports.adminApi();
|
||||
const user = await exports.createUser(admin, { sandbox, role: 'admin', prefix, display });
|
||||
const user = await exports.createUser(admin, { sandbox, role: 'admin', prefix, display, email });
|
||||
const staff = [];
|
||||
for (const name of employees) staff.push(await exports.createUser(admin, { sandbox, role: 'member', prefix: name.split(' ')[0], display: name }));
|
||||
return { admin, ...user, staff };
|
||||
|
||||
+180
-20
@@ -13,10 +13,17 @@ use tokio::{
|
||||
pub struct State {
|
||||
pub objects: BTreeMap<String, Value>,
|
||||
pub writes: usize,
|
||||
pub rotation_creates: usize,
|
||||
pub rotation_patches: usize,
|
||||
pub fail_after_rotation_patch: bool,
|
||||
pub fail_after_schedule_patch: bool,
|
||||
pub fail_once: Option<String>,
|
||||
pub external_override: bool,
|
||||
pub users: Vec<Value>,
|
||||
pub lookups: usize,
|
||||
pub fail_after_override_create: bool,
|
||||
pub fail_after_resource_create: bool,
|
||||
pub override_not_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
pub struct Server {
|
||||
pub base: String,
|
||||
@@ -72,13 +79,14 @@ impl Server {
|
||||
assert!(head.to_lowercase().contains("authorization: basic "));
|
||||
let mut first = head.lines().next().unwrap().split_whitespace();
|
||||
let method = first.next().unwrap();
|
||||
let path = first.next().unwrap().split('?').next().unwrap();
|
||||
let uri = first.next().unwrap();
|
||||
let body = if length > 0 {
|
||||
serde_json::from_slice(&buffer[head_end..head_end + length]).unwrap()
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
let (code, result) = handle(&mut shared.lock().unwrap(), method, path, body);
|
||||
let (code, result) =
|
||||
handle_page(&mut shared.lock().unwrap(), method, uri, body);
|
||||
let body = result.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 {code} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
@@ -91,6 +99,42 @@ impl Server {
|
||||
Self { base, state, task }
|
||||
}
|
||||
}
|
||||
fn handle_page(s: &mut State, method: &str, uri: &str, body: Value) -> (u16, Value) {
|
||||
let url = reqwest::Url::parse(&format!("http://localhost{uri}")).unwrap();
|
||||
let query: BTreeMap<_, _> = url.query_pairs().into_owned().collect();
|
||||
let size = query.get("size").and_then(|s| s.parse::<usize>().ok());
|
||||
if size.is_some_and(|size| size > 50) {
|
||||
return (
|
||||
422,
|
||||
json!({"errors":[{"title":"Field: size, error: must be less than or equal to 50"}]}),
|
||||
);
|
||||
}
|
||||
let (code, mut result) = handle(s, method, url.path(), body);
|
||||
if method == "GET"
|
||||
&& let Some(size) = size
|
||||
&& let Some(values) = result.get_mut("values").and_then(Value::as_array_mut)
|
||||
{
|
||||
let offset = query
|
||||
.get("offset")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let more = offset + size < values.len();
|
||||
*values = values.iter().skip(offset).take(size).cloned().collect();
|
||||
if more {
|
||||
result["links"] =
|
||||
json!({"next":format!("{}?offset={}&size={size}",url.path(),offset+size)});
|
||||
}
|
||||
}
|
||||
(code, result)
|
||||
}
|
||||
fn read_response(path: &str, value: &Value) -> Value {
|
||||
let mut response = value.clone();
|
||||
if path.contains("/escalations/") && response["repeat"]["waitInterval"] == 0 {
|
||||
response.as_object_mut().unwrap().remove("repeat");
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Value) {
|
||||
if method == "POST" && s.fail_once.as_ref().is_some_and(|p| path.ends_with(p)) {
|
||||
s.fail_once = None;
|
||||
@@ -102,19 +146,36 @@ fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Val
|
||||
return (200, json!(s.users));
|
||||
}
|
||||
if path.ends_with("/overrides") {
|
||||
return (
|
||||
200,
|
||||
json!({"values":if s.external_override{vec![json!({"alias":"external"})]}else{vec![]}}),
|
||||
);
|
||||
if !s
|
||||
.objects
|
||||
.contains_key(path.strip_suffix("/overrides").unwrap())
|
||||
{
|
||||
return (404, json!({}));
|
||||
}
|
||||
let mut values: Vec<_> = s
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
key.rsplit_once('/')
|
||||
.is_some_and(|(parent, _)| parent == path)
|
||||
})
|
||||
.map(|(_, value)| value.clone())
|
||||
.collect();
|
||||
if s.external_override {
|
||||
values.push(json!({"alias":"external"}));
|
||||
}
|
||||
return (200, json!({"values":values}));
|
||||
}
|
||||
if let Some(parent) = path.strip_suffix("/rotations") {
|
||||
return (
|
||||
200,
|
||||
json!({"values":s.objects.get(parent).unwrap()["rotations"]}),
|
||||
);
|
||||
let Some(object) = s.objects.get(parent) else {
|
||||
return (404, json!({}));
|
||||
};
|
||||
let mut rotations = object["rotations"].as_array().unwrap().clone();
|
||||
rotations.reverse();
|
||||
return (200, json!({"values":rotations}));
|
||||
}
|
||||
if let Some(value) = s.objects.get(path) {
|
||||
return (200, value.clone());
|
||||
return (200, read_response(path, value));
|
||||
}
|
||||
if path.ends_with("/schedules")
|
||||
|| path.ends_with("/escalations")
|
||||
@@ -122,7 +183,7 @@ fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Val
|
||||
{
|
||||
return (
|
||||
200,
|
||||
json!({"values":s.objects.iter().filter(|(k,_)|k.rsplit_once('/').is_some_and(|(p,_)|p==path)).map(|(_,v)|v.clone()).collect::<Vec<_>>()}),
|
||||
json!({"values":s.objects.iter().filter(|(k,_)|k.rsplit_once('/').is_some_and(|(p,_)|p==path)).map(|(k,v)|read_response(k,v)).collect::<Vec<_>>()}),
|
||||
);
|
||||
}
|
||||
return (404, json!({}));
|
||||
@@ -133,6 +194,8 @@ fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Val
|
||||
s.external_override = false;
|
||||
} else {
|
||||
s.objects.remove(path);
|
||||
s.objects
|
||||
.retain(|key, _| !key.starts_with(&format!("{path}/")));
|
||||
}
|
||||
return (200, json!({"result":"deleted"}));
|
||||
}
|
||||
@@ -144,26 +207,123 @@ fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Val
|
||||
assert!((1..=20).contains(&body["repeat"]["count"].as_u64().unwrap()));
|
||||
assert!(body["rules"].is_array());
|
||||
}
|
||||
if path.contains("schedules") {
|
||||
for rotation in body["rotations"].as_array().unwrap() {
|
||||
assert_eq!(rotation["participants"].as_array().unwrap().len(), 1);
|
||||
assert!(
|
||||
rotation["endDate"].as_str().unwrap() > rotation["startDate"].as_str().unwrap()
|
||||
if path.contains("/overrides") {
|
||||
assert!(body["responder"]["type"] == "user" || body["responder"]["type"] == "noone");
|
||||
for field in ["startDate", "endDate"] {
|
||||
let time = body[field]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<chrono::DateTime<chrono::Utc>>()
|
||||
.unwrap();
|
||||
body[field] =
|
||||
json!(chrono::DateTime::from_timestamp_millis(time.timestamp_millis()).unwrap());
|
||||
}
|
||||
if method == "POST"
|
||||
&& s.override_not_before.is_some_and(|now| {
|
||||
body["startDate"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<chrono::DateTime<chrono::Utc>>()
|
||||
.unwrap()
|
||||
< now
|
||||
})
|
||||
{
|
||||
return (
|
||||
400,
|
||||
json!({"error":"Start time cannot be earlier than the current time"}),
|
||||
);
|
||||
}
|
||||
s.writes += 1;
|
||||
if method == "POST" {
|
||||
let alias = uuid::Uuid::new_v4().to_string();
|
||||
body["alias"] = json!(alias);
|
||||
s.objects.insert(format!("{path}/{alias}"), body.clone());
|
||||
if s.fail_after_override_create {
|
||||
s.fail_after_override_create = false;
|
||||
return (500, json!({"error":"Lost response after create"}));
|
||||
}
|
||||
} else {
|
||||
assert_eq!(method, "PUT");
|
||||
body["alias"] = json!(path.rsplit('/').next().unwrap());
|
||||
s.objects.insert(path.into(), body.clone());
|
||||
}
|
||||
return (201, body);
|
||||
}
|
||||
if let Some((parent, id)) = path.split_once("/rotations/") {
|
||||
assert_eq!(method, "PATCH");
|
||||
let Some(rotation) = s
|
||||
.objects
|
||||
.get_mut(parent)
|
||||
.and_then(|v| v["rotations"].as_array_mut())
|
||||
.and_then(|r| r.iter_mut().find(|r| r["id"] == id))
|
||||
else {
|
||||
return (404, json!({}));
|
||||
};
|
||||
body["id"] = json!(id);
|
||||
*rotation = body.clone();
|
||||
s.writes += 1;
|
||||
s.rotation_patches += 1;
|
||||
if s.fail_after_rotation_patch {
|
||||
s.fail_after_rotation_patch = false;
|
||||
return (500, json!({"error":"Lost rotation update response"}));
|
||||
}
|
||||
return (200, body);
|
||||
}
|
||||
if path.contains("schedules") && body.get("rotations").is_some() {
|
||||
let zone = body["timezone"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<chrono_tz::Tz>()
|
||||
.unwrap();
|
||||
for rotation in body["rotations"].as_array_mut().unwrap() {
|
||||
rotation["id"] = json!(uuid::Uuid::new_v4().to_string());
|
||||
s.rotation_creates += 1;
|
||||
assert!(!rotation["participants"].as_array().unwrap().is_empty());
|
||||
if let Some(end) = rotation["endDate"].as_str() {
|
||||
assert!(end > rotation["startDate"].as_str().unwrap());
|
||||
} else {
|
||||
assert_eq!(rotation["participants"][0]["type"], "noone");
|
||||
}
|
||||
// Anonymized live behavior: native rotation minutes snap to a half-hour;
|
||||
// override endpoints above retain arbitrary minutes and milliseconds.
|
||||
for field in ["startDate", "endDate"] {
|
||||
if let Some(raw) = rotation[field].as_str() {
|
||||
use chrono::Timelike;
|
||||
let time = raw.parse::<chrono::DateTime<chrono::Utc>>().unwrap();
|
||||
let minute = i64::from(time.with_timezone(&zone).minute());
|
||||
let rounded =
|
||||
time + chrono::Duration::minutes(((minute + 15) / 30) * 30 - minute);
|
||||
rotation[field] = json!(
|
||||
chrono::DateTime::from_timestamp_millis(rounded.timestamp_millis())
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.writes += 1;
|
||||
if method == "POST" {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
body["id"] = json!(id);
|
||||
s.objects.insert(format!("{path}/{id}"), body.clone());
|
||||
if s.fail_after_resource_create {
|
||||
s.fail_after_resource_create = false;
|
||||
return (500, json!({"error":"Lost resource create response"}));
|
||||
}
|
||||
return (201, body);
|
||||
}
|
||||
assert_eq!(method, "PATCH");
|
||||
if let Some(previous) = s.objects.get(path) {
|
||||
body["id"] = previous["id"].clone();
|
||||
s.objects.insert(path.into(), body.clone());
|
||||
(200, body)
|
||||
let mut merged = previous.clone();
|
||||
for (key, value) in body.as_object().unwrap() {
|
||||
merged[key] = value.clone();
|
||||
}
|
||||
s.objects.insert(path.into(), merged.clone());
|
||||
if path.starts_with("/schedules/") && s.fail_after_schedule_patch {
|
||||
s.fail_after_schedule_patch = false;
|
||||
return (500, json!({"error":"Lost schedule metadata response"}));
|
||||
}
|
||||
(200, merged)
|
||||
} else {
|
||||
(404, json!({}))
|
||||
}
|
||||
|
||||
+32
-2
@@ -6,7 +6,7 @@ use meguri::{
|
||||
admin::{self, NewUser},
|
||||
domain::*,
|
||||
identity,
|
||||
models::UserRow,
|
||||
models::{HistoryRow, UserRow},
|
||||
service,
|
||||
store::{Store, WorkspaceRecord},
|
||||
};
|
||||
@@ -59,7 +59,7 @@ pub async fn account(db: &Store, config: &Config, name: &str, groups: &[String])
|
||||
username: format!("test-{}", Uuid::new_v4().simple()),
|
||||
display_name: name.into(),
|
||||
password: "test-only-password".into(),
|
||||
email: None,
|
||||
email: Some(format!("{}@example.com", Uuid::new_v4())),
|
||||
groups: groups.to_vec(),
|
||||
country: None,
|
||||
},
|
||||
@@ -123,6 +123,13 @@ impl Fixture {
|
||||
config,
|
||||
}
|
||||
}
|
||||
/// Makes `user` a workspace administrator of the fixture workspace through the group mapping.
|
||||
pub async fn make_admin(&self, user: &User) {
|
||||
let groups = self.config.workspaces[0].admin_groups.clone();
|
||||
identity::apply_access(&self.db, &self.config, user.id, Some(groups))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
/// A second workspace whose administrator group is granted to `admin` only.
|
||||
pub async fn extra_workspace(&self, name: &str, admin: &User) -> WorkspaceRecord {
|
||||
let extra = unique_workspace(name);
|
||||
@@ -175,6 +182,29 @@ impl Fixture {
|
||||
}
|
||||
}
|
||||
pub async fn prioritize_publication(&self) {
|
||||
let revision = self.db.workspace(self.team.id).await.unwrap().revision;
|
||||
self.db
|
||||
.retry(self.team.id, self.people[0].id, revision)
|
||||
.await
|
||||
.unwrap();
|
||||
// PostgreSQL and the worker can run on different host clocks. Publish only
|
||||
// once this test's latest committed revision has actually become effective.
|
||||
let workspace = self.team.id.to_string();
|
||||
let effective = query!(HistoryRow, $workspace_id == workspace)
|
||||
.all(&self.db.db)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|row| row.effective_at)
|
||||
.max()
|
||||
.unwrap();
|
||||
if let Ok(delay) = (effective - Utc::now()).to_std() {
|
||||
assert!(
|
||||
delay < std::time::Duration::from_secs(2),
|
||||
"Database clock is too far ahead of the test worker"
|
||||
);
|
||||
tokio::time::sleep(delay + std::time::Duration::from_millis(1)).await;
|
||||
}
|
||||
// The worker takes a bounded batch. Repeated runs leave other test tenants
|
||||
// in this disposable database, so explicitly put this test's job first.
|
||||
self.db
|
||||
|
||||
Reference in new issue
Block a user