NEXT
Checks / test (push) Failing after 2m25s

This commit is contained in:
Ultradesu committed 2026-10-07 15:30:31 +01:00
1 parent e37d583179
commit 5260c2828b
65 files changed
+8469 -380

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+9
View File
@@ -16,3 +16,12 @@ RUST_LOG=meguri=info,cot=warn
# Tenant-specific JSM deployment secrets. Replace UUID_WITHOUT_DASHES with uppercase tenant ID.
# MEGURI_JSM_UUID_WITHOUT_DASHES_EMAIL=integration@example.com
# MEGURI_JSM_UUID_WITHOUT_DASHES_TOKEN=from-your-secret-manager
# Explicit one-time site administrator grant to an existing local username.
# MEGURI_BOOTSTRAP_ADMIN=admin
# OIDC settings UI is draft-only; the authentication adapter is not active yet.
# MEGURI_OIDC_CLIENT_SECRET=provided-by-deployment-secret-store
# One-time migration of existing login accounts: required only for missing or
# ambiguous emails. Existing unambiguous linked Person emails are recovered.
# MEGURI_ACCOUNT_EMAILS='{"admin":"admin@example.com"}'
+71 -3
View File
@@ -10,7 +10,7 @@ Users, sessions, workspaces, memberships, audit, history, provider checkpoints,
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, then writes membership, history, audit, and the durable sync request atomically. 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 initial migration creates frozen Cot models; the second adds scoped foreign keys, constraints, and indexes in one atomic PostgreSQL block. A JSM destination can have only one local workspace binding. Never edit applied migrations.
@@ -34,9 +34,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.
@@ -111,10 +117,72 @@ 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
`site` owns global authorization, section-versioned settings, account access flags and an installation audit stream. Site mutations compare a global control revision and the actor role inside a single PostgreSQL statement; workspace metadata also compares its own revision. Site permissions never bypass workspace membership. Account suspension does not mutate historical scheduling state. Authentication configuration is stored as a typed section, but is intentionally not connected to login enforcement yet. A future identity adapter will consume the section after secure OIDC execution is implemented.
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 stores required unique normalized email in the user row. The domain User embeds its canonical Person (same ID); workspace Person records are snapshots, not independently editable account 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. Password signup never claims planning contacts merely by email.
The preparatory contact/account migration runs before account email enforcement on legacy databases. An explicit deployment email mapping authorizes association only in workspaces where the account is already a member. It rewrites current scheduling references, person-valued generator inputs, shared drafts and explicit provider identity keys, retaining resource IDs and old history/publication checkpoints. Missing/ambiguous mappings, conflicting provider identities or collapsed invalid schedules/replacements block the repair before writes. Already email-migrated installations are skipped. Regression coverage includes migration ordering, retry after failure, generator drafts and unchanged access/history.
+82
View File
@@ -22,6 +22,12 @@ Browser checks: sign in; inspect the calendar; create a request as one user and
## 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 seeded local app with signup enabled. By default the tests use installed Chrome; set `MEGURI_BROWSER_CHANNEL=chromium` for downloaded Chromium. The original suite creates its own workspace. `tests/browser-calendars.cjs` creates an isolated test account and checks email participants/absences, country defaults, reactive CSV import, workspace working hours, policy flows, and display-timezone isolation. `tests/browser-configuration.cjs` uses another isolated account to check removal dependencies/redirects, native routing drag/drop, saved positions, insertion/removal renumbering, and mobile controls. Screenshots go under ignored `test-results/`.
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.
@@ -62,3 +68,79 @@ Migration 8 replaces the former tenant/team tables with workspaces and workspace
The default Engineering example stores projects as one list of dictionaries: each entry has `name` and an ordered `responsible` person list. The Project iteration reads both fields directly from its current entry; no parallel responsible lookup variable is needed. The independent `on_call` list still defines the shared weekly rotation.
Generation map uses locally vendored Cytoscape.js 3.33.1 (`static/vendor/cytoscape.js`, adjacent MIT license), with no CDN requests or Node build requirement. Its preset lane layout and inspector live in `static/generation-map.js` / `.html`; engine trace data is returned by the authorized generation-preview endpoint. Run `node tests/browser-generation-map.cjs` against the disposable demo for nested iterations, filtering, diagnostics, read-only behavior, canvas interactions and mobile layout.
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.
Site administration bootstrap: set `MEGURI_BOOTSTRAP_ADMIN=admin` for the existing local username and restart once, then remove it. It never creates the account. Authentication settings are a draft-only form: OIDC execution and password-disable enforcement are not implemented yet. The client-secret field stores an environment-variable name, not its value. No credentials are written to the database or returned to the browser. Users, Workspaces, Authentication and Administration history are separate administration sections; future typed sections extend this structure.
See [site administration](site-administration.md) for bootstrap, role boundaries and extending settings sections.
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.
Account/person migration requires real email addresses for existing login accounts. Recover unambiguous email from linked workspace Person snapshots; resolve missing or conflicting mappings explicitly rather than inventing addresses. New signup forms and test fixtures supply email. OIDC remains deferred.
For a pre-migration database, start once with an explicit mapping, for example
`MEGURI_ACCOUNT_EMAILS='{"admin":"admin@example.com"}'` alongside the usual
launch environment. The map is keyed by existing username. Missing or ambiguous
linked emails are listed in the migration error; unknown usernames, duplicate
emails and unapproved contact collisions abort the migration. An explicit mapping
permits linking a matching contact to an account already in that workspace. Supply every missing
address together and retry. Remove the variable after successful migration.
No placeholder addresses are assigned. A failed migration preserves the schema,
workspace revisions and history. Successful migration adds canonical current
member snapshots and a new audit/history revision without authorizing publication.
Account email is displayed read-only for now; verified identity/email changes and
contact association belong to the later OIDC work.
The preparatory contact/account migration runs before account email enforcement on legacy databases. An explicit deployment email mapping authorizes association only in workspaces where the account is already a member. It rewrites current scheduling references, person-valued generator inputs, shared drafts and explicit provider identity keys, retaining resource IDs and old history/publication checkpoints. Missing/ambiguous mappings, conflicting provider identities or collapsed invalid schedules/replacements block the repair before writes. Already email-migrated installations are skipped. Regression coverage includes migration ordering, retry after failure, generator drafts and unchanged access/history.
+10
View File
@@ -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.
+49
View File
@@ -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.
+102
View File
@@ -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.
+74 -4
View File
@@ -9,17 +9,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.
@@ -67,7 +73,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 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; an email is never proof of identity. Only authenticated, authorized users can approve requests; an email-only recipient requires a lead's explicit forced application until a verified account-link workflow exists. 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 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; an email is never proof of identity. Only authenticated, authorized users can approve requests; email-only contacts are planning entities and require no approval of their own. Future verified OIDC linking must preserve their scheduling identity, but is not implemented by this workflow change. 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.
@@ -170,10 +176,74 @@ 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.
## Site administration and identity
Site administrators are an explicit installation-wide role, separate from workspace ownership. They manage login display names, suspension, administrator grants, workspace names and owner access through audited site APIs. Ordinary workspace APIs still require membership; administration never silently joins every workspace. Suspension immediately prevents login/session access but does not remove scheduling contacts or history. The last active site administrator cannot be demoted or disabled. Bootstrap names an existing local username through deployment configuration; it never promotes the first visitor.
Site settings have independently versioned sections. The Authentication section is a saved configuration draft only: OIDC enabled, password enabled, issuer URL, client ID, deployment-secret environment variable name, login button label and new-account policy. Saving this form does not change current password login, signup or sessions. The UI explicitly communicates this limitation; OIDC execution and login-page integration are future work. No client-secret value is accepted or returned. Future OIDC login identities must bind issuer/subject. A verified email may associate an authenticated account with a planning contact while preserving scheduling history; an unverified email must never grant access or merge login accounts.
## Account-owned person
Every login account owns a Person with the same stable ID and a mandatory normalized, installation-unique email. 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; linked account email is authoritative and cannot be edited through a workspace contact form. New password signups require email but do not claim existing contacts or grant access by matching an unverified address. Future OIDC will associate a verified identity/email with existing planning references explicitly, preserving history and applying the access policy. OIDC execution remains deferred.
The preparatory contact/account migration runs before account email enforcement on legacy databases. An explicit deployment email mapping authorizes association only in workspaces where the account is already a member. It rewrites current scheduling references, person-valued generator inputs, shared drafts and explicit provider identity keys, retaining resource IDs and old history/publication checkpoints. Missing/ambiguous mappings, conflicting provider identities or collapsed invalid schedules/replacements block the repair before writes. Already email-migrated installations are skipped. Regression coverage includes migration ordering, retry after failure, generator drafts and unchanged access/history.
+34
View File
@@ -0,0 +1,34 @@
# Site administration
The site administrator controls the installation, independently of workspace owners. Workspace membership still governs calendars, generation, approvals, provider configuration and publication. Administrators can explicitly grant themselves owner access when they need to work inside a workspace; this is audited, never implicit.
## Bootstrap
Create the local account normally, then start the application once with `MEGURI_BOOTSTRAP_ADMIN=admin` (or another exact existing username). The grant is idempotent and recorded in the installation audit. Remove the variable after bootstrap so later demotion or suspension is not undone by a restart. An unknown username fails startup rather than creating an account or elevating a visitor. The role survives application restarts.
The navigation entry appears only to site administrators, including accounts with no workspace memberships. The server rechecks the role for every site API request. Suspending an account ends its sessions and prevents password login; the scheduling person and approved history remain intact. Other administrators can restore it. The last active administrator cannot be suspended or demoted, including concurrent edits.
## Sections
- **Users:** search accounts, edit display names, grant/revoke the site role and suspend/restore login access. User IDs and usernames remain stable. This is not a scheduling-contact editor.
- **Workspaces:** list workspaces, rename them and grant owner access to an existing active account. Existing owners retain access. Scheduling and integration edits remain inside the workspace's existing settings.
- **Authentication:** save the future OIDC configuration, described below.
- **Administration history:** show the latest 100 administrative actions, their actor, target, changes and time. Workspace administrative edits also enter the workspace audit and versioned history.
Administrative edits require confirmation. Optimistic revisions reject stale site writes. Workspace changes additionally compare their workspace revision and update history, audit and the staged sync revision atomically. They never authorize a provider publication.
## Authentication configuration is not active yet
The form saves a typed draft under the `authentication` section. Password login, registration and the login page continue to use the existing implementation. This is stated next to the form and returned as `authentication_applied: false` by the API.
Fields: enable OIDC, allow password login, HTTPS issuer URL, client ID, environment-variable name holding the client secret, login button label, and whether SSO may create new accounts. At least one login method must be selected in the draft. The issuer cannot contain URL credentials, query parameters or fragments. For Keycloak, use the realm issuer URL, not an authorization endpoint. The future adapter will use standard discovery for authorization/token/JWKS endpoints.
The secret field accepts a reference such as `MEGURI_OIDC_CLIENT_SECRET`, never the client secret itself. Environment values are not read by the form or exposed in API responses and audit. New accounts are intended to receive no automatic workspace access or administrator role. OIDC protocol execution, identity linking and application of password-disable settings are deliberately deferred.
## Extending the settings surface
`static/site.js` owns the section registry, loading and explicit save actions; `static/site.html` contains section-specific ordinary forms. New sections add a catalog label and typed form, without changing the workspace modal system. Every built-in string is in the English/Russian/French catalog.
`site_settings` stores a section key, section revision and serialized typed payload. `AuthenticationSettings` rejects unknown fields. The independent `control` revision serializes administrative writes against stale privilege and last-admin snapshots. Add future sections as separate typed payloads and handlers; never accept unrestricted document merges from the browser. Site access and site audit have dedicated tables, separate from workspace grants, scheduling documents and provider secrets.
The browser suite accepts `MEGURI_BROWSER_SITE_USERNAME` and optional `MEGURI_BROWSER_SITE_PASSWORD`, and must run only on an isolated loopback test installation with a dedicated administrator. It edits that account and the authentication draft, and creates its own ordinary user and workspace. PostgreSQL integration tests cover permissions, suspension, stale writes, ownership and concurrent last-admin protection.
+71 -1
View File
@@ -31,6 +31,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. CI also runs `cargo test --test integration -- --ignored --test-threads=1` with PostgreSQL 17. Fixtures use UUID names and never truncate shared tables. Use a disposable database. Run this suite 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.
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.
@@ -117,10 +125,72 @@ 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.
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.
Site administration tests cover denied non-admin access, explicit bootstrap, suspension/session invalidation, stale writes, last-admin protection, workspace owner changes without ambient membership, authentication draft validation and audit. Saving disabled-password/OIDC settings must not alter current authentication until the identity adapter is implemented. Browser checks cover navigation without workspace membership, user/workspace editing, settings persistence and mobile layout.
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.
Account/person regressions verify mandatory normalized unique account emails, account-owned Person IDs, canonical directory/export email, prevention of workspace-level identity edits, and no privileges or automatic contact claiming on password signup. Migration must preserve IDs/history and reject missing or ambiguous legacy emails without partial writes. Browser signup checks required email and account profile display.
The preparatory contact/account migration runs before account email enforcement on legacy databases. An explicit deployment email mapping authorizes association only in workspaces where the account is already a member. It rewrites current scheduling references, person-valued generator inputs, shared drafts and explicit provider identity keys, retaining resource IDs and old history/publication checkpoints. Missing/ambiguous mappings, conflicting provider identities or collapsed invalid schedules/replacements block the repair before writes. Already email-migrated installations are skipped. Regression coverage includes migration ordering, retry after failure, generator drafts and unchanged access/history.
+1 -1
View File
@@ -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"
"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"
},
"devDependencies": {
"playwright": "1.62.1"
+2
View File
@@ -242,6 +242,7 @@ pub async fn seed(db: &Store) -> Result<()> {
db,
username.into(),
display.into(),
format!("{username}@example.com"),
"meguri-demo-only".into(),
)
.await?
@@ -353,6 +354,7 @@ pub async fn seed(db: &Store) -> Result<()> {
_ => service_tiers(monday, alice, users[2].id),
};
let mut draft = Draft {
shared_revision: None,
revision: workspace.revision,
variables,
rules,
+2 -2
View File
@@ -191,7 +191,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']) name WHERE to_regclass('public.'||name) IS NOT NULL;
SELECT string_agg(format('public.%I',name),',') INTO tables FROM unnest(ARRAY['meguri__site_access','meguri__site_audit','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']) 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?;
@@ -239,7 +239,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#"
+21 -4
View File
@@ -37,14 +37,21 @@ 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>,
}
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,
@@ -428,7 +435,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(
@@ -704,3 +712,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
View File
@@ -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,
@@ -833,4 +890,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
View File
@@ -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())
}
@@ -729,7 +770,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" {
@@ -737,13 +785,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)?)?,
}
},
});
@@ -1013,7 +1065,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 {
@@ -1050,7 +1115,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 -10
View File
@@ -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,13 +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()),
});
expanded.document.people.push(user.person.clone());
}
}
if expanded.issues.is_empty() {
@@ -122,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,
@@ -161,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}))
}
+4
View File
@@ -12,8 +12,12 @@ pub mod holidays;
pub mod i18n;
pub mod migrations;
pub mod models;
pub mod portable;
mod portable_v2;
pub mod providers;
pub mod service;
pub mod store;
pub mod web;
pub mod worker;
pub mod site;
+5
View File
@@ -72,6 +72,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if enabled("MEGURI_SEED_DEMO") {
service::seed_demo(&db).await?;
}
if let Ok(username) = std::env::var("MEGURI_BOOTSTRAP_ADMIN")
&& !username.trim().is_empty()
{
meguri::site::bootstrap(&db, &username).await?;
}
let demo_maintenance = demo_runtime::notice()
.map(|notice| tokio::spawn(demo_runtime::run(db.clone(), notice.retention.clone())));
if role == "worker" {
+8
View File
@@ -6,6 +6,10 @@ pub mod m_0005_variables;
pub mod m_0006_demo_lifecycle;
pub mod m_0007_language;
pub mod m_0008_workspaces;
pub mod m_0009_manual_publication;
pub mod m_0010_site_admin;
pub mod m_0010b_contact_accounts;
pub mod m_0011_account_people;
pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[
&m_0001_initial::Migration,
&m_0002_constraints::Migration,
@@ -15,4 +19,8 @@ pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[
&m_0006_demo_lifecycle::Migration,
&m_0007_language::Migration,
&m_0008_workspaces::Migration,
&m_0009_manual_publication::Migration,
&m_0010_site_admin::Migration,
&m_0010b_contact_accounts::Migration,
&m_0011_account_people::Migration,
];
@@ -0,0 +1,24 @@
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_0009_manual_publication";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0008_workspaces",
)];
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(())
}
+23
View File
@@ -0,0 +1,23 @@
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_site_admin";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0009_manual_publication",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
context.db.raw(r#"DO $$ BEGIN
CREATE TABLE meguri__site_access(user_id text PRIMARY KEY REFERENCES meguri__users(id) ON DELETE CASCADE, administrator boolean NOT NULL DEFAULT false, disabled boolean NOT NULL DEFAULT false, revision bigint NOT NULL DEFAULT 1);
CREATE TABLE meguri__site_settings(section text PRIMARY KEY, revision bigint NOT NULL, payload text NOT NULL);
INSERT INTO meguri__site_settings VALUES('authentication',1,'{"oidc_enabled":false,"password_enabled":true,"issuer":"","client_id":"","client_secret_env":"MEGURI_OIDC_CLIENT_SECRET","button_label":"Single sign-on","allow_new_users":false}');
INSERT INTO meguri__site_settings VALUES('control',1,'{}');
CREATE TABLE meguri__site_audit(id bigserial PRIMARY KEY, actor_id text, action text NOT NULL, details text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
END $$;"#).await?;
Ok(())
}
+342
View File
@@ -0,0 +1,342 @@
// Repair pre-account-email identity duplication without rewriting applied migrations.
use crate::{
domain::*,
generation::{Binding, Node, Rule},
models::{WorkspaceDraftRow, WorkspaceMemberRow, WorkspaceRow},
};
use cot::db::migrations::{MigrationContext, MigrationDependency, Operation, migration_op};
use cot::db::{Auto, Database, DatabaseError, Model, model, query};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
#[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_0010b_contact_accounts";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0010_site_admin",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[derive(Debug)]
#[model(table_name = "cot__migrations", model_type = "internal")]
struct Applied {
#[model(primary_key)]
id: Auto<i32>,
app: String,
name: String,
}
#[derive(Debug)]
#[model(table_name = "meguri__users", model_type = "internal")]
struct Account {
#[model(primary_key)]
id: String,
username: String,
display_name: String,
country: String,
}
fn fail(error: impl std::fmt::Display) -> DatabaseError {
DatabaseError::ValueDecode(Box::new(std::io::Error::other(error.to_string())))
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
// Already migrated installations retain their existing identity policy.
if query!(Applied,$app=="meguri" && $name=="m_0011_account_people")
.exists(context.db)
.await?
{
return Ok(());
}
repair(
context.db,
&std::env::var("MEGURI_ACCOUNT_EMAILS").unwrap_or_else(|_| "{}".into()),
)
.await
}
type Links = BTreeMap<Uuid, Uuid>;
fn id(value: &mut Uuid, links: &Links) {
if let Some(next) = links.get(value) {
*value = *next;
}
}
fn values(value: &mut Value, links: &Links) {
match value {
Value::String(s) => {
if let Ok(old) = Uuid::parse_str(s)
&& let Some(next) = links.get(&old)
{
*s = next.to_string();
}
}
Value::Array(a) => {
for v in a {
values(v, links);
}
}
Value::Object(o) => {
for v in o.values_mut() {
values(v, links);
}
}
_ => {}
}
}
fn node(value: &mut Node, links: &Links) {
if value.kind == "person" {
values(&mut value.value, links);
}
for child in &mut value.entries {
node(child, links);
}
}
fn binding(b: &mut Binding, links: &Links) {
values(&mut b.value, links);
for item in &mut b.items {
binding(item, links);
}
if let Some(key) = &mut b.key {
binding(key, links);
}
}
fn rules(rules: &mut [Rule], links: &Links) {
for rule in rules {
for iteration in &mut rule.iterations {
binding(&mut iteration.source, links);
}
if let Some(p) = &mut rule.when {
binding(&mut p.left, links);
binding(&mut p.right, links);
}
for block in &mut rule.blocks {
for field in ["participants", "primary", "secondary"] {
if let Some(b) = block.fields.get_mut(field) {
binding(b, links);
}
}
for step in &mut block.steps {
if step.user && !step.team_all && !step.team_random {
binding(&mut step.recipient, links);
}
}
}
}
}
fn leg(leg: &mut ReplacementLeg, links: &Links) -> cot::db::Result<()> {
id(&mut leg.from_user, links);
id(&mut leg.to_user, links);
if leg.from_user == leg.to_user {
return Err(fail(
"Contact association collapses a replacement to the same person; review it before migration",
));
}
Ok(())
}
fn remap(doc: &mut WorkspaceDocument, links: &Links) -> cot::db::Result<()> {
for schedule in &mut doc.schedules {
let (ScheduleKind::Rotation { participants, .. }
| ScheduleKind::Priority { participants, .. }) = &mut schedule.kind;
for person in participants {
id(person, links);
}
}
for absence in &mut doc.absences {
id(&mut absence.user_id, links);
}
for policy in &mut doc.escalations {
for step in &mut policy.steps {
if let Recipient::User { id: person } = &mut step.recipient {
id(person, links);
}
}
}
for request in &mut doc.requests {
for l in &mut request.legs {
leg(l, links)?;
}
if let Some(legs) = &mut request.effective_legs {
for l in legs {
leg(l, links)?;
}
}
request.required_approvers = request
.required_approvers
.iter()
.map(|v| *links.get(v).unwrap_or(v))
.collect();
// Existing decisions remain evidence of the original actors. Newly linked
// account participants must approve still-pending requests themselves.
if request.status == RequestStatus::Pending {
for l in &request.legs {
for person in [l.from_user, l.to_user] {
if links.values().any(|v| *v == person) && person != request.requested_by {
request.required_approvers.insert(person);
}
}
}
}
}
if let Integration::Jsm { users, .. } = &mut doc.integration {
for (old, new) in links {
if let Some(external) = users.remove(old) {
if users.get(new).is_some_and(|v| v != &external) {
return Err(fail(
"Conflicting explicit JSM identities for contact and account",
));
}
users.insert(*new, external);
}
}
}
for variable in &mut doc.generation.variables {
node(&mut variable.node, links);
}
rules(&mut doc.generation.rules, links);
Ok(())
}
/// Validate all links before atomically updating current documents and shared drafts.
pub async fn repair(db: &Database, mapping: &str) -> cot::db::Result<()> {
let mapping: BTreeMap<String, String> = serde_json::from_str(mapping).map_err(fail)?;
let accounts = Account::objects().all(db).await?;
for (name, email) in &mapping {
if !accounts.iter().any(|u| &u.username == name) {
return Err(fail(format!(
"Unknown username in MEGURI_ACCOUNT_EMAILS: {name}"
)));
}
crate::service::normalize_email(email).map_err(fail)?;
}
let workspaces = WorkspaceRow::objects().all(db).await?;
let memberships = WorkspaceMemberRow::objects().all(db).await?;
let drafts = WorkspaceDraftRow::objects().all(db).await?;
let mut emails = BTreeMap::new();
// Fail before any writes if the subsequent email migration would need more input.
for account in &accounts {
let email = if let Some(e) = mapping.get(&account.username) {
crate::service::normalize_email(e).map_err(fail)?
} else {
let mut found = BTreeSet::new();
for row in &workspaces {
let doc: WorkspaceDocument = serde_json::from_str(&row.document).map_err(fail)?;
for p in doc.people {
if p.user_id.map(|v| v.to_string()).as_deref() == Some(&account.id)
&& let Some(e) = p.email
{
found.insert(crate::service::normalize_email(&e).map_err(fail)?);
}
}
}
if found.len() != 1 {
return Err(fail(format!(
"Set MEGURI_ACCOUNT_EMAILS for missing or ambiguous email: {}",
account.username
)));
}
found.into_iter().next().unwrap()
};
if emails.values().any(|e| e == &email) {
return Err(fail("Duplicate account email in MEGURI_ACCOUNT_EMAILS"));
}
emails.insert(account.id.clone(), email);
}
let mut changes = vec![];
for row in workspaces {
let mut doc: WorkspaceDocument = serde_json::from_str(&row.document).map_err(fail)?;
let mut links = Links::new();
let members: BTreeSet<Uuid> = memberships
.iter()
.filter(|m| m.workspace_id == row.id)
.map(|m| Uuid::parse_str(&m.user_id).map_err(fail))
.collect::<cot::db::Result<_>>()?;
for account in &accounts {
let account_id = Uuid::parse_str(&account.id).map_err(fail)?;
if !members.contains(&account_id) {
continue;
}
let email = &emails[&account.id];
for person in &doc.people {
if person.id != account_id
&& person
.email
.as_ref()
.is_some_and(|e| e.trim().eq_ignore_ascii_case(email))
{
if person.user_id.is_some() || !mapping.contains_key(&account.username) {
return Err(fail(format!(
"Explicit MEGURI_ACCOUNT_EMAILS required to associate contact with {} in workspace {}",
account.username, row.id
)));
}
links.insert(person.id, account_id);
}
}
}
if links.is_empty() {
continue;
}
remap(&mut doc, &links)?;
doc.people.retain(|p| !links.contains_key(&p.id));
for account in &accounts {
let account_id = Uuid::parse_str(&account.id).map_err(fail)?;
if links.values().any(|v| *v == account_id) {
doc.people.retain(|p| p.id != account_id);
doc.people.push(Person {
id: account_id,
user_id: Some(account_id),
display_name: account.display_name.clone(),
email: Some(emails[&account.id].clone()),
country: Some(account.country.clone()),
});
}
}
doc.validate(&members).map_err(fail)?;
let draft = if let Some(d) = drafts.iter().find(|d| d.workspace_id == row.id) {
let mut payload: crate::generation_service::Draft =
serde_json::from_str(&d.payload).map_err(fail)?;
for v in &mut payload.variables {
node(&mut v.node, &links);
}
rules(&mut payload.rules, &links);
payload.review_id = None;
Some(json!({"revision":d.revision,"payload":payload}))
} else {
None
};
changes.push(
json!({"id":row.id,"revision":row.revision,"document":doc,"links":links,"draft":draft}),
);
}
if changes.is_empty() {
return Ok(());
}
let literal = json!(changes)
.to_string()
.replace('\\', "\\\\")
.replace('\'', "''");
let mut delimiter = "$repair$".to_owned();
while literal.contains(&delimiter) {
delimiter.insert(1, '_');
}
db.raw(&format!(r#"DO {delimiter}
DECLARE changes jsonb := E'{literal}'::jsonb; item jsonb;
BEGIN
LOCK TABLE meguri__workspaces,meguri__workspace_drafts,meguri__workspace_members IN SHARE ROW EXCLUSIVE MODE;
FOR item IN SELECT value FROM jsonb_array_elements(changes) LOOP
IF NOT EXISTS(SELECT 1 FROM meguri__workspaces WHERE id=item->>'id' AND revision=(item->>'revision')::bigint) THEN RAISE EXCEPTION 'Workspace changed during contact migration'; END IF;
IF item->'draft'='null'::jsonb AND EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=item->>'id') THEN RAISE EXCEPTION 'Shared draft created during contact migration'; END IF;
IF item->'draft'<>'null'::jsonb AND NOT EXISTS(SELECT 1 FROM meguri__workspace_drafts WHERE workspace_id=item->>'id' AND revision=(item->'draft'->>'revision')::bigint) THEN RAISE EXCEPTION 'Shared draft changed during contact migration'; END IF;
UPDATE meguri__workspaces SET document=(item->'document')::text,revision=revision+1 WHERE id=item->>'id';
IF item->'draft'<>'null'::jsonb THEN
UPDATE meguri__workspace_drafts SET payload=(item->'draft'->'payload')::text,revision=revision+1 WHERE workspace_id=item->>'id';
END IF;
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at)
SELECT id||':'||revision::text,id,revision,document,clock_timestamp() FROM meguri__workspaces WHERE id=item->>'id';
INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) VALUES(item->>'id','account.contacts_linked',(item->'links')::text,clock_timestamp());
UPDATE meguri__sync_jobs SET requested_revision=(item->>'revision')::bigint+1 WHERE workspace_id=item->>'id';
END LOOP;
END {delimiter};"#)).await?;
Ok(())
}
+87
View File
@@ -0,0 +1,87 @@
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_0011_account_people";
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
"meguri",
"m_0010_site_admin",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
}
#[migration_op]
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
migrate(
context.db,
&std::env::var("MEGURI_ACCOUNT_EMAILS").unwrap_or_else(|_| "{}".into()),
)
.await
}
/// A single PostgreSQL statement ensures validation failures leave the old schema intact.
pub async fn migrate(db: &cot::db::Database, mapping: &str) -> cot::db::Result<()> {
// Explicit E-string escaping, never shell interpolation or unquoted JSON.
let literal = mapping.replace('\\', "\\\\").replace('\'', "''");
let mut delimiter = "$migration$".to_owned();
while literal.contains(&delimiter) {
delimiter.insert(1, '_');
}
let sql = format!(
r#"DO {delimiter}
DECLARE mapping jsonb := E'{literal}'::jsonb; missing text;
BEGIN
IF jsonb_typeof(mapping) <> 'object' OR EXISTS(SELECT 1 FROM jsonb_each(mapping) WHERE jsonb_typeof(value)<>'string') THEN
RAISE EXCEPTION 'MEGURI_ACCOUNT_EMAILS must be a JSON object mapping existing usernames to email addresses';
END IF;
IF EXISTS(SELECT 1 FROM jsonb_object_keys(mapping) k WHERE NOT EXISTS(SELECT 1 FROM meguri__users u WHERE u.username=k)) THEN
RAISE EXCEPTION 'MEGURI_ACCOUNT_EMAILS contains an unknown username';
END IF;
ALTER TABLE meguri__users ADD COLUMN email text;
UPDATE meguri__users u SET email=lower(trim(COALESCE(mapping->>u.username,(
SELECT CASE WHEN count(DISTINCT lower(trim(p->>'email')))=1 THEN min(lower(trim(p->>'email'))) END
FROM meguri__workspaces w CROSS JOIN LATERAL jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p
WHERE p->>'user_id'=u.id AND nullif(trim(p->>'email'),'') IS NOT NULL
))));
SELECT string_agg(username,', ' ORDER BY username) INTO missing FROM meguri__users
WHERE email IS NULL OR length(email)>254 OR email !~ '^[^[:space:]@]+@[^[:space:]@]+$';
IF missing IS NOT NULL THEN
RAISE EXCEPTION 'Missing, ambiguous or invalid account email. Set MEGURI_ACCOUNT_EMAILS for: %', missing;
END IF;
IF EXISTS(SELECT email FROM meguri__users GROUP BY email HAVING count(*)>1) THEN
RAISE EXCEPTION 'Duplicate account email. Resolve MEGURI_ACCOUNT_EMAILS before migration';
END IF;
ALTER TABLE meguri__users ALTER COLUMN email SET NOT NULL;
ALTER TABLE meguri__users ADD CONSTRAINT account_email_valid CHECK(email=lower(trim(email)) AND length(email)<=254 AND email ~ '^[^[:space:]@]+@[^[:space:]@]+$');
ALTER TABLE meguri__users ADD CONSTRAINT account_email_unique UNIQUE(email);
-- Current documents acquire canonical member snapshots; historical and authorized
-- publication snapshots retain the identity data that was actually used then.
WITH snapshots AS (
SELECT w.id, COALESCE((
SELECT jsonb_agg(p) FROM jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p
WHERE NOT EXISTS(SELECT 1 FROM meguri__workspace_members m WHERE m.workspace_id=w.id AND m.user_id=p->>'id')
),'[]'::jsonb) || COALESCE((
SELECT jsonb_agg(jsonb_build_object('id',u.id,'user_id',u.id,'display_name',u.display_name,'email',u.email,'country',u.country))
FROM meguri__workspace_members m JOIN meguri__users u ON u.id=m.user_id WHERE m.workspace_id=w.id
),'[]'::jsonb) AS people FROM meguri__workspaces w
), changed AS (
UPDATE meguri__workspaces w SET document=jsonb_set(w.document::jsonb,'{{people}}',s.people)::text, revision=w.revision+1
FROM snapshots s WHERE w.id=s.id RETURNING w.*
), history AS (
INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at)
SELECT id||':'||revision::text,id,revision,document,clock_timestamp() FROM changed
), jobs AS (
UPDATE meguri__sync_jobs j SET requested_revision=c.revision FROM changed c WHERE j.workspace_id=c.id
)
INSERT INTO meguri__audit_events(workspace_id,action,details,created_at)
SELECT id,'account.people_migrated','{{}}',clock_timestamp() FROM changed;
IF EXISTS(SELECT 1 FROM meguri__workspaces w CROSS JOIN LATERAL jsonb_array_elements(w.document::jsonb->'people') p GROUP BY w.id,p->>'email' HAVING count(*)>1) THEN
RAISE EXCEPTION 'Account email conflicts with an existing planning contact. Resolve the contact identity before migration; no identities were merged';
END IF;
END {delimiter};"#
);
db.raw(&sql).await?;
Ok(())
}
+42
View File
@@ -11,6 +11,8 @@ pub struct UserRow {
pub id: String,
#[model(unique)]
pub username: String,
#[model(unique)]
pub email: String,
pub display_name: String,
pub country: String,
pub timezone: Option<String>,
@@ -87,6 +89,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,
@@ -112,3 +117,40 @@ pub struct ProviderObjectRow {
pub content: String,
pub updated_at: DateTime<Utc>,
}
#[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 = "site_access")]
pub struct SiteAccessRow {
#[model(primary_key)]
pub user_id: String,
pub administrator: bool,
pub disabled: bool,
pub revision: i64,
}
#[derive(Debug, Clone)]
#[model(table_name = "site_settings")]
pub struct SiteSettingsRow {
#[model(primary_key)]
pub section: String,
pub revision: i64,
pub payload: String,
}
#[derive(Debug, Clone)]
#[model(table_name = "site_audit")]
pub struct SiteAuditRow {
#[model(primary_key)]
pub id: Auto<i64>,
pub actor_id: Option<String>,
pub action: String,
pub details: String,
pub created_at: DateTime<Utc>,
}
+738
View File
@@ -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)
}
+537
View File
@@ -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
View File
@@ -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) = &current {
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(&current["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"}]}]});
+44
View File
@@ -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 {
+124 -74
View File
@@ -38,10 +38,23 @@ pub async fn hash_password(password: String) -> Result<String> {
.map_err(|e| Error::Internal(e.to_string()))?
}
pub fn normalize_email(value: &str) -> Result<String> {
let email = value.trim().to_lowercase();
ensure(
email.len() <= 254
&& email.split('@').count() == 2
&& !email.chars().any(char::is_whitespace)
&& email_address::EmailAddress::is_valid(&email),
crate::i18n::text("err_enter_a_valid_email_address_1577ea"),
)?;
Ok(email)
}
pub async fn register(
db: &Store,
username: String,
display_name: String,
email: String,
password: String,
) -> Result<User> {
let username = username.trim().to_ascii_lowercase();
@@ -53,14 +66,20 @@ pub async fn register(
crate::i18n::text("err_username_must_use_3_64_letters_numbers_dots_unde_2c6b6f"),
)?;
validate_name(&display_name)?;
let email = normalize_email(&email)?;
let hash = hash_password(password).await?;
let id = Uuid::new_v4();
let user = User {
language: None,
id: Uuid::new_v4(),
username,
display_name,
country: "CY".into(),
timezone: None,
person: Person {
id,
user_id: Some(id),
display_name,
email: Some(email.clone()),
country: Some("CY".into()),
},
};
let result = UserRow {
language: None,
@@ -68,7 +87,8 @@ pub async fn register(
id: user.id.to_string(),
username: user.username.clone(),
display_name: user.display_name.clone(),
country: user.country.clone(),
country: user.country.clone().unwrap_or_else(|| "CY".into()),
email,
timezone: None,
password_hash: hash,
created_at: Utc::now(),
@@ -77,7 +97,7 @@ pub async fn register(
.await;
if matches!(result, Err(cot::db::DatabaseError::UniqueViolation)) {
return Err(Error::Conflict(
crate::i18n::text("err_this_username_is_already_registered_5ea7ea").into(),
crate::i18n::text("account_identity_taken").into(),
));
}
result?;
@@ -116,6 +136,7 @@ pub async fn login(
}
let row = row.unwrap();
let user = store::user(row)?;
crate::site::active(db, user.id).await?;
let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let csrf = Uuid::new_v4().to_string();
SessionRow {
@@ -132,7 +153,13 @@ pub async fn login(
pub async fn create_workspace(db: &Store, actor: Uuid, name: String) -> Result<WorkspaceRecord> {
validate_name(&name)?;
let id = Uuid::new_v4();
let doc = WorkspaceDocument::new(name);
let mut doc = WorkspaceDocument::new(name);
let key = actor.to_string();
let account = query!(UserRow, $id == key)
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
doc.people.push(store::user(account)?.person);
db.db
.raw_with(
CREATE_WORKSPACE,
@@ -199,6 +226,7 @@ pub async fn update_document(
)?;
}
}
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 {
@@ -487,24 +515,13 @@ async fn prepare_absence(
}
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());
}
}
}
@@ -518,7 +535,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(
@@ -538,6 +557,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 =
@@ -545,20 +565,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);
@@ -577,11 +595,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?;
@@ -593,14 +607,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(),
@@ -611,17 +623,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);
@@ -641,25 +653,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"),
)
}
@@ -678,17 +723,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"),
@@ -696,7 +739,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)]
@@ -950,7 +1000,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(),$3 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,$3,'workspace.created','{}',now() FROM workspace
"#;
@@ -960,16 +1010,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
"#;
+282
View File
@@ -0,0 +1,282 @@
//! Explicit installation administration; never an ambient workspace grant.
use crate::{
domain::*,
error::{Error, Result, ensure},
models::*,
store::{Store, user},
};
use cot::db::{Model, query};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthenticationSettings {
pub oidc_enabled: bool,
pub password_enabled: bool,
pub issuer: String,
pub client_id: String,
pub client_secret_env: String,
pub button_label: String,
pub allow_new_users: bool,
}
pub fn invalid() -> Error {
Error::Validation(crate::i18n::text("site_invalid").into())
}
pub async fn settings(db: &Store) -> Result<(i64, AuthenticationSettings)> {
let row = query!(SiteSettingsRow,$section=="authentication")
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
Ok((row.revision, serde_json::from_str(&row.payload)?))
}
pub async fn access(db: &Store, id: Uuid) -> Result<SiteAccessRow> {
let id = id.to_string();
Ok(query!(SiteAccessRow,$user_id==id.clone())
.get(&db.db)
.await?
.unwrap_or(SiteAccessRow {
user_id: id,
administrator: false,
disabled: false,
revision: 0,
}))
}
pub async fn active(db: &Store, id: Uuid) -> Result<()> {
if access(db, id).await?.disabled {
Err(Error::Unauthorized)
} else {
Ok(())
}
}
pub async fn require(db: &Store, id: Uuid) -> Result<()> {
let a = access(db, id).await?;
if a.administrator && !a.disabled {
Ok(())
} else {
Err(Error::Forbidden)
}
}
pub async fn bootstrap(db: &Store, username: &str) -> Result<()> {
let username = username.trim().to_ascii_lowercase();
let row = query!(UserRow,$username==username)
.get(&db.db)
.await?
.filter(|r| r.demo_deleted_at.is_none())
.ok_or_else(invalid)?;
db.db.raw_with(r#"WITH changed AS (INSERT INTO meguri__site_access(user_id,administrator) VALUES($1,true) ON CONFLICT(user_id) DO UPDATE SET administrator=true,disabled=false,revision=meguri__site_access.revision+1 WHERE NOT meguri__site_access.administrator OR meguri__site_access.disabled RETURNING user_id), version AS(UPDATE meguri__site_settings SET revision=revision+1 WHERE section='control' AND EXISTS(SELECT 1 FROM changed)) INSERT INTO meguri__site_audit(actor_id,action,details) SELECT NULL,'site.bootstrap',json_build_object('user_id',user_id)::text FROM changed"#,&[&row.id]).await?;
Ok(())
}
pub async fn overview(db: &Store, actor: Uuid) -> Result<Value> {
require(db, actor).await?;
let (revision, authentication) = settings(db).await?;
let control = query!(SiteSettingsRow,$section=="control")
.get(&db.db)
.await?
.ok_or(Error::NotFound)?;
let access_rows: std::collections::BTreeMap<_, _> = SiteAccessRow::objects()
.all(&db.db)
.await?
.into_iter()
.map(|a| (a.user_id.clone(), a))
.collect();
let member_rows = WorkspaceMemberRow::objects().all(&db.db).await?;
let mut users = vec![];
for row in UserRow::objects().all(&db.db).await? {
if row.demo_deleted_at.is_none() {
let u = user(row)?;
let a = access_rows
.get(&u.id.to_string())
.cloned()
.unwrap_or(SiteAccessRow {
user_id: u.id.to_string(),
administrator: false,
disabled: false,
revision: 0,
});
users.push(json!({"user":u,"access":a_value(&a)}));
}
}
users.sort_by_key(|v| {
v["user"]["username"]
.as_str()
.unwrap_or_default()
.to_owned()
});
let mut workspaces = vec![];
for w in WorkspaceRow::objects().all(&db.db).await? {
if w.demo_deleted_at.is_none() {
let d: WorkspaceDocument = serde_json::from_str(&w.document)?;
let id = w.id.clone();
let members: Vec<_> = member_rows
.iter()
.filter(|m| m.workspace_id == id)
.collect();
workspaces.push(json!({"id":w.id,"name":d.name,"revision":w.revision,"owners":members.iter().filter(|m|m.is_owner).map(|m|m.user_id.clone()).collect::<Vec<_>>(),"members":members.len()}));
}
}
workspaces.sort_by_key(|v| v["name"].as_str().unwrap_or_default().to_owned());
let audit = SiteAuditRow::objects().all(&db.db).await?;
let mut audit:Vec<_>=audit.into_iter().map(|a|json!({"id":a.id,"actor_id":a.actor_id,"action":a.action,"details":serde_json::from_str::<Value>(&a.details).unwrap_or(Value::Null),"created_at":a.created_at})).collect();
audit.sort_by(|a, b| b["created_at"].as_str().cmp(&a["created_at"].as_str()));
audit.truncate(100);
Ok(
json!({"revision":control.revision,"users":users,"workspaces":workspaces,"audit":audit,"settings":{"authentication":{"revision":revision,"value":authentication}},"authentication_applied":false}),
)
}
fn a_value(a: &SiteAccessRow) -> Value {
json!({"administrator":a.administrator,"disabled":a.disabled,"revision":a.revision})
}
// Every mutation checks the same control revision. Concurrent site writes cannot
// both succeed from a stale administrator/last-admin snapshot.
const GATE: &str = r#"WITH gate AS (UPDATE meguri__site_settings SET revision=revision+1 WHERE section='control' AND revision=$2 AND EXISTS(SELECT 1 FROM meguri__site_access WHERE user_id=$1 AND administrator AND NOT disabled) RETURNING revision)"#;
pub async fn mutate(db: &Store, actor: Uuid, action: &str, body: Value) -> Result<()> {
require(db, actor).await?;
let rev = body["revision"].as_i64().ok_or_else(invalid)?;
let actor = actor.to_string();
let id = body["id"].as_str().unwrap_or_default().to_owned();
let (sql, details) = match action {
"user" => {
let target = query!(UserRow,$id==id.clone())
.get(&db.db)
.await?
.filter(|u| u.demo_deleted_at.is_none())
.ok_or(Error::NotFound)?;
let name = body["display_name"].as_str().ok_or_else(invalid)?;
validate_name(name)?;
let admin = body["administrator"].as_bool().ok_or_else(invalid)?;
let disabled = body["disabled"].as_bool().ok_or_else(invalid)?;
if !admin || disabled {
let active_ids: std::collections::BTreeSet<_> = UserRow::objects()
.all(&db.db)
.await?
.into_iter()
.filter(|u| u.demo_deleted_at.is_none())
.map(|u| u.id)
.collect();
let active_admins = SiteAccessRow::objects()
.all(&db.db)
.await?
.into_iter()
.filter(|a| a.administrator && !a.disabled && active_ids.contains(&a.user_id))
.collect::<Vec<_>>();
ensure(
!active_admins.iter().any(|a| a.user_id == target.id)
|| active_admins.len() > 1,
crate::i18n::text("site_last_admin"),
)?;
}
(
r#", changed AS (INSERT INTO meguri__site_access(user_id,administrator,disabled) SELECT $3,($4::jsonb->>'administrator')::boolean,($4::jsonb->>'disabled')::boolean FROM gate ON CONFLICT(user_id) DO UPDATE SET administrator=EXCLUDED.administrator,disabled=EXCLUDED.disabled,revision=meguri__site_access.revision+1 RETURNING user_id), profile AS(UPDATE meguri__users SET display_name=$4::jsonb->>'display_name' WHERE id IN(SELECT user_id FROM changed) RETURNING *), snapshots AS(
UPDATE meguri__workspaces w SET revision=w.revision+1,document=jsonb_set(w.document::jsonb,'{people}',
(SELECT jsonb_agg(CASE WHEN p->>'user_id'=u.id THEN p||jsonb_build_object('display_name',u.display_name) ELSE p END)
FROM jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p))::text
FROM profile u WHERE EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(w.document::jsonb->'people','[]')) p WHERE p->>'user_id'=u.id AND p->>'display_name' IS DISTINCT FROM u.display_name) RETURNING w.*
), profile_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 snapshots),
profile_jobs AS(UPDATE meguri__sync_jobs j SET requested_revision=s.revision FROM snapshots s WHERE j.workspace_id=s.id),
profile_audit AS(INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$1,'person.profile_changed',$4,clock_timestamp() FROM snapshots), sessions AS(DELETE FROM meguri__sessions WHERE user_id IN(SELECT user_id FROM changed) AND ($4::jsonb->>'disabled')::boolean)"#,
json!({"display_name":name,"administrator":admin,"disabled":disabled}),
)
}
"workspace" => {
let workspace = db
.workspace(Uuid::parse_str(&id).map_err(|_| invalid())?)
.await?;
ensure(
body["workspace_revision"].as_i64() == Some(workspace.revision),
crate::i18n::text("site_stale"),
)?;
let name = body["name"].as_str().ok_or_else(invalid)?;
validate_name(name)?;
let owner = body["owner_id"].as_str().ok_or_else(invalid)?.to_owned();
let owner_row = query!(UserRow,$id==owner.clone())
.get(&db.db)
.await?
.filter(|u| u.demo_deleted_at.is_none())
.ok_or_else(invalid)?;
active(db, Uuid::parse_str(&owner_row.id).map_err(|_| invalid())?).await?;
let mut document = workspace.document.clone();
crate::store::account_people(&mut document, &[user(owner_row)?])?;
let mut members = db.member_ids(workspace.id).await?;
members.insert(Uuid::parse_str(&owner).map_err(|_| invalid())?);
document.validate(&members)?;
document.name = name.into();
// Add an owner explicitly, retaining existing owners and workspace grants.
(
r#", workspace AS(UPDATE meguri__workspaces SET document=($4::jsonb->'document')::text,revision=revision+1 WHERE id=$3 AND revision=($4::jsonb->>'workspace_revision')::bigint AND EXISTS(SELECT 1 FROM gate) RETURNING *), jobs AS(INSERT INTO meguri__sync_jobs(workspace_id,requested_revision,state,attempts,next_attempt) SELECT id,revision,'draft',0,now() FROM workspace ON CONFLICT(workspace_id) DO UPDATE SET requested_revision=EXCLUDED.requested_revision), changed AS(INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) SELECT $4::jsonb->>'member_id',$3,$4::jsonb->>'owner_id',$4::jsonb->>'permissions',true FROM workspace ON CONFLICT(workspace_id,user_id) DO UPDATE SET is_owner=true RETURNING user_id), history AS(INSERT INTO meguri__workspace_history(id,workspace_id,revision,document,effective_at,actor_id) SELECT $4::jsonb->>'history_id',id,revision,document,now(),$1 FROM workspace), audit AS(INSERT INTO meguri__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$1,'site.workspace_updated',$4,now() FROM workspace)"#,
json!({"document":document,"name":name,"owner_id":owner,"workspace_revision":workspace.revision,"member_id":format!("{id}:{owner}"),"history_id":format!("{id}:{}",workspace.revision+1),"permissions":serde_json::to_string(&Permission::all())?}),
)
}
"authentication" => {
let cfg: AuthenticationSettings =
serde_json::from_value(body["value"].clone()).map_err(|_| invalid())?;
ensure(
cfg.button_label.chars().count() <= 80
&& cfg.client_id.len() <= 500
&& cfg.issuer.len() <= 2048,
crate::i18n::text("site_invalid"),
)?;
if cfg.oidc_enabled {
ensure(
!cfg.client_id.trim().is_empty() && !cfg.button_label.trim().is_empty(),
crate::i18n::text("site_invalid"),
)?;
}
if !cfg.issuer.is_empty() || cfg.oidc_enabled {
let url = reqwest::Url::parse(&cfg.issuer).map_err(|_| invalid())?;
ensure(
url.scheme() == "https"
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none(),
crate::i18n::text("site_invalid"),
)?;
}
ensure(
cfg.password_enabled || cfg.oidc_enabled,
crate::i18n::text("site_auth_method"),
)?;
ensure(
cfg.client_secret_env.starts_with("MEGURI_OIDC_")
&& cfg
.client_secret_env
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'),
crate::i18n::text("site_invalid"),
)?;
let (oldrev, _) = settings(db).await?;
ensure(
body["section_revision"].as_i64() == Some(oldrev),
crate::i18n::text("site_stale"),
)?;
(
r#", changed AS(UPDATE meguri__site_settings SET payload=($4::jsonb->'value')::text,revision=revision+1 WHERE section='authentication' AND EXISTS(SELECT 1 FROM gate) RETURNING section)"#,
json!({"value":cfg}),
)
}
_ => return Err(Error::NotFound),
};
let sql = format!(
"{GATE}{sql} INSERT INTO meguri__site_audit(actor_id,action,details) SELECT $1,$5,jsonb_build_object('target',$3,'change',$4::jsonb)::text FROM changed"
);
let result = db
.db
.raw_with(
&sql,
&[
&actor,
&rev,
&id,
&details.to_string(),
&format!("site.{action}"),
],
)
.await?;
if result.rows_affected().0 == 0 {
return Err(Error::Conflict(crate::i18n::text("site_stale").into()));
}
Ok(())
}
+179 -23
View File
@@ -30,13 +30,37 @@ 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: Some(row.email),
country: Some(row.country),
},
username: row.username,
display_name: row.display_name,
country: row.country,
timezone: row.timezone,
})
}
/// 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"),
)?;
*person = user.person.clone();
} else {
document.people.push(user.person.clone());
}
}
Ok(())
}
pub fn record(row: WorkspaceRow) -> Result<WorkspaceRecord> {
if row.demo_deleted_at.is_some() {
return Err(Error::NotFound);
@@ -177,6 +201,22 @@ impl Store {
details: Value,
member: Option<(Uuid, Vec<Permission>)>,
) -> Result<()> {
let mut users = self.directory(record.id).await?;
if let Some((id, _)) = &member {
let key = id.to_string();
if !users.iter().any(|u| u.id == *id) {
users.push(user(
query!(UserRow, $id == key)
.get(&self.db)
.await?
.ok_or(Error::NotFound)?,
)?);
}
}
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)?;
@@ -213,7 +253,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, &current.document);
@@ -260,20 +300,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 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,
@@ -360,19 +459,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 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(())
}
}
@@ -380,6 +480,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 ($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__workspace_members WHERE workspace_id=$1 AND user_id=$4 AND (is_owner OR permissions::jsonb @> $7::jsonb)) RETURNING *
), member AS (
INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner)
@@ -390,9 +491,64 @@ 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
"#;
/// 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))
}
+57 -4
View File
@@ -38,6 +38,7 @@ impl App for MeguriApp {
Route::with_handler("/", index),
Route::with_handler("/assets/app.css", css),
Route::with_handler("/assets/app.js", js),
Route::with_handler("/assets/site.js", site_js),
Route::with_handler("/assets/generation.js", generation_js),
Route::with_handler("/assets/generation-map.js", generation_map_js),
Route::with_handler("/assets/cytoscape.js", cytoscape_js),
@@ -47,6 +48,8 @@ impl App for MeguriApp {
Route::with_handler("/health/ready", ready),
];
for path in [
"/api/site",
"/api/site/{action}",
"/api/session",
"/api/register",
"/api/login",
@@ -62,8 +65,12 @@ 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}/generation-draft",
"/api/workspaces/{workspace}/members",
"/api/workspaces/{workspace}/requests",
"/api/workspaces/{workspace}/request-preview",
@@ -108,8 +115,14 @@ fn secured(response: Response) -> cot::Result<Response> {
.with_header("Cache-Control", "no-store")
.into_response()
}
async fn site_js() -> cot::Result<Response> {
Html::new(include_str!("../static/site.js"))
.with_header("Content-Type", "application/javascript; charset=utf-8")
.into_response()
}
async fn index() -> cot::Result<Response> {
let html = include_str!("../static/index.html")
.replace("<!-- SITE_ADMIN -->", include_str!("../static/site.html"))
.replace(
"<!-- GENERATION_MAP -->",
include_str!("../static/generation-map.html"),
@@ -230,6 +243,7 @@ async fn authenticate(db: &Store, request: &Request) -> Result<Identity> {
if user.demo_deleted_at.is_some() {
return Err(Error::Unauthorized);
}
crate::site::active(db, store::uuid(&user.id)?).await?;
Ok(Identity {
user: store::user(user)?,
csrf: session.csrf,
@@ -275,7 +289,7 @@ async fn dispatch(request: Request) -> Result<Response> {
if path == "/api/session" && method == Method::GET {
return match identity {
Ok(i) => response(
json!({"user":i.user,"csrf":i.csrf,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
json!({"site_admin":crate::site::require(&db,i.user.id).await.is_ok(),"user":i.user,"csrf":i.csrf,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
),
Err(Error::Unauthorized) => response(
json!({"user":null,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
@@ -316,6 +330,8 @@ async fn dispatch(request: Request) -> Result<Response> {
password: String,
#[serde(default)]
display_name: String,
#[serde(default)]
email: String,
}
let credentials: Credentials = decode(body)?;
check_login_rate(&credentials.username)?;
@@ -327,6 +343,7 @@ async fn dispatch(request: Request) -> Result<Response> {
&db,
credentials.username.clone(),
credentials.display_name,
credentials.email,
credentials.password.clone(),
)
.await?;
@@ -338,7 +355,7 @@ async fn dispatch(request: Request) -> Result<Response> {
} else {
""
};
return response(json!({"user":user,"csrf":csrf}))?
return response(json!({"site_admin":crate::site::require(&db,user.id).await.is_ok(),"user":user,"csrf":csrf}))?
.with_header(
"Set-Cookie",
format!(
@@ -350,6 +367,16 @@ async fn dispatch(request: Request) -> Result<Response> {
}
let identity = identity?;
let actor = identity.user.id;
if path == "/api/site" && method == Method::GET {
return response(crate::site::overview(&db, actor).await?);
}
if let Some(action) = path.strip_prefix("/api/site/") {
if method == Method::PUT {
crate::site::mutate(&db, actor, action, body).await?;
return response(json!({"ok":true}));
}
return Err(Error::NotFound);
}
if path == "/api/logout" && method == Method::POST {
let token = identity.token_hash;
query!(SessionRow,$token_hash==token).delete(&db.db).await?;
@@ -476,10 +503,36 @@ async fn dispatch(request: Request) -> Result<Response> {
json!({"total":0,"issues":[]})
};
return response(
json!({"coverage":coverage,"workspace":record,"is_owner":db.owner(workspace,actor).await.is_ok(),"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(),"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?,
@@ -711,7 +764,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
View File
@@ -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))
+13
View File
@@ -194,3 +194,16 @@ input[type=date]:not(.native-date),.date-field{max-width:176px;min-width:0}
.map-source-card{border:1px solid var(--line);border-radius:7px;padding:10px;margin:8px 0;background:#f8faf9}.map-source-title{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px}.map-source-title>small{margin-left:auto;color:var(--muted)}.map-source-addresses{display:flex;flex-direction:column;gap:4px;margin-top:7px}.map-source-addresses code{font:10px/1.5 Consolas,monospace;overflow-wrap:anywhere;color:#567565}.map-source-card>button{margin-top:6px;padding:3px 0}
.map-resource-fields{padding:0 12px 12px;border-top:1px solid var(--line)}.map-field-hint{display:block;font-size:10px;line-height:1.5;color:var(--muted);margin-bottom:6px}.map-resource-fields .map-reference>small{flex-basis:auto;margin:0;min-width:15px}.map-resource-fields .map-input-field:last-child{margin-bottom:0}
.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}}
.site-layout{display:grid;grid-template-columns:180px minmax(0,1fr);gap:28px;align-items:start}.site-tabs{display:flex;flex-direction:column;gap:6px}.site-tabs button{text-align:left}.site-tabs .active{background:var(--ink,#193c34);color:white}.site-body{min-width:0;max-width:1000px}.site-list{display:grid;gap:10px}.site-row{display:flex;justify-content:space-between;align-items:center;gap:16px;background:white;border:1px solid #dae4df;border-radius:12px;padding:16px}.site-editor,.site-settings-form fieldset{background:white;border:1px solid #dae4df;border-radius:12px;padding:20px;margin:20px 0}.site-settings-form{max-width:720px}.site-settings-form legend{font-weight:650;padding:0 8px}.site-settings-form label,.site-editor label{margin:12px 0}.site-settings-form .check,.site-editor .check{display:flex;align-items:center;gap:10px}.site-settings-form .check input,.site-editor .check input{width:auto}.site-audit-row{border-bottom:1px solid #dae4df;padding:14px 0;display:flex;gap:12px;flex-wrap:wrap}.site-audit-row pre{width:100%;white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}.site-admin .page-heading{align-items:start}.site-row strong{overflow-wrap:anywhere}@media(max-width:760px){.site-layout{grid-template-columns:minmax(0,1fr);gap:16px}.site-tabs{flex-direction:row;flex-wrap:wrap}.site-tabs button{font-size:12px}.site-row{padding:12px}.site-settings-form fieldset{padding:14px}.site-admin .page-heading{flex-wrap:wrap}}
@media(max-width:950px){.sidebar .site-nav-label{display:none}.sidebar .site-nav-link{flex-shrink:0;justify-content:center}}
@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}}
+21 -16
View File
@@ -18,8 +18,10 @@ document.addEventListener('alpine:init', () => {
}));
Alpine.data('meguri', () => ({
...generationUi(),
...siteUi(),
syncSubmitting:false,workspacePollAt:0,
user: null, csrf: '', ready: false, busy: false, error: '', toast: '', allowSignup: false, demo: false, demoLifecycle:null,
loginForm: { username: '', password: '', display_name: '' }, registering: false,
loginForm: { username: '', password: '', display_name: '', email: '' }, registering: false,
directoryState: { workspaces: [] }, selected: '', detail: null, calendar: { days: [], assignments: [] },
view: 'calendar', scheduleId: '', startDay: '', modal: '', form: {}, menu: false,
navigationRestoring:0, navigationEpoch:0, setupOpen:false, scheduleSearch:'', schedulePicker:false, coverQuery:'', requestResult:null, requestError:'', requestLoading:false, requestSequence:0,
@@ -39,7 +41,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()];
},
@@ -71,7 +73,7 @@ document.addEventListener('alpine:init', () => {
get needsMe() { return this.pending.filter(r=>r.required_approvers.includes(this.user?.id) && !r.approvals.includes(this.user?.id)); },
get myRequests() { return this.pending.filter(r=>r.requested_by===this.user?.id); },
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(); },
@@ -104,28 +106,28 @@ document.addEventListener('alpine:init', () => {
},
async run(task, message = '') { if (this.busy) return; this.busy = true; this.error = ''; try { await task(); if (message) { this.toast = message; setTimeout(() => this.toast = '', 4500); } } catch (e) { this.error = e.message; } finally { this.busy = false; } },
readNavigation(){const query=new URLSearchParams(location.search);const raw=query.get('view');const view=['schedules','policies','generation'].includes(raw)?'configuration':raw;return {view:this.nav.some(n=>n.id===view)?view:'calendar',workspace:query.get('workspace')||''};},
readNavigation(){const query=new URLSearchParams(location.search);const raw=query.get('view');const view=['schedules','policies','generation'].includes(raw)?'configuration':raw;return {view:view==='site'||this.nav.some(n=>n.id===view)?view:'calendar',workspace:query.get('workspace')||''};},
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;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='';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;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();});
this.$watch('draftPeople',()=>{if(this.modal==='absence')this.absenceChanged();});
this.$watch('generationDraft',()=>{if(this.generationDraft && this.can('edit_schedules'))this.generationChanged();});
this.$watch('modal',(value,old)=>{if(['variable','generation-rule'].includes(old) && !value){this.bindingEditor=null;this.rulePeopleTarget=null;this.generationChanged();}});
this.$watch('view',v=>{if(v==='configuration')this.loadGenerationDraft();if(this.setupView)this.setupOpen=true;this.writeNavigation();});
this.$watch('view',v=>{if(v==='site'&&this.siteAdmin)this.loadSite();if(v==='configuration')this.loadGenerationDraft();if(this.setupView)this.setupOpen=true;this.writeNavigation();});
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.user = session.user; this.csrf = session.csrf || ''; this.allowSignup = session.allow_signup; this.demo = session.demo;this.demoLifecycle=session.demo_lifecycle; if (this.user) await this.loadWorkspaces(); });
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);
await this.run(async () => { const session = await this.api('/api/session'); this.user = session.user; this.siteAdmin=!!session.site_admin; this.csrf = session.csrf || ''; this.allowSignup = session.allow_signup; this.demo = session.demo;this.demoLifecycle=session.demo_lifecycle; if (this.user) await this.loadWorkspaces(); });
if(this.view==='site'&&this.siteAdmin)await this.loadSite();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.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);
},
async signIn() { await this.run(async () => { const s = await this.api(this.registering ? '/api/register' : '/api/login', 'POST', this.loginForm); this.user = s.user; this.csrf = s.csrf; this.loginForm.password = ''; await this.loadWorkspaces(); }); },
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.detail = null; this.menu = false; this.selected = '';this.directoryState={workspaces:[]};this.startDay=''; }); },
async signIn() { await this.run(async () => { const s = await this.api(this.registering ? '/api/register' : '/api/login', 'POST', this.loginForm); this.user = s.user; this.siteAdmin=!!s.site_admin; this.csrf = s.csrf; this.loginForm.password = ''; await this.loadWorkspaces();if(this.view==='site'&&this.siteAdmin)await this.loadSite(); }); },
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.siteAdmin=false;this.siteData=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();}},
@@ -198,6 +200,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){
@@ -244,8 +247,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);},
@@ -253,6 +256,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;}
@@ -325,7 +329,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'); },
+1 -1
View File
@@ -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()">
+18 -5
View File
@@ -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><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></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 &amp;&amp; !$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><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></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
View File
@@ -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);
}
};}
+19 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
+10 -5
View File
@@ -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
View File
@@ -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];},
+32 -27
View File
@@ -9,6 +9,7 @@
<script defer src="/assets/cytoscape.js"></script>
<script defer src="/assets/generation-map.js"></script>
<script defer src="/assets/generation.js"></script>
<script defer src="/assets/site.js"></script>
<script defer src="/assets/app.js">
</script>
<script defer src="/assets/alpine.js">
@@ -49,6 +50,7 @@
</label>
<label x-show="registering"><span x-text="t('display_name_c7874a')"></span><input x-model="loginForm.display_name" :required="registering">
</label>
<label x-show="registering"><span x-text="t('account_email')"></span><input type="email" autocomplete="email" x-model="loginForm.email" :required="registering" maxlength="254"></label>
<label><span x-text="t('password_8be3c9')"></span><input type="password" :autocomplete="registering ? 'new-password' : 'current-password'" x-model="loginForm.password" required :minlength="registering ? 12 : 1">
</label>
<button class="primary" :disabled="busy" x-text="registering ? t('create_account_aaf374') : t('sign_in_ada2e9')">
@@ -83,6 +85,7 @@
</template>
<button class="setup-toggle" :aria-label="t('setup_cdd7bb')" :class="{active:setupView}" @click="setupOpen=!setupOpen" :aria-expanded="setupOpen"><span class="nav-icon"><span x-text="t('symbol_be22a3')"></span></span><span><span x-text="t('setup_cdd7bb')"></span></span><span class="grow"></span><span x-text="setupOpen?'−':'+'"></span></button>
<div class="setup-nav" x-show="setupOpen"><template x-for="item in nav.filter(n=>['configuration','settings'].includes(n.id))" :key="item.id"><a :href="menuHref(item.id)" :aria-current="view===item.id?'page':null" :class="{active:view===item.id}" @click="menuClick($event,item.id)" x-text="item.name"></a></template></div>
<a class="site-nav-link" x-show="siteAdmin" :aria-label="t('site_title')" :href="menuHref('site')" @click="menuClick($event,'site')" :class="{active:view==='site'}"><span class="nav-icon" aria-hidden="true" x-text="t('symbol_be22a3')"></span><span class="site-nav-label" x-text="t('site_title')"></span></a>
</nav>
<div class="sidebar-bottom">
@@ -120,7 +123,8 @@
</div>
</header>
<div class="content">
<template x-if="!workspace">
<!-- SITE_ADMIN -->
<template x-if="!workspace && view!=='site'">
<section class="empty welcome">
<span class="empty-symbol"><span x-text="t('symbol_4d82f1')"></span></span>
<h1><span x-text="t('your_team_s_next_chapter_starts_here_4e986d')"></span></h1>
@@ -129,7 +133,7 @@
</button>
</section>
</template>
<template x-if="workspace">
<template x-if="workspace && view!=='site'">
<div>
<div class="page-heading">
<div>
@@ -140,16 +144,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' &amp;&amp; !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>
@@ -403,12 +405,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-show="modal!=='workspace'" x-text="['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:t('team_access_8fb357'),workspace:t('new_workspace_b21b86'),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:t('team_access_8fb357'),workspace:t('new_workspace_b21b86'),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>
@@ -456,11 +458,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>
@@ -468,6 +470,7 @@
</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">
@@ -481,6 +484,7 @@
<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><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">
@@ -504,17 +508,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>
@@ -595,7 +600,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'">
@@ -644,7 +649,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>
@@ -855,7 +860,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>
@@ -908,17 +913,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)" x-text="busy?t('saving_56a228'):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)" x-text="busy?t('saving_56a228'):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>
+731 -31
View File
@@ -1,4 +1,19 @@
{
"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é."
},
"meguri_on_call_together_f7894c": {
"en": "Meguri · On-call together",
"ru": "Meguri · Дежурим вместе",
@@ -1765,9 +1780,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.",
@@ -2295,9 +2310,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",
@@ -3230,9 +3245,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.",
@@ -3290,9 +3305,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.",
@@ -3310,9 +3325,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",
@@ -3805,9 +3820,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",
@@ -4029,11 +4044,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 символов",
@@ -4049,11 +4059,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": "Этот запрос уже обработан",
@@ -5720,9 +5725,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",
@@ -5953,5 +5958,700 @@
"en": "Select a pass above to inspect its values.",
"ru": "Выберите проход выше, чтобы увидеть его значения.",
"fr": "Sélectionnez un passage ci-dessus pour voir ses valeurs."
},
"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."
},
"site_title": {
"en": "Site administration",
"ru": "Администрирование сайта",
"fr": "Administration du site"
},
"site_scope": {
"en": "Installation settings",
"ru": "Настройки сайта",
"fr": "Paramètres du site"
},
"site_scope_hint": {
"en": "Manage accounts and workspace ownership. Workspace access remains explicit.",
"ru": "Управление аккаунтами и владельцами workspace. Доступ к workspace назначается отдельно.",
"fr": "Gérez les comptes et les propriétaires des espaces. Les accès restent explicites."
},
"site_users": {
"en": "Users",
"ru": "Пользователи",
"fr": "Utilisateurs"
},
"site_workspaces": {
"en": "Workspaces",
"ru": "Рабочие пространства",
"fr": "Espaces de travail"
},
"site_authentication": {
"en": "Authentication",
"ru": "Авторизация",
"fr": "Authentification"
},
"site_audit": {
"en": "Administration history",
"ru": "Журнал администрирования",
"fr": "Historique administratif"
},
"site_refresh": {
"en": "Refresh",
"ru": "Обновить",
"fr": "Actualiser"
},
"site_sections": {
"en": "Administration sections",
"ru": "Разделы администрирования",
"fr": "Sections administratives"
},
"site_denied": {
"en": "Site administrator access is required.",
"ru": "Нужны права администратора сайта.",
"fr": "Le rôle administrateur du site est requis."
},
"site_search": {
"en": "Search",
"ru": "Поиск",
"fr": "Rechercher"
},
"site_administrator": {
"en": "Site administrator",
"ru": "Администратор сайта",
"fr": "Administrateur du site"
},
"site_disabled": {
"en": "Account suspended",
"ru": "Аккаунт заблокирован",
"fr": "Compte suspendu"
},
"site_edit": {
"en": "Edit",
"ru": "Редактировать",
"fr": "Modifier"
},
"site_owners": {
"en": "Owners",
"ru": "Владельцы",
"fr": "Propriétaires"
},
"site_name": {
"en": "Name",
"ru": "Название",
"fr": "Nom"
},
"site_add_owner": {
"en": "Grant owner access to",
"ru": "Выдать права владельца",
"fr": "Accorder le rôle propriétaire à"
},
"site_owner_hint": {
"en": "Existing owners keep access. To work inside this workspace, explicitly add yourself as an owner.",
"ru": "Текущие владельцы сохранят доступ. Для работы внутри workspace явно назначьте себя владельцем.",
"fr": "Les propriétaires actuels conservent leur accès. Ajoutez-vous explicitement pour intervenir dans cet espace."
},
"site_suspend_hint": {
"en": "Suspension blocks login and ends sessions. Scheduling assignments and history are retained.",
"ru": "Блокировка запрещает вход и завершает сессии. Дежурства и история сохраняются.",
"fr": "La suspension bloque la connexion et ferme les sessions. Les plannings et l’historique sont conservés."
},
"site_cancel": {
"en": "Cancel",
"ru": "Отмена",
"fr": "Annuler"
},
"site_save": {
"en": "Save changes",
"ru": "Сохранить изменения",
"fr": "Enregistrer"
},
"site_saved": {
"en": "Changes saved",
"ru": "Изменения сохранены",
"fr": "Modifications enregistrées"
},
"site_confirm": {
"en": "Apply these administrative changes?",
"ru": "Применить эти административные изменения?",
"fr": "Appliquer ces modifications administratives ?"
},
"site_auth_draft": {
"en": "Configuration draft only. OIDC login is not connected yet. Saving these settings does not change the login page, password login or signup.",
"ru": "Пока это только конфигурация. OIDC-вход ещё не подключён. Сохранение не меняет страницу входа, парольную авторизацию и регистрацию.",
"fr": "Configuration uniquement. La connexion OIDC n’est pas encore active. Enregistrer ne modifie ni la page de connexion, ni les mots de passe, ni les inscriptions."
},
"site_login_methods": {
"en": "Login methods",
"ru": "Способы входа",
"fr": "Méthodes de connexion"
},
"site_password_enabled": {
"en": "Allow password login",
"ru": "Разрешить вход по паролю",
"fr": "Autoriser la connexion par mot de passe"
},
"site_oidc_enabled": {
"en": "Enable OIDC single sign-on",
"ru": "Включить единый вход OIDC",
"fr": "Activer la connexion unique OIDC"
},
"site_oidc_provider": {
"en": "OIDC provider",
"ru": "Провайдер OIDC",
"fr": "Fournisseur OIDC"
},
"site_issuer": {
"en": "Issuer URL",
"ru": "Issuer URL",
"fr": "URL de l’émetteur"
},
"site_issuer_hint": {
"en": "Provider issuer URL, for example https://identity.example.com/realms/company. Discovery will provide the authorization, token and key endpoints.",
"ru": "Адрес issuer, например https://identity.example.com/realms/company. Через discovery будут определяться адреса авторизации, токенов и ключей.",
"fr": "URL de l’émetteur, par exemple https://identity.example.com/realms/company. La découverte fournira les points d’autorisation, de jetons et de clés."
},
"site_client_id": {
"en": "Client ID",
"ru": "Client ID",
"fr": "ID client"
},
"site_secret_env": {
"en": "Client secret environment variable",
"ru": "Переменная окружения с client secret",
"fr": "Variable d’environnement du secret client"
},
"site_secret_hint": {
"en": "Enter the variable name, never the secret itself. The secret will be supplied by the deployment.",
"ru": "Укажите имя переменной, а не сам секрет. Значение секрета задаётся при развёртывании.",
"fr": "Indiquez le nom de la variable, jamais le secret. Sa valeur sera fournie par le déploiement."
},
"site_login_experience": {
"en": "Login page and accounts",
"ru": "Страница входа и аккаунты",
"fr": "Page de connexion et comptes"
},
"site_button_label": {
"en": "Login button label",
"ru": "Название кнопки входа",
"fr": "Libellé du bouton de connexion"
},
"site_new_accounts": {
"en": "Allow new accounts through SSO",
"ru": "Разрешить создание аккаунтов через SSO",
"fr": "Autoriser les nouveaux comptes via SSO"
},
"site_new_accounts_hint": {
"en": "New accounts will not automatically receive workspace access or administrator rights.",
"ru": "Новые аккаунты не получат автоматически доступ к workspace или права администратора.",
"fr": "Les nouveaux comptes ne recevront automatiquement aucun accès aux espaces ni rôle administrateur."
},
"site_save_draft": {
"en": "Save configuration draft",
"ru": "Сохранить конфигурацию",
"fr": "Enregistrer la configuration"
},
"site_deployment": {
"en": "Deployment",
"ru": "Развёртывание",
"fr": "Déploiement"
},
"site_action_bootstrap": {
"en": "Administrator bootstrap",
"ru": "Назначение первого администратора",
"fr": "Initialisation administrateur"
},
"site_action_user": {
"en": "Account updated",
"ru": "Аккаунт изменён",
"fr": "Compte modifié"
},
"site_action_workspace": {
"en": "Workspace updated",
"ru": "Workspace изменён",
"fr": "Espace modifié"
},
"site_action_authentication": {
"en": "Authentication draft updated",
"ru": "Настройки авторизации изменены",
"fr": "Configuration d’authentification modifiée"
},
"site_invalid": {
"en": "Check the configuration fields. Use an HTTPS issuer without credentials, query or fragment and a secret variable name beginning MEGURI_OIDC_.",
"ru": "Проверьте поля. Используйте HTTPS issuer без учётных данных, параметров и фрагмента; имя переменной секрета должно начинаться с MEGURI_OIDC_.",
"fr": "Vérifiez les champs. Utilisez un émetteur HTTPS sans identifiants, paramètres ni fragment, et une variable de secret commençant par MEGURI_OIDC_."
},
"site_last_admin": {
"en": "The last active site administrator cannot be suspended or demoted.",
"ru": "Нельзя заблокировать или лишить роли последнего активного администратора сайта.",
"fr": "Le dernier administrateur actif ne peut être suspendu ni rétrogradé."
},
"site_stale": {
"en": "Administration data changed. Refresh before saving again.",
"ru": "Данные администрирования изменились. Обновите страницу перед сохранением.",
"fr": "Les données ont changé. Actualisez avant d’enregistrer."
},
"site_auth_method": {
"en": "Keep at least one login method selected in the configuration.",
"ru": "В конфигурации должен быть выбран хотя бы один способ входа.",
"fr": "Gardez au moins une méthode de connexion dans la configuration."
},
"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"
}
}
+24
View File
@@ -0,0 +1,24 @@
<section class="site-admin" x-show="view==='site'" x-cloak>
<div class="page-heading"><div><div class="eyebrow" x-text="t('site_scope')"></div><h1 x-text="t('site_title')"></h1><p class="muted" x-text="t('site_scope_hint')"></p></div><button type="button" @click="loadSite()" :disabled="siteLoading||busy" x-text="t('site_refresh')"></button></div>
<p class="info-note" x-show="!siteAdmin" x-text="t('site_denied')"></p>
<template x-if="siteAdmin && siteData"><div class="site-layout">
<nav class="site-tabs" :aria-label="t('site_sections')"><template x-for="section in siteSections()" :key="section.id"><button type="button" :class="{active:siteSection===section.id}" :aria-current="siteSection===section.id?'page':null" @click="siteTab(section.id)" x-text="section.label"></button></template></nav>
<div class="site-body">
<label x-show="['users','workspaces'].includes(siteSection)"><span x-text="t('site_search')"></span><input x-model="siteSearch" type="search"></label>
<section x-show="siteSection==='users'"><h2 x-text="t('site_users')"></h2><div class="site-list"><template x-for="row in siteUsers()" :key="row.user.id"><article class="site-row"><div><strong x-text="row.user.display_name"></strong><div class="muted" x-text="row.user.username"></div><div class="muted" x-text="row.user.email"></div><span class="badge" x-show="row.access.administrator" x-text="t('site_administrator')"></span><span class="badge" x-show="row.access.disabled" x-text="t('site_disabled')"></span></div><button @click="editSiteUser(row)" x-text="t('site_edit')"></button></article></template></div></section>
<section x-show="siteSection==='workspaces'"><h2 x-text="t('site_workspaces')"></h2><div class="site-list"><template x-for="row in siteWorkspaces()" :key="row.id"><article class="site-row"><div><strong x-text="row.name"></strong><div class="muted"><span x-text="t('site_owners')"></span>: <span x-text="row.owners.map(id=>siteUserName(id)).join(', ')"></span></div></div><button @click="editSiteWorkspace(row)" x-text="t('site_edit')"></button></article></template></div></section>
<form class="site-editor" x-show="siteEdit" @submit.prevent="saveSiteEdit()"><h3 x-text="t('site_edit')"></h3>
<template x-if="siteEdit?.kind==='user'"><div><label><span x-text="t('display_name_c7874a')"></span><input x-model="siteEdit.display_name" required maxlength="120"></label><label class="check"><input type="checkbox" x-model="siteEdit.administrator"><span x-text="t('site_administrator')"></span></label><label class="check"><input type="checkbox" x-model="siteEdit.disabled"><span x-text="t('site_disabled')"></span></label><p class="muted" x-text="t('site_suspend_hint')"></p></div></template>
<template x-if="siteEdit?.kind==='workspace'"><div><label><span x-text="t('site_name')"></span><input x-model="siteEdit.name" required maxlength="120"></label><label><span x-text="t('site_add_owner')"></span><select x-model="siteEdit.owner_id" :aria-label="t('site_add_owner')" required><template x-for="row in siteData.users.filter(x=>!x.access.disabled)" :key="row.user.id"><option :value="row.user.id" x-text="row.user.username"></option></template></select></label><p class="muted" x-text="t('site_owner_hint')"></p></div></template>
<div class="actions"><button type="button" @click="siteEdit=null" x-text="t('site_cancel')"></button><button class="primary" :disabled="busy" x-text="t('site_save')"></button></div>
</form>
<section x-show="siteSection==='authentication'"><h2 x-text="t('site_authentication')"></h2><p class="info-note" x-text="t('site_auth_draft')"></p>
<form @submit.prevent="saveSiteAuth()" class="site-settings-form"><fieldset><legend x-text="t('site_login_methods')"></legend><label class="check"><input type="checkbox" x-model="siteAuth.password_enabled"><span x-text="t('site_password_enabled')"></span></label><label class="check"><input type="checkbox" x-model="siteAuth.oidc_enabled"><span x-text="t('site_oidc_enabled')"></span></label></fieldset>
<fieldset><legend x-text="t('site_oidc_provider')"></legend><label><span x-text="t('site_issuer')"></span><input type="url" x-model="siteAuth.issuer" :required="siteAuth.oidc_enabled" maxlength="2048"></label><p class="muted" x-text="t('site_issuer_hint')"></p><label><span x-text="t('site_client_id')"></span><input x-model="siteAuth.client_id" :required="siteAuth.oidc_enabled" maxlength="500"></label><label><span x-text="t('site_secret_env')"></span><input x-model="siteAuth.client_secret_env" required pattern="MEGURI_OIDC_[A-Z0-9_]+"></label><p class="muted" x-text="t('site_secret_hint')"></p></fieldset>
<fieldset><legend x-text="t('site_login_experience')"></legend><label><span x-text="t('site_button_label')"></span><input x-model="siteAuth.button_label" :required="siteAuth.oidc_enabled" maxlength="80"></label><label class="check"><input type="checkbox" x-model="siteAuth.allow_new_users"><span x-text="t('site_new_accounts')"></span></label><p class="muted" x-text="t('site_new_accounts_hint')"></p></fieldset><button class="primary" :disabled="busy" x-text="t('site_save_draft')"></button>
</form>
</section>
<section x-show="siteSection==='audit'"><h2 x-text="t('site_audit')"></h2><template x-for="event in siteData.audit" :key="event.id"><article class="site-audit-row"><strong x-text="t('site_action_'+event.action.split('.').at(-1))"></strong><span x-text="event.actor_id?siteUserName(event.actor_id):t('site_deployment')"></span><time x-text="formatTime(event.created_at)"></time><pre x-text="JSON.stringify(event.details,null,2)"></pre></article></template></section>
</div>
</div></template>
</section>
+14
View File
@@ -0,0 +1,14 @@
// Installation settings have independent sections and explicit save boundaries.
function siteUi(){return {
siteAdmin:false,siteData:null,siteSection:'users',siteSearch:'',siteEdit:null,siteAuth:null,siteLoading:false,
siteSections(){return ['users','workspaces','authentication','audit'].map(id=>({id,label:tr('site_'+id)}));},
siteUsers(){const q=this.siteSearch.toLowerCase();return (this.siteData?.users||[]).filter(x=>(x.user.username+' '+x.user.display_name+' '+x.user.email).toLowerCase().includes(q));},
siteWorkspaces(){const q=this.siteSearch.toLowerCase();return (this.siteData?.workspaces||[]).filter(x=>x.name.toLowerCase().includes(q));},
siteUserName(id){return this.siteData?.users.find(x=>x.user.id===id)?.user.username||id;},
async loadSite(){if(!this.siteAdmin||this.siteLoading)return;this.siteLoading=true;try{this.siteData=await this.api('/api/site');this.siteAuth=copy(this.siteData.settings.authentication.value);}catch(e){this.error=e.message;}finally{this.siteLoading=false;}},
siteTab(id){this.siteSection=id;this.siteSearch='';this.siteEdit=null;},
editSiteUser(row){this.siteEdit={kind:'user',id:row.user.id,display_name:row.user.display_name,administrator:row.access.administrator,disabled:row.access.disabled};},
editSiteWorkspace(row){this.siteEdit={kind:'workspace',id:row.id,name:row.name,workspace_revision:row.revision,owner_id:row.owners[0]||''};},
async saveSiteEdit(){const edit=copy(this.siteEdit);if(!confirm(tr('site_confirm')))return;await this.run(async()=>{await this.api('/api/site/'+edit.kind,'PUT',{...edit,revision:this.siteData.revision});this.siteEdit=null;const session=await this.api('/api/session');this.siteAdmin=!!session.site_admin;this.user=session.user;if(this.siteAdmin)await this.loadSite();else{this.siteData=null;this.view='calendar';}await this.loadWorkspaces();},tr('site_saved'));},
async saveSiteAuth(){await this.run(async()=>{await this.api('/api/site/authentication','PUT',{revision:this.siteData.revision,section_revision:this.siteData.settings.authentication.revision,value:this.siteAuth});await this.loadSite();},tr('site_saved'));}
};}
+1 -1
View File
@@ -12,7 +12,7 @@ assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
const fmt=iso=>iso.split('-').reverse().join('-');
try {
const unique=Date.now();await page.goto(base);await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('absence-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Absence tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByLabel('Username',{exact:true}).fill('absence-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Absence tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save();
const dates=await page.evaluate(async()=>{
+53
View File
@@ -0,0 +1,53 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=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));
const username='account-person-'+Date.now(),email=username+'@example.com';
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill(username);
await page.getByLabel('Display name',{exact:true}).fill('Account person');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
const emailInput=page.getByLabel('Email address',{exact:true});
assert.equal(await emailInput.evaluate(e=>e.required),true);
await page.getByRole('button',{name:'Create account',exact:true}).click();
assert.equal(await emailInput.evaluate(e=>e.validity.valueMissing),true);
await emailInput.fill(email.toUpperCase());
await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Account identity');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
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;});
+49
View File
@@ -0,0 +1,49 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=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));
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('binding-path-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Path tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Binding path regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
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;});
+1 -1
View File
@@ -20,7 +20,7 @@ fs.mkdirSync('test-results',{recursive:true});
await page.getByLabel('Username',{exact:true}).fill('calendars-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Calendar tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).waitFor();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill(label);await save();
+8 -1
View File
@@ -29,7 +29,7 @@ fs.mkdirSync('test-results',{recursive:true});
await page.getByLabel('Username',{exact:true}).fill('configuration-'+unique);
await page.getByLabel('Display name',{exact:true}).fill('Configuration tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await dialog().getByLabel('Name',{exact:true}).fill('Configuration '+unique);await save();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
@@ -70,8 +70,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']);
+53
View File
@@ -0,0 +1,53 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=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));
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('contact-approval-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Contact planner');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).waitFor();
const fixture=await page.evaluate(async()=>{
const a=Alpine.$data(document.body);
const r=await a.api('/api/workspaces','POST',{name:'Contact approval regression'});
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;});
+92 -10
View File
@@ -1,13 +1,95 @@
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('meguri-demo-only');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}).click();await page.screenshot({patLine 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}=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));
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('map-origin-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Path tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Layered map regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
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;});
+46
View File
@@ -0,0 +1,46 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=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));
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('generation-team-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Path tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Team escalation regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
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;});
+3 -3
View File
@@ -43,7 +43,7 @@ fs.mkdirSync('test-results',{recursive:true});
const ctx=await browser.newContext();const api=ctx.request;const login=await api.post(base+'/api/login',{headers:{Origin:base},data:{username,password:'meguri-demo-only'}});assert.equal(login.status(),200);const response=await api.get(`${base}/api/workspaces/${denied.id}`);assert.equal(response.status(),404);await ctx.close();
}
// Mutating UI checks use a separate account, leaving the examples untouched.
await page.getByRole('button',{name:'Sign out',exact:true}).click();await page.getByRole('button',{name:'New here? Create an account',exact:true}).click();const unique=Date.now();await page.getByLabel('Username',{exact:true}).fill('matrix-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Matrix tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'Sign out',exact:true}).click();await page.getByRole('button',{name:'New here? Create an account',exact:true}).click();const unique=Date.now();await page.getByLabel('Username',{exact:true}).fill('matrix-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Matrix tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Matrix '+unique);await save();
await openSetup(page,'Variables & rules');
await page.evaluate(()=>{const a=Alpine.$data(document.body),projects={id:crypto.randomUUID(),name:'projects',node:a.newNode('list')},responsible={id:crypto.randomUUID(),name:'responsible',node:a.newNode('dictionary')};for(const name of ['A','B']){projects.node.entries.push(a.newNode('text',name));const entry=a.newNode('list',null,name);entry.entries=['alice@example.com','bob@example.com'].map(email=>a.newNode('person',email));responsible.node.entries.push(entry);}const iteration={id:crypto.randomUUID(),name:'Project',source:{kind:'variable',variable:projects.id,path:[]}},block=a.newBlock('rotation');block.fields.name={kind:'item',iteration:iteration.id,path:[],suffix:' on call'};block.fields.participants={kind:'lookup',variable:responsible.id,key:{kind:'item',iteration:iteration.id,path:[]},path:[]};a.generationDraft.variables.push(projects,responsible);a.generationDraft.rules.push({id:crypto.randomUUID(),name:'Project rotations',enabled:true,iterations:[iteration],when:null,blocks:[block]});a.generationChanged();});await checked();
@@ -57,9 +57,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();
+101
View File
@@ -0,0 +1,101 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser,openSetup}=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));
const suffix=Date.now(),username='sync-owner-'+suffix,password='local-workflow-test-password';
const colleague='sync-colleague-'+suffix;
try{
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill(username);
await page.getByLabel('Display name',{exact:true}).fill('Workflow owner');
await page.getByLabel('Password',{exact:true}).fill(password);
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.waitForFunction(()=>Alpine.$data(document.body).user && !Alpine.$data(document.body).busy);
await page.getByRole('button',{name:'New workspace',exact:true}).click();
await page.getByRole('dialog').getByLabel('Name',{exact:true}).fill('Shared publication test');
await page.getByRole('dialog').getByRole('button',{name:'Save changes',exact:true}).click();
await page.getByRole('dialog').waitFor({state:'hidden'});
const workspace=await page.evaluate(()=>Alpine.$data(document.body).workspace.id);
const secondContext=await browser.newContext({locale:'en-US'});
const registered=await secondContext.request.post(base+'/api/register',{headers:{Origin:base},data:{username:colleague,email:colleague+'@example.com',password,display_name:'Workflow colleague'}});assert.equal(registered.status(),200);
await page.evaluate(async colleague=>{const a=Alpine.$data(document.body);await a.api(a.endpoint('/members'),'PUT',{revision:a.workspace.revision,username:colleague,permissions:['view','edit_schedules','manage_integration']});await a.loadWorkspace();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();},colleague);
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.getByRole('button',{name:'Already registered? Sign in',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 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);});
+74
View File
@@ -0,0 +1,74 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=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));
await page.goto(base);
await page.getByRole('button',{name:'New here? Create an account'}).click();
await page.getByLabel('Username',{exact:true}).fill('config-transfer-'+Date.now());
await page.getByLabel('Display name',{exact:true}).fill('Path tester');
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
await page.getByLabel('Email address',{exact:true}).fill('browser-'+Date.now()+'-'+Math.random().toString(16).slice(2)+'@example.com');await page.getByRole('button',{name:'Create account',exact:true}).click();
await page.getByRole('button',{name:'New workspace',exact:true}).click();
const dialog=page.getByRole('dialog');
await dialog.getByLabel('Name',{exact:true}).fill('Portable config regression');
await dialog.getByRole('button',{name:'Save changes',exact:true}).click();
await dialog.waitFor({state:'hidden'});
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;});
+65
View File
@@ -0,0 +1,65 @@
const assert=require('node:assert/strict');
const fs=require('node:fs');
const {launchBrowser}=require('./support/browser.cjs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3011';
const username=process.env.MEGURI_BROWSER_SITE_USERNAME;
assert(username,'Set MEGURI_BROWSER_SITE_USERNAME to a dedicated test administrator');
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
(async()=>{
const browser=await launchBrowser();
try{
const context=await browser.newContext({locale:'en-US',viewport:{width:1400,height:1050}});
const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());
await page.goto(base+'/?view=site');
await page.getByLabel('Username',{exact:true}).fill(username);
await page.getByLabel('Password',{exact:true}).fill(process.env.MEGURI_BROWSER_SITE_PASSWORD||'local-browser-test-password');
await page.getByRole('button',{name:'Sign in',exact:true}).click();
await page.getByRole('link',{name:'Site administration',exact:true}).click();
await page.waitForFunction(()=>Alpine.$data(document.body).siteData);
const root=page.locator('.site-admin');
await root.getByLabel('Search',{exact:true}).fill(username);
await root.getByRole('button',{name:'Edit',exact:true}).click();
await root.getByLabel('Display name',{exact:true}).fill('Administration browser test');
await root.getByRole('button',{name:'Save changes',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).busy&&!Alpine.$data(document.body).siteEdit);
await root.getByRole('button',{name:'Authentication',exact:true}).click();
assert(await root.getByText(/Configuration draft only/).isVisible());
await root.getByLabel('Enable OIDC single sign-on',{exact:true}).check();
await root.getByLabel('Allow password login',{exact:true}).uncheck();
await root.getByLabel('Issuer URL',{exact:true}).fill('https://identity.example.com/realms/company');
await root.getByLabel('Client ID',{exact:true}).fill('meguri-browser-test');
await root.getByLabel('Login button label',{exact:true}).fill('Company SSO');
await root.getByRole('button',{name:'Save configuration draft',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).busy);
assert.equal(await page.locator('.error:visible').count(),0);
await page.reload();await page.waitForFunction(()=>Alpine.$data(document.body).siteData);
await root.getByRole('button',{name:'Authentication',exact:true}).click();
assert.equal(await root.getByLabel('Login button label',{exact:true}).inputValue(),'Company SSO');
assert.equal(await root.getByLabel('Allow password login',{exact:true}).isChecked(),false);
fs.mkdirSync('test-results',{recursive:true});await page.screenshot({path:'test-results/site-admin-desktop.png',fullPage:true});
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/site-admin-mobile.png',fullPage:true});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
// Current password authentication stays active despite the saved draft.
const guest=await browser.newContext({locale:'en-US'});const other=await guest.newPage();await other.goto(base);
await other.getByLabel('Username',{exact:true}).fill(username);await other.getByLabel('Password',{exact:true}).fill(process.env.MEGURI_BROWSER_SITE_PASSWORD||'local-browser-test-password');await other.getByRole('button',{name:'Sign in',exact:true}).click();await other.waitForFunction(()=>Alpine.$data(document.body).user);
// Non-admins cannot enumerate the site, even with direct requests.
const ordinary=await browser.newContext();const userPage=await ordinary.newPage();await userPage.goto(base);
const denied=await userPage.evaluate(async()=>{const username='site-member-'+Date.now();const r=await fetch('/api/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,email:username+'@example.com',display_name:'Ordinary user',password:'local-browser-test-password'})});if(!r.ok)throw new Error('signup');return (await fetch('/api/site')).status;});assert.equal(denied,403);
const created=await userPage.evaluate(async()=>{const session=await (await fetch('/api/session')).json();const result=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':session.csrf},body:JSON.stringify({name:'Site UI workspace '+Date.now()})});if(!result.ok)throw new Error('workspace');return {workspace:await result.json(),user:session.user};});
await page.setViewportSize({width:1400,height:1050});
await root.getByRole('button',{name:'Refresh',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).siteLoading);
await root.getByRole('button',{name:'Workspaces',exact:true}).click();
await root.getByLabel('Search',{exact:true}).fill('Site UI workspace');
await root.getByRole('button',{name:'Edit',exact:true}).last().click();
await root.getByLabel('Name',{exact:true}).fill('Site UI renamed workspace');
await root.getByLabel('Grant owner access to',{exact:true}).selectOption({label:username});
await root.getByRole('button',{name:'Save changes',exact:true}).click();
await page.waitForFunction(()=>!Alpine.$data(document.body).busy&&!Alpine.$data(document.body).siteEdit);
assert.equal(await page.locator('.error:visible').count(),0);
assert(await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.some(w=>w.name==='Site UI renamed workspace')));
assert.deepEqual(errors,[]);
await context.close();await guest.close();await ordinary.close();
console.log('PASS: site navigation, profile editing, persisted OIDC draft, unchanged password login, denied non-admin access, desktop/mobile');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
+437
View File
@@ -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": ""
}
}
]
}
+104
View File
@@ -688,3 +688,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());
}
+1741 -3
View File
File diff suppressed because it is too large. Load diff
+459
View File
@@ -0,0 +1,459 @@
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()),
});
}
(
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());
}
+180 -20
View File
@@ -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!({}))
}
+36 -6
View File
@@ -1,8 +1,8 @@
use chrono::{Duration, Utc};
use cot::db::Model;
use cot::db::{Model, query};
use meguri::{
domain::*,
models::UserRow,
models::{HistoryRow, UserRow},
service,
store::{Store, WorkspaceRecord},
};
@@ -24,19 +24,26 @@ impl Fixture {
for name in ["Alice", "Bob", "Carol"] {
let user = User {
language: None,
id: Uuid::new_v4(),
person: Person {
id: Uuid::new_v4(),
user_id: None,
display_name: name.into(),
email: Some(format!("{}@example.com", Uuid::new_v4())),
country: Some("CY".into()),
},
username: format!("test-{}", Uuid::new_v4().simple()),
display_name: name.into(),
country: "CY".into(),
timezone: None,
};
let mut user = user;
user.person.user_id = Some(user.id);
UserRow {
language: None,
demo_deleted_at: None,
id: user.id.to_string(),
username: user.username.clone(),
display_name: user.display_name.clone(),
country: user.country.clone(),
country: user.country.clone().unwrap(),
email: user.email.clone().unwrap(),
timezone: None,
password_hash: "test-only-unusable-password".into(),
created_at: Utc::now(),
@@ -121,6 +128,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