diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..b24a296 Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example index 5b349ea..de6dcb1 100644 --- a/.env.example +++ b/.env.example @@ -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"}' diff --git a/docs/architecture.md b/docs/architecture.md index 72f7899..4b1f7c0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/development.md b/docs/development.md index a9077e2..763e448 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. diff --git a/docs/jsm-acceptance.md b/docs/jsm-acceptance.md index 252c75e..3f87f44 100644 --- a/docs/jsm-acceptance.md +++ b/docs/jsm-acceptance.md @@ -30,3 +30,13 @@ Do not assume an organization's sandbox copies its on-call configuration: Atlass 6. Repeat unchanged, simulate partial publication recovery, change a managed remote resource, verify drift blocking and explicit overwrite, then remove only resources created by this test. Record redacted requests/responses, API dates, plan and observed limits. Keep ordinary CI on the local fake; live acceptance must be explicit and restricted to the dedicated test destination. See [testing.md](testing.md) for the provider acceptance contract. + +## Live compatibility observations, 2026-09-28 + +User-authorized checks confirmed that this destination requires the `og-` prefix on its team ID (prefixed routing GET: 200; bare UUID: 404). Schedule listing rejects `size=100` with `Field: size, error: must be less than or equal to 50`; pages of 50 and 20 succeed. + +A temporary disabled schedule, unconnected to routing and deleted after inspection, exposed the native-rotation precision limitation. Rotation starts submitted as `10:01:05.053868Z` and `10:17:05.053Z` returned as `10:00:05.053Z` and `10:30:05.053Z`. Starts at `10:30:00Z` and `10:00:00Z` were preserved. The returned rotation array also differed in order. Do not treat arbitrary exact intervals as verified or relax drift checks to accept these shifted boundaries. A subsequent disabled-schedule probe confirmed arbitrary future override boundaries and millisecond precision in the final timeline over a `noone` base. Creating an override with a past start returned HTTP 400. The adapter therefore uses the current assignee as the base and future overrides for exact transitions. A further disabled-schedule probe verified the current-user base followed by a future `noone` override in the final timeline at the exact millisecond boundary. A roughly 90-day override and an overlapping replacement were accepted; the older override retained its boundaries and could be deleted after replacement. All temporary probes were deleted. Full application-level acceptance, including ongoing override replacement, remains necessary on the dedicated test team; individual endpoint probes do not certify production reconciliation. + +Readable resource names and legacy-name recovery are covered locally. They do not resolve this time-boundary mismatch: a resource left behind by a failed read-back remains protected by drift checks. + +Compact recurring rotations use the documented daily/weekly rotation API with ordered participants and a finite end date. Local regression coverage includes calendar-day phase selection across both DST changes and in-place conversion from per-shift rotations. Compare the real provider timeline across a handover and DST boundary during the next explicitly requested live publication; no live provider writes were made for this optimization. diff --git a/docs/portable-config-v1.md b/docs/portable-config-v1.md new file mode 100644 index 0000000..820c76e --- /dev/null +++ b/docs/portable-config-v1.md @@ -0,0 +1,49 @@ +# Portable configuration: meguri/v1 + +The Import / Export action in Review changes transfers only variables and generators from the shared draft. It excludes credentials, provider settings, manual resources, memberships and calendar history. Export is JSON; the textarea supports copying and downloading. Import parses and previews on the server after typing stops, without saving. Applying saves the reviewed shared draft, with both workspace and shared-draft revision checks. Saving checked changes and explicit provider synchronization retain their existing meanings. + +## Merge and identity + +The top-level object requires `format: "meguri/v1"`, `variables`, and `generators`. Unknown fields are errors. Arrays preserve order. Match variable and generator names case-insensitively after trimming, update matching inputs in place, append new inputs, and retain omitted inputs. Names must be unique. Renaming creates a new input; removing inputs remains an explicit UI action. Existing generator order and routing insertion position remain unchanged; new generators append in document order. A generator entry replaces that generator's complete iterations, condition and blocks, so the preview can include removed generated resources. + +Nested variable nodes and output blocks have optional `id` strings. Export includes them. These are portable identity keys, not workspace access identifiers. Keep them when changing values or reordering entries. Import into another workspace remaps them deterministically within the owning variable/generator; repeat import preserves generated identity. For hand-written documents, use descriptive block IDs (for example `daytime`). If omitted, IDs fall back to existing positions or deterministic positions; moving ID-less list entries does not preserve logical item identity. Iterations are referenced by their names within a generator. Generated output bindings identify `[generator-name, block-id]`. + +## Types and bindings + +A variable has `name` and `node`. Nodes use `kind`, optional scalar `value`, and optional `entries`. Kinds are `text`, `person`, `number`, `boolean`, `date`, `time`, `duration`, `list`, and `dictionary`. Dictionary entries also have a `key`. Dates are ISO dates; times are local scheduling times; duration units follow the generator field contract. Every `person` node and literal person binding uses an email address. Existing unique workspace contacts resolve exactly by email. A new email becomes an email-only scheduling contact during generation, never a login or workspace member. A person with no stored scheduling email blocks export until it is supplied. + +A generator has `name`, `enabled` (default true), `iterations`, optional `when`, and `blocks`. Blocks use `id`, `kind`, `fields`, and optionally `steps` and `conditions`, matching the visual editor. Binding objects use `kind` (`literal`, `variable`, `item`, `key`, `lookup`, `list`, `output`) with the appropriate `value`, `variable` name, `iteration` name, `output` pair, nested lookup `key`, or binding `items`. `path` is an array of dictionary keys or one-based list indexes written as strings; `prefix` and `suffix` modify text. A person used inside a generic literal (for example a predicate comparison) uses `{"$person":"alice@example.com"}` to preserve its type without a workspace UUID. No expressions or executable scripts are evaluated. + +Literal fallback schedules and escalation references use exact names in the target workspace. They must resolve uniquely; import never creates manual resources. All generation, permission, coverage and provider-capability validation runs before Apply becomes available. External resource links should normally use output bindings for portability. + +```json +{ + "format": "meguri/v1", + "variables": [ + {"name": "owners", "node": {"kind": "list", "entries": [ + {"id": "alice", "kind": "person", "value": "alice@example.com"}, + {"id": "bob", "kind": "person", "value": "bob@example.com"} + ]}} + ], + "generators": [ + { + "name": "Weekly coverage", + "blocks": [{ + "id": "weekly", + "kind": "rotation", + "fields": { + "name": {"kind": "literal", "value": "Platform on-call"}, + "participants": {"kind": "variable", "variable": "owners"}, + "anchor": {"kind": "literal", "value": "2026-09-28"}, + "handover": {"kind": "literal", "value": "11:00"}, + "shift_days": {"kind": "literal", "value": 7} + } + }] + } + ] +} +``` + +Export an existing visual rule for examples of conditions, escalation steps, repeats, nested lookups and route filters. Import is limited to 1 MB of text and the existing bounded generation limits. Parse errors retain the input and disable application. The preview lists creates, updates and unchanged inputs separately from generated resource changes; it also lists new email-only contacts and displays validation errors and pending-request cancellations. + +Escalation steps accept `team_random: true` to notify one random member of the workspace provider team, or `team_all: true` for everyone. Both default to false and are mutually exclusive with each other and `user: true`. Team modes ignore the recipient binding; export writes a null literal for it. diff --git a/docs/portable-config.md b/docs/portable-config.md new file mode 100644 index 0000000..a0f13c4 --- /dev/null +++ b/docs/portable-config.md @@ -0,0 +1,102 @@ +# Compact portable configuration: meguri/v2 + +Import / Export in Review changes now exports compact JSON. It transfers variables and generators only. Existing [v1 files](portable-config-v1.md) remain importable. No credentials, provider settings, membership, manual resources or history are exported. Import previews on the server while typing and saves only the reviewed shared draft; Sync remains a separate action. Workspace and shared-draft revision checks prevent stale application. + +## Everyday structure + +Variables are an object of names and ordinary JSON values. Lists are arrays; dictionaries are objects; strings, numbers and booleans need no type declarations. An email string denotes a person. Generators are an ordered array. Their outputs have readable, stable `key` names and flattened fields. Literal values need no `kind` or `value` wrapper. + +```json +{ + "format": "meguri/v2", + "variables": { + "on_call": ["alice@example.com", "bob@example.com"], + "projects": [ + {"name":"alpha", "primary":["alice@example.com"], "secondary":["bob@example.com"]}, + {"name":"beta", "primary":["bob@example.com"], "secondary":["alice@example.com"]} + ] + }, + "generators": [ + { + "name": "Time off", + "outputs": [{ + "key": "rotation", + "type": "rotation", + "name": "Time off default", + "participants": {"$var":"on_call"}, + "anchor": "2026-09-28" + }] + }, + { + "name": "Project owners", + "for": [{"as":"Project", "in":{"$var":"projects"}}], + "outputs": [{ + "key": "primary", + "type": "priority", + "name": {"$item":["Project","name"], "suffix":" daytime primary"}, + "participants": {"$item":["Project","primary"]}, + "fallback": {"$output":["Time off","rotation"]} + }] + } + ] +} +``` + +Defaults: generators are enabled; handover is 11:00 in the workspace IANA zone; rotation length is seven calendar days; repeat count/wait are zero; routes do not restrict working hours unless `working: true`. Explicit nondefaults remain in the file. Inactive editor metadata and cached values never appear. Small references and values are formatted on one line. + +## Bindings + +| Value | Meaning | +| --- | --- | +| `"Platform"`, `7`, `true`, `["alice@example.com"]` | Literal value | +| `{"$var":"projects"}` | Entire variable | +| `{"$var":["settings","owners","1"]}` | Variable with a nested path | +| `{"$item":["Project","primary"]}` | Field of the current iteration item | +| `{"$key":"Project"}` | Current dictionary iteration key | +| `{"$output":["Project owners","primary"]}` | Generated resource from the named output | +| `{"$lookup":["tiers",{"$item":["Project","tier"]}]}` | Dictionary lookup with a dynamic key | +| `{"$list":[{"$item":["Project","primary","1"]},"alice@example.com"]}` | List of separately evaluated bindings | +| `{"$literal":{"custom":"object"}}` | Literal object, including objects containing `$` keys | + +References may add `prefix`, `suffix`, or a `path` array. Variable/item/key shorthand arrays already contain the path; do not also specify `path`. List positions are one-based strings. Dots, slashes and spaces in names remain literal: each array element is one exact path segment. There is no expression evaluation or executable code. + +A generator's optional condition is `"when": [left-binding, operation, right-binding]`. `for` is an ordered array of `{"as":alias,"in":binding}` objects; nesting preserves that order. + +## Escalations and routes + +An escalation output uses `type: "escalation"`, `name`, optional `repeat_count`/`repeat_wait`, and `steps`: + +```json +"steps": [ + {"schedule":{"$output":["Project owners","primary"]}}, + {"after":10, "schedule":{"$output":["Project owners","secondary"]}}, + {"after":30, "team":"all"} +] +``` + +Each step chooses exactly one `schedule`, `person`, or `team: "all"` / `team: "random"`. `after` is minutes after alert creation (default zero). `if` defaults to `"not-acked"`; `"not-closed"` is also supported. Delays and person/schedule recipients may use any binding. + +Route outputs use `type: "route"`, `name`, `escalation`, optional `working`, and `conditions`. Each condition is `[field, operation, value-binding]`; an optional final `"not"` negates it: + +```json +"conditions": [ + ["tags", "contains", {"$item":["Project","tags"]}], + ["priority", "less-than", "P3", "not"] +] +``` + +## Special variable types and exact dictionaries + +Use `{"$date":"2026-10-01"}`, `{"$time":"09:00"}`, and `{"$duration":30}` to retain typed variable values. Use `{"$text":"literal@example.com"}` for text that looks like an email. `{"$person":"alice@example.com"}` is an explicit alternative to an email string. Unknown emails become scheduling contacts without login access; exports require a stored email for referenced people. + +Ordinary JSON objects represent dictionaries in key order. When existing dictionary entry order differs from alphabetical order, or a key begins with `$`, export uses the unambiguous ordered form `{"$object":[["z",1],["a",2]]}`. This preserves iteration order and supports all existing dictionary keys. Bindings with literal person-ID comparisons use the explicit `$person` marker inside `$literal`; import resolves the email within the target workspace. + +## Merge, identity and limits + +Import updates matching variable/generator names (case-insensitive, trimmed), appends new inputs and retains omitted inputs. Existing generator order and route insertion position stay unchanged. An included generator replaces its full definition: removed output blocks can remove generated resources, shown before Apply. + +Keep output `key` values stable when changing display names or order. Keys persist in the shared draft and do not depend on resource names. UUIDs remain internal. Variable root and dictionary-field IDs are preserved; list entries are matched first by unique value or record `name`, then by position for unmatched edits of equal-length lists. Named project reordering and field edits therefore preserve resource identity. Simultaneous renames, reorderings or edits of indistinguishable list entries can be ambiguous; inspect create/remove changes before applying. New items receive internal IDs without requiring users to author UUIDs. + +Literal references to manual schedules and escalation policies still use exact target-workspace names. Import never creates manual resources. All fields, references, types, existing generation limits and provider capabilities are validated on the server. Invalid JSON, unknown fields, malformed references or stale reviews block application. Import remains limited to 1 MB of text. The preview separates input changes, generated resource changes and new email-only contacts. + +`{"after":30,"team":"random"}` notifies one randomly selected member of the workspace provider team 30 minutes after alert creation if the condition still holds. The provider chooses the member; this does not sequentially notify all members and does not create a random schedule. diff --git a/docs/product.md b/docs/product.md index 4bbecf8..dae973d 100644 --- a/docs/product.md +++ b/docs/product.md @@ -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:]` 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. diff --git a/docs/site-administration.md b/docs/site-administration.md new file mode 100644 index 0000000..df177bf --- /dev/null +++ b/docs/site-administration.md @@ -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. diff --git a/docs/testing.md b/docs/testing.md index 6120744..303b2de 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. diff --git a/package.json b/package.json index b496bb3..7b7ee35 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "meguri-browser-tests", "private": true, "scripts": { - "test:browser": "node tests/browser-generation-map.cjs && node tests/browser-variable-editor.cjs && node tests/browser-rule-result.cjs && node tests/browser-repeat-defaults.cjs && node tests/browser-binding-popover.cjs && node tests/browser-resource-details.cjs && node tests/browser-coverage-status.cjs && node tests/browser-diagnostics.cjs && node tests/browser-advanced-demos.cjs && node tests/browser.cjs && node tests/browser-calendars.cjs && node tests/browser-configuration.cjs && node tests/browser-absence.cjs && node tests/browser-generation.cjs && node tests/browser-cover.cjs && node tests/browser-navigation.cjs && node tests/browser-binding-preview.cjs && node tests/browser-generation-scroll.cjs && node tests/browser-setup-layout.cjs" + "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" diff --git a/src/demo.rs b/src/demo.rs index 86c9773..60005e3 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -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, diff --git a/src/demo_runtime.rs b/src/demo_runtime.rs index 63ff5da..3729e57 100644 --- a/src/demo_runtime.rs +++ b/src/demo_runtime.rs @@ -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#" diff --git a/src/domain.rs b/src/domain.rs index 0d151bd..df9566a 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -37,14 +37,21 @@ impl Permission { pub struct User { #[serde(default)] pub language: Option, - pub id: Uuid, + /// Canonical account-owned person; email is required by account persistence. + #[serde(flatten)] + pub person: Person, pub username: String, - pub display_name: String, - pub country: String, /// None follows the browser timezone; this never changes scheduling rules. pub timezone: Option, } +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, pub assignments: Vec, } + +/// A compact representation of a verified calendar-day cycle, independent of providers. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecurringRotation { + pub start: DateTime, + pub end: DateTime, + pub shift_days: u16, + pub participants: Vec, +} diff --git a/src/engine.rs b/src/engine.rs index 4957197..d057ada 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -170,6 +170,63 @@ pub fn timeline( }) } +/// Compress only coverage identical to the base cycle. History or approved +/// replacements that change any effective span must retain their exact timeline. +pub fn recurring_rotation( + doc: &WorkspaceDocument, + schedule: &Schedule, + periods: &[Assignment], +) -> Result> { + let ScheduleKind::Rotation { + participants, + anchor, + shift_days, + } = &schedule.kind + else { + return Ok(None); + }; + let (Some(first), Some(last)) = (periods.first(), periods.last()) else { + return Ok(None); + }; + let mut base = doc.clone(); + base.schedules = vec![schedule.clone()]; + base.requests.clear(); + let expected = timeline(&base, first.start, last.end)?; + // Compare coverage at every interval boundary; reason labels and redundant + // adjacent spans do not change who is on call. + if periods.windows(2).any(|p| p[0].end != p[1].start) + || periods.iter().any(|p| p.start >= p.end || p.unavailable) + { + return Ok(None); + } + for p in &expected.assignments { + if periods + .iter() + .filter(|a| a.start < p.end && p.start < a.end) + .any(|a| a.user_id != p.user_id || a.unavailable != p.unavailable) + { + return Ok(None); + } + } + let day = schedule_duty_day(doc, schedule.id, first.start)?; + let shift = (day - *anchor) + .num_days() + .div_euclid(i64::from(*shift_days)); + let start = local_time( + doc, + *anchor + Duration::days(shift * i64::from(*shift_days)), + schedule.handover, + )?; + let mut ordered = participants.clone(); + ordered.rotate_left(shift.rem_euclid(participants.len() as i64) as usize); + Ok(Some(RecurringRotation { + start, + end: last.end, + shift_days: *shift_days, + participants: ordered, + })) +} + fn resolve( doc: &WorkspaceDocument, id: Uuid, @@ -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::>().unwrap() + ); + assert_eq!(compact.participants[0], first_person); + let zone: chrono_tz::Tz = doc.timezone.parse().unwrap(); + for p in &periods { + let days = (p.start.with_timezone(&zone).date_naive() + - compact.start.with_timezone(&zone).date_naive()) + .num_days(); + assert_eq!(p.user_id, compact.participants[(days / 7) as usize % 3]); + } + assert!( + periods + .windows(2) + .any(|p| (p[0].end - p[0].start).num_hours() != 168) + ); + let mut changed = periods.clone(); + changed[1].user_id = people + .iter() + .copied() + .find(|p| *p != changed[1].user_id) + .unwrap(); + assert!( + recurring_rotation(&doc, &doc.schedules[0], &changed) + .unwrap() + .is_none() + ); + assert_eq!(periods[0].schedule_id, id); + } + } } diff --git a/src/generation.rs b/src/generation.rs index 30f6e0e..28269f9 100644 --- a/src/generation.rs +++ b/src/generation.rs @@ -84,6 +84,10 @@ pub struct Step { #[serde(default)] pub user: bool, #[serde(default)] + pub team_all: bool, + #[serde(default)] + pub team_random: bool, + #[serde(default)] pub condition: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -97,6 +101,8 @@ pub struct Condition { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(from = "BlockWire")] pub struct Block { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub portable_key: Option, pub id: Uuid, pub kind: String, pub fields: BTreeMap, @@ -107,6 +113,8 @@ pub struct Block { } #[derive(Deserialize)] struct BlockWire { + #[serde(default)] + pub portable_key: Option, pub id: Uuid, pub kind: String, pub fields: BTreeMap, @@ -133,6 +141,7 @@ impl From for Block { ); } Self { + portable_key: value.portable_key, id: value.id, kind: value.kind, fields: value.fields, @@ -506,6 +515,38 @@ impl Resolver<'_> { } Ok(node) } + fn provenance(&self, b: &Binding, iterations: &[Iteration], depth: usize) -> Value { + if depth > 16 { + return Value::Null; + } + let mut source = b.clone(); + source.path.clear(); + source.prefix.clear(); + source.suffix.clear(); + let snapshot = |binding: &Binding| match self.eval(binding, 0) { + Ok(node) => json!({"value":node.json(),"node_id":node.id}), + Err(error) => json!({"error":error.to_string()}), + }; + let mut stages = vec![json!({"path":[],"result":snapshot(&source)})]; + for segment in &b.path { + source.path.push(segment.clone()); + stages.push(json!({"path":source.path,"result":snapshot(&source)})); + } + let mut parents = vec![]; + if matches!(b.kind.as_str(), "item" | "key") + && let Some(iteration) = iterations.iter().find(|i| Some(i.id) == b.iteration) + { + parents.push(self.provenance(&iteration.source, iterations, depth + 1)); + } + if let Some(key) = &b.key { + parents.push(self.provenance(key, iterations, depth + 1)); + } + for item in &b.items { + parents.push(self.provenance(item, iterations, depth + 1)); + } + json!({"binding":b,"stages":stages,"result":snapshot(b),"parents":parents}) + } + fn value(&self, b: &Binding) -> Result { Ok(self.eval(b, 0)?.json()) } @@ -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::>(),"status":"included","resources":[],"blocks":[]}); + let bindings: Vec<_> = rule + .iterations + .iter() + .map(|i| &i.source) + .chain(rule.when.iter().flat_map(|p| [&p.left, &p.right])) + .chain(rule.blocks.iter().flat_map(|b| { + b.fields + .values() + .chain(b.steps.iter().flat_map(|s| [&s.delay, &s.recipient])) + .chain(b.conditions.iter().map(|c| &c.value)) + })) + .map(|b| resolver.provenance(b, &rule.iterations, 0)) + .collect(); + let mut row_trace = json!({"bindings":bindings,"labels":context.labels,"inputs":context.items.iter().map(|(id,n)|(id.to_string(),n.json())).collect::>(),"status":"included","resources":[],"blocks":[]}); let managed_start = managed.len(); let row_issue_start = issues.len(); if let Some(predicate) = &rule.when { @@ -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::>(), - "steps":block.steps.iter().map(|s|json!({"delay":resolved(&s.delay),"recipient":resolved(&s.recipient),"condition":s.condition})).collect::>(), + "steps":block.steps.iter().map(|s|json!({"delay":resolved(&s.delay),"recipient":if s.team_all {json!(crate::i18n::text("everyone_on_the_team_235b55"))} else if s.team_random {json!(crate::i18n::text("a_random_team_member_8f35f3"))} else {resolved(&s.recipient)},"condition":s.condition})).collect::>(), "conditions":block.conditions.iter().map(|c|json!({"field":c.field,"operation":c.operation,"value":resolved(&c.value),"negate":c.negate})).collect::>() })); if manual_ids.contains(&id) { diff --git a/src/generation_service.rs b/src/generation_service.rs index 59768b7..ac70336 100644 --- a/src/generation_service.rs +++ b/src/generation_service.rs @@ -8,12 +8,14 @@ use crate::{ store::{Store, WorkspaceRecord}, }; use chrono::{Duration, Utc}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use uuid::Uuid; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Draft { pub revision: i64, + #[serde(default)] + pub shared_revision: Option, pub variables: Vec, pub rules: Vec, #[serde(default)] @@ -32,6 +34,7 @@ async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Res .prepare(workspace, draft.revision, actor, Permission::EditSchedules) .await?; let directory = db.directory(workspace).await?; + crate::store::account_people(&mut record.document, &directory)?; let members = directory.iter().map(|u| u.id).collect(); let now = Utc::now(); let mut expanded = generation::expand( @@ -61,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, +} +async fn transfer_base( + db: &Store, + actor: Uuid, + workspace: Uuid, + request: &Transfer, +) -> Result<(WorkspaceRecord, Draft)> { + let record = db + .prepare( + workspace, + request.revision, + actor, + Permission::EditSchedules, + ) + .await?; + let shared = db.shared_draft(workspace).await?; + ensure( + shared["revision"].as_i64() == Some(request.shared_revision), + crate::i18n::text("err_shared_draft_stale"), + )?; + let mut draft: Draft = if shared["payload"].is_null() { + Draft { + revision: record.revision, + shared_revision: Some(request.shared_revision), + variables: record.document.generation.variables.clone(), + rules: record.document.generation.rules.clone(), + route_position: record.document.generation.route_position, + review_id: None, + } + } else { + serde_json::from_value(shared["payload"].clone())? + }; + draft.revision = record.revision; + draft.shared_revision = Some(request.shared_revision); + Ok((record, draft)) +} +pub async fn export_config( + db: &Store, + actor: Uuid, + workspace: Uuid, + request: Transfer, +) -> Result { + let (record, draft) = transfer_base(db, actor, workspace, &request).await?; + Ok( + json!({"text":crate::portable::format_config(&crate::portable::export(&record.document,&draft)?)}), + ) +} +pub async fn import_config( + db: &Store, + actor: Uuid, + workspace: Uuid, + request: Transfer, + apply: bool, +) -> Result { + let (record, base) = transfer_base(db, actor, workspace, &request).await?; + let (draft, inputs) = + crate::portable::import(workspace, &record.document, &base, &request.text)?; + let prepared = prepare(db, actor, workspace, &draft).await?; + let review = generation::stable_id(&json!([ + prepared.review, + request.shared_revision, + request.text + ])) + .to_string(); + if apply { + ensure( + prepared.issues.is_empty(), + crate::i18n::text("transfer_fix_errors"), + )?; + ensure( + request.review_id.as_deref() == Some(review.as_str()), + crate::i18n::text("err_the_preview_is_stale_check_the_updated_impact_be_85cacd"), + )?; + return db + .save_shared_draft( + workspace, + actor, + request.shared_revision, + serde_json::to_value(draft)?, + ) + .await; + } + let contacts: Vec<_> = prepared + .record + .document + .people + .iter() + .filter(|p| record.document.person(p.id).is_none() && p.user_id.is_none()) + .collect(); + Ok(json!({"review_id":review,"inputs":inputs,"contacts":contacts,"preview":prepared.report})) +} diff --git a/src/lib.rs b/src/lib.rs index 9fe410a..132ba24 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 730886b..93a345c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,6 +72,11 @@ async fn main() -> Result<(), Box> { 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" { diff --git a/src/migrations.rs b/src/migrations.rs index a2886e9..49a2208 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -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, ]; diff --git a/src/migrations/m_0009_manual_publication.rs b/src/migrations/m_0009_manual_publication.rs new file mode 100644 index 0000000..308145c --- /dev/null +++ b/src/migrations/m_0009_manual_publication.rs @@ -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(()) +} diff --git a/src/migrations/m_0010_site_admin.rs b/src/migrations/m_0010_site_admin.rs new file mode 100644 index 0000000..7664a5d --- /dev/null +++ b/src/migrations/m_0010_site_admin.rs @@ -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(()) +} diff --git a/src/migrations/m_0010b_contact_accounts.rs b/src/migrations/m_0010b_contact_accounts.rs new file mode 100644 index 0000000..8b49ef7 --- /dev/null +++ b/src/migrations/m_0010b_contact_accounts.rs @@ -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, + 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; +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 = 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 = memberships + .iter() + .filter(|m| m.workspace_id == row.id) + .map(|m| Uuid::parse_str(&m.user_id).map_err(fail)) + .collect::>()?; + 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(()) +} diff --git a/src/migrations/m_0011_account_people.rs b/src/migrations/m_0011_account_people.rs new file mode 100644 index 0000000..6a7aeba --- /dev/null +++ b/src/migrations/m_0011_account_people.rs @@ -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(()) +} diff --git a/src/models.rs b/src/models.rs index 7a70622..acea50b 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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, @@ -87,6 +89,9 @@ pub struct AuditRow { #[derive(Debug, Clone)] #[model(table_name = "sync_jobs")] pub struct SyncJobRow { + pub authorized_revision: Option, + pub authorized_document: Option, + pub published_document: Option, #[model(primary_key)] pub workspace_id: String, pub requested_revision: i64, @@ -112,3 +117,40 @@ pub struct ProviderObjectRow { pub content: String, pub updated_at: DateTime, } + +#[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, + pub actor_id: Option, + pub action: String, + pub details: String, + pub created_at: DateTime, +} diff --git a/src/portable.rs b/src/portable.rs new file mode 100644 index 0000000..fcb077c --- /dev/null +++ b/src/portable.rs @@ -0,0 +1,738 @@ +//! Portable, non-executable generation documents. Import only prepares a shared draft. +use crate::{ + domain::*, + error::{Error, Result, ensure}, + generation::{self, Node, Rule, Variable}, + generation_service::Draft, +}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; +use uuid::Uuid; + +pub(super) fn fail(detail: impl ToString) -> Error { + Error::Validation(crate::i18n::message( + "transfer_invalid", + &[("detail", detail.to_string())], + )) +} +pub(super) fn object(v: &Value, allowed: &[&str]) -> Result<()> { + let o = v.as_object().ok_or_else(|| fail(v))?; + if let Some(k) = o.keys().find(|k| !allowed.contains(&k.as_str())) { + return Err(fail(k)); + } + Ok(()) +} +fn text(v: &Value) -> Result<&str> { + v.as_str() + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| fail(v)) +} +fn array(v: &Value) -> Result<&[Value]> { + v.as_array().map(Vec::as_slice).ok_or_else(|| fail(v)) +} +fn decode(v: Value) -> Result { + serde_json::from_value(v).map_err(fail) +} +fn normalized(s: &str) -> String { + s.trim().to_lowercase() +} +fn unique_names(values: impl Iterator) -> Result<()> { + let mut seen = BTreeSet::new(); + for name in values { + ensure( + !name.trim().is_empty() && seen.insert(normalized(&name)), + crate::i18n::message("transfer_duplicate", &[("name", name)]), + )?; + } + Ok(()) +} +fn email(doc: &WorkspaceDocument, value: &str) -> Result { + if email_address::EmailAddress::is_valid(value) { + return Ok(value.trim().to_lowercase()); + } + let found = Uuid::parse_str(value) + .ok() + .and_then(|id| doc.person(id)) + .and_then(|p| p.email.as_deref()); + found + .filter(|s| email_address::EmailAddress::is_valid(s)) + .map(|s| s.trim().to_lowercase()) + .ok_or_else(|| { + Error::Validation(crate::i18n::message( + "transfer_email_required", + &[( + "person", + doc.people + .iter() + .find(|p| p.id.to_string() == value) + .map(|p| p.display_name.clone()) + .unwrap_or_else(|| value.into()), + )], + )) + }) +} +fn resolve_email(doc: &WorkspaceDocument, value: &str) -> Result { + ensure( + email_address::EmailAddress::is_valid(value), + crate::i18n::message("transfer_email_required", &[("person", value.into())]), + )?; + let matches: Vec<_> = doc + .people + .iter() + .filter(|p| { + p.email + .as_ref() + .is_some_and(|e| e.eq_ignore_ascii_case(value.trim())) + }) + .collect(); + ensure( + matches.len() <= 1, + crate::i18n::message("transfer_duplicate", &[("name", value.into())]), + )?; + Ok(matches + .first() + .map(|p| json!(p.id)) + .unwrap_or_else(|| json!(value.trim().to_lowercase()))) +} +fn literal(doc: &WorkspaceDocument, value: &mut Value, role: &str, export: bool) -> Result<()> { + if let Some(a) = value.as_array_mut() { + for v in a { + literal(doc, v, role, export)?; + } + return Ok(()); + } + if value.is_null() || value.as_str() == Some("") { + return Ok(()); + } + if role.is_empty() { + if !export && value.get("$person").is_some() { + object(value, &["$person"])?; + *value = resolve_email(doc, text(&value["$person"])?)?; + } else if export + && value + .as_str() + .and_then(|s| Uuid::parse_str(s).ok()) + .is_some_and(|id| doc.person(id).is_some()) + { + *value = json!({"$person":email(doc,text(value)?)?}); + } else if let Some(fields) = value.as_object_mut() { + for field in fields.values_mut() { + literal(doc, field, "", export)?; + } + } + return Ok(()); + } + if role == "person" { + *value = if export { + json!(email(doc, text(value)?)?) + } else { + resolve_email(doc, text(value)?)? + }; + } else if matches!(role, "schedule" | "escalation") { + let resources: Vec<_> = if role == "schedule" { + doc.schedules + .iter() + .map(|s| (s.id, s.name.as_str())) + .collect() + } else { + doc.escalations + .iter() + .map(|s| (s.id, s.name.as_str())) + .collect() + }; + let input = text(value)?; + let found: Vec<_> = resources + .into_iter() + .filter(|(id, name)| { + if export { + id.to_string() == input + } else { + *name == input + } + }) + .collect(); + ensure( + found.len() == 1, + crate::i18n::message("transfer_reference", &[("name", input.into())]), + )?; + *value = if export { + json!(found[0].1) + } else { + json!(found[0].0) + }; + } + Ok(()) +} +fn compact(value: &mut Value) { + if let Some(o) = value.as_object_mut() { + o.retain(|k, v| { + k == "value" + || (!v.is_null() + && v.as_str() != Some("") + && !v.as_array().is_some_and(Vec::is_empty)) + }); + } +} +fn field_role(field: &str) -> &str { + match field { + "participants" | "primary" | "secondary" => "person", + "fallback" => "schedule", + "escalation" => "escalation", + _ => "", + } +} +struct Links { + variables: BTreeMap, + iterations: BTreeMap, + outputs: BTreeMap, +} +fn binding( + b: &mut Value, + links: &Links, + doc: &WorkspaceDocument, + role: &str, + export: bool, + depth: usize, +) -> Result<()> { + ensure(depth <= 16, crate::i18n::text("transfer_limit"))?; + object( + b, + &[ + "kind", + "value", + "variable", + "iteration", + "output", + "key", + "items", + "path", + "prefix", + "suffix", + ], + )?; + if export { + let kind = b["kind"].as_str().unwrap_or_default().to_owned(); + b.as_object_mut() + .unwrap() + .retain(|key, _| match key.as_str() { + "value" => kind == "literal", + "variable" => matches!(kind.as_str(), "variable" | "lookup"), + "iteration" => matches!(kind.as_str(), "item" | "key"), + "output" => kind == "output", + "items" => kind == "list", + "key" => kind == "lookup", + _ => true, + }); + } + for field in ["variable", "iteration", "output"] { + if b.get(field).is_none_or(Value::is_null) { + continue; + } + let key = if field == "output" && !export { + b[field].to_string() + } else { + text(&b[field])?.into() + }; + b[field] = if field == "output" { + links.outputs.get(&key).cloned() + } else { + (if field == "variable" { + &links.variables + } else { + &links.iterations + }) + .get(&key) + .map(|s| json!(s)) + } + .ok_or_else(|| { + Error::Validation(crate::i18n::message("transfer_reference", &[("name", key)])) + })?; + } + if b["kind"] == "literal" { + literal(doc, &mut b["value"], role, export)?; + } + if let Some(items) = b.get_mut("items") { + for item in items.as_array_mut().ok_or_else(|| fail("items"))? { + binding(item, links, doc, role, export, depth + 1)?; + } + } + if let Some(key) = b.get_mut("key").filter(|v| !v.is_null()) { + binding(key, links, doc, "", export, depth + 1)?; + } + if export { + compact(b); + } + Ok(()) +} +fn rule_bindings( + rule: &mut Value, + links: &Links, + doc: &WorkspaceDocument, + export: bool, +) -> Result<()> { + if let Some(iterations) = rule.get_mut("iterations") { + for i in iterations + .as_array_mut() + .ok_or_else(|| fail("iterations"))? + { + binding(&mut i["source"], links, doc, "", export, 0)?; + } + } + if let Some(p) = rule.get_mut("when").filter(|v| !v.is_null()) { + object(p, &["left", "operation", "right"])?; + binding(&mut p["left"], links, doc, "", export, 0)?; + if export && p["operation"] == "not_empty" { + p["right"] = json!({"kind":"literal","value":null}); + } + binding(&mut p["right"], links, doc, "", export, 0)?; + } + for block in rule["blocks"] + .as_array_mut() + .ok_or_else(|| fail("blocks"))? + { + object( + block, + &[ + "id", + "kind", + "fields", + "steps", + "conditions", + "portable_key", + ], + )?; + let allowed = match block["kind"].as_str() { + Some("rotation") => vec!["name", "participants", "anchor", "handover", "shift_days"], + Some("priority") => vec!["name", "participants", "fallback", "handover"], + Some("escalation") => vec!["name", "repeat_count", "repeat_wait"], + Some("route") => vec!["name", "escalation", "working"], + _ => return Err(fail(&block["kind"])), + }; + object(&block["fields"], &allowed)?; + if block["kind"] != "escalation" + && block + .get("steps") + .is_some_and(|v| v.as_array().is_none_or(|a| !a.is_empty())) + { + return Err(fail("steps")); + } + if block["kind"] != "route" + && block + .get("conditions") + .is_some_and(|v| v.as_array().is_none_or(|a| !a.is_empty())) + { + return Err(fail("conditions")); + } + + for (field, b) in block["fields"] + .as_object_mut() + .ok_or_else(|| fail("fields"))? + { + binding(b, links, doc, field_role(field), export, 0)?; + } + if let Some(steps) = block.get_mut("steps") { + for step in steps.as_array_mut().ok_or_else(|| fail("steps"))? { + object( + step, + &[ + "delay", + "recipient", + "user", + "team_all", + "team_random", + "condition", + ], + )?; + if step.get("condition").is_some_and(|v| { + !matches!(v.as_str(), Some("" | "if-not-acked" | "if-not-closed")) + }) { + return Err(fail(&step["condition"])); + } + binding(&mut step["delay"], links, doc, "", export, 0)?; + let role = if step["team_all"] == true || step["team_random"] == true { + "" + } else if step["user"] == true { + "person" + } else { + "schedule" + }; + if (step["team_all"] == true || step["team_random"] == true) && export { + step["recipient"] = json!({"kind":"literal","value":null}); + } + binding(&mut step["recipient"], links, doc, role, export, 0)?; + } + } + if let Some(conditions) = block.get_mut("conditions") { + for c in conditions + .as_array_mut() + .ok_or_else(|| fail("conditions"))? + { + object(c, &["field", "operation", "value", "negate"])?; + binding(&mut c["value"], links, doc, "", export, 0)?; + } + } + if export { + compact(block); + } + } + Ok(()) +} +pub(super) fn export_node(node: &Node, doc: &WorkspaceDocument) -> Result { + let mut v = serde_json::to_value(node)?; + if node.kind == "person" { + v["value"] = json!(email(doc, text(&node.value)?)?); + } + v["entries"] = Value::Array( + node.entries + .iter() + .map(|n| export_node(n, doc)) + .collect::>()?, + ); + compact(&mut v); + Ok(v) +} +/// IDs on nested entries are portable identity keys, never authority to access another workspace. +pub fn export_v1(doc: &WorkspaceDocument, draft: &Draft) -> Result { + unique_names(draft.variables.iter().map(|v| v.name.clone()))?; + unique_names(draft.rules.iter().map(|r| r.name.clone()))?; + let mut links = Links { + variables: draft + .variables + .iter() + .map(|v| (v.id.to_string(), v.name.clone())) + .collect(), + iterations: BTreeMap::new(), + outputs: BTreeMap::new(), + }; + for r in &draft.rules { + for b in &r.blocks { + links + .outputs + .insert(b.id.to_string(), json!([r.name, b.id.to_string()])); + } + } + let mut rules = vec![]; + for r in &draft.rules { + unique_names(r.iterations.iter().map(|i| i.name.clone()))?; + links.iterations = r + .iterations + .iter() + .map(|i| (i.id.to_string(), i.name.clone())) + .collect(); + let mut value = serde_json::to_value(r)?; + value.as_object_mut().unwrap().remove("id"); + rule_bindings(&mut value, &links, doc, true)?; + for i in value["iterations"].as_array_mut().unwrap() { + i.as_object_mut().unwrap().remove("id"); + } + compact(&mut value); + rules.push(value); + } + Ok( + json!({"format":"meguri/v1","variables":draft.variables.iter().map(|v| Ok(json!({"name":v.name,"node":export_node(&v.node,doc)?}))).collect::>>()?,"generators":rules}), + ) +} +fn identity( + scope: Uuid, + key: Option<&Value>, + fallback: &str, + existing: Option, + owned: &BTreeSet, +) -> Result { + if let Some(key) = key { + let key = text(key)?; + if let Ok(id) = Uuid::parse_str(key) + && owned.contains(&id) + { + return Ok(id); + } + Ok(generation::stable_id(&json!(["portable", scope, key]))) + } else { + Ok( + existing + .unwrap_or_else(|| generation::stable_id(&json!(["portable", scope, fallback]))), + ) + } +} +fn node_ids(n: &Node, into: &mut BTreeSet) { + into.insert(n.id); + for child in &n.entries { + node_ids(child, into); + } +} +fn import_node( + v: &Value, + old: Option<&Node>, + scope: Uuid, + path: &str, + owned: &BTreeSet, + doc: &WorkspaceDocument, + depth: usize, +) -> Result { + ensure(depth <= 8, crate::i18n::text("transfer_limit"))?; + object(v, &["id", "kind", "key", "value", "entries"])?; + if matches!(v["kind"].as_str(), Some("list" | "dictionary")) + && v.get("value") + .is_some_and(|v| !v.is_null() && v.as_str() != Some("")) + { + return Err(fail("entries")); + } + let id = identity(scope, v.get("id"), path, old.map(|n| n.id), owned)?; + let mut result = v.clone(); + result["id"] = json!(id); + if v["kind"] == "person" { + result["value"] = resolve_email(doc, text(&v["value"])?)?; + } + if let Some(entries) = v.get("entries") { + result["entries"] = Value::Array( + array(entries)? + .iter() + .enumerate() + .map(|(index, entry)| { + let child_old = old.and_then(|n| { + if n.kind == "dictionary" { + n.entries + .iter() + .find(|c| Some(c.key.as_str()) == entry["key"].as_str()) + } else { + n.entries.get(index) + } + }); + import_node( + entry, + child_old, + scope, + &format!("{path}/{index}"), + owned, + doc, + depth + 1, + ) + .and_then(|n| Ok(serde_json::to_value(n)?)) + }) + .collect::>()?, + ); + } + decode(result) +} +/// Merge only named inputs. Omitted inputs and all manual resources are retained. +pub fn import( + workspace: Uuid, + doc: &WorkspaceDocument, + base: &Draft, + source: &str, +) -> Result<(Draft, Value)> { + ensure( + source.len() <= 1_000_000, + crate::i18n::text("transfer_limit"), + )?; + let mut input: Value = serde_json::from_str(source).map_err(fail)?; + if input["format"] == "meguri/v2" { + input = crate::portable_v2::expand(&input, doc, base)?; + } + object(&input, &["format", "variables", "generators"])?; + ensure( + input["format"] == "meguri/v1", + crate::i18n::text("transfer_version"), + )?; + let vars = array(&input["variables"])?; + let rules = array(&input["generators"])?; + ensure( + vars.len() <= 100 && rules.len() <= 50, + crate::i18n::text("transfer_limit"), + )?; + unique_names( + vars.iter() + .map(|v| v["name"].as_str().unwrap_or_default().into()), + )?; + unique_names( + rules + .iter() + .map(|v| v["name"].as_str().unwrap_or_default().into()), + )?; + unique_names(base.variables.iter().map(|v| v.name.clone()))?; + unique_names(base.rules.iter().map(|v| v.name.clone()))?; + let mut draft = base.clone(); + let mut changes = vec![]; + for v in vars { + object(v, &["name", "node"])?; + let name = text(&v["name"])?; + let old = base + .variables + .iter() + .find(|v| normalized(&v.name) == normalized(name)); + let id = old.map(|v| v.id).unwrap_or_else(|| { + generation::stable_id(&json!([workspace, "variable", normalized(name)])) + }); + let mut owned = BTreeSet::new(); + if let Some(v) = old { + node_ids(&v.node, &mut owned); + } + let new = Variable { + id, + name: name.into(), + node: import_node(&v["node"], old.map(|v| &v.node), id, "root", &owned, doc, 0)?, + }; + let same = + old.is_some_and(|o| serde_json::to_value(o).ok() == serde_json::to_value(&new).ok()); + changes.push(json!({"kind":"variable","name":name,"action":if same {"unchanged"} else if old.is_some(){"update"}else{"create"}})); + if let Some(at) = draft.variables.iter().position(|v| v.id == id) { + draft.variables[at] = new; + } else { + draft.variables.push(new); + } + } + let mut prepared = vec![]; + let mut links = Links { + variables: draft + .variables + .iter() + .map(|v| (v.name.clone(), v.id.to_string())) + .collect(), + iterations: BTreeMap::new(), + outputs: BTreeMap::new(), + }; + for r in &base.rules { + for b in &r.blocks { + links + .outputs + .insert(json!([r.name, b.id.to_string()]).to_string(), json!(b.id)); + } + } + for r in rules { + object(r, &["name", "enabled", "iterations", "when", "blocks"])?; + let name = text(&r["name"])?; + let old = base + .rules + .iter() + .find(|r| normalized(&r.name) == normalized(name)); + let id = old.map(|r| r.id).unwrap_or_else(|| { + generation::stable_id(&json!([workspace, "rule", normalized(name)])) + }); + let mut new = r.clone(); + new["id"] = json!(id); + if new.get("enabled").is_none() { + new["enabled"] = json!(true); + } + if new.get("iterations").is_none() { + new["iterations"] = json!([]); + } + let owned: BTreeSet<_> = old + .into_iter() + .flat_map(|r| r.blocks.iter().map(|b| b.id)) + .collect(); + let blocks = new["blocks"].as_array_mut().ok_or_else(|| fail("blocks"))?; + let mut keys = BTreeSet::new(); + for (index, b) in blocks.iter_mut().enumerate() { + let key = b + .get("id") + .map(text) + .transpose()? + .map(str::to_owned) + .unwrap_or_else(|| format!("block-{}", index + 1)); + ensure( + keys.insert(key.clone()), + crate::i18n::message("transfer_duplicate", &[("name", key.clone())]), + )?; + let bid = identity( + id, + b.get("id"), + &key, + old.and_then(|r| r.blocks.get(index)).map(|b| b.id), + &owned, + )?; + links + .outputs + .insert(json!([name, key]).to_string(), json!(bid)); + b["id"] = json!(bid); + } + prepared.push(new); + } + for mut r in prepared { + let name = text(&r["name"])?.to_owned(); + let old = base + .rules + .iter() + .find(|r| normalized(&r.name) == normalized(&name)); + let id: Uuid = decode(r["id"].clone())?; + let iterations = r["iterations"].as_array_mut().unwrap(); + unique_names( + iterations + .iter() + .map(|i| i["name"].as_str().unwrap_or_default().into()), + )?; + links.iterations.clear(); + for i in iterations { + object(i, &["name", "source"])?; + let name = text(&i["name"])?; + let iid = old + .and_then(|r| r.iterations.iter().find(|i| i.name == name)) + .map(|i| i.id) + .unwrap_or_else(|| generation::stable_id(&json!([id, "iteration", name]))); + links.iterations.insert(name.into(), iid.to_string()); + i["id"] = json!(iid); + } + rule_bindings(&mut r, &links, doc, false)?; + let new: Rule = decode(r)?; + let same = + old.is_some_and(|o| serde_json::to_value(o).ok() == serde_json::to_value(&new).ok()); + changes.push(json!({"kind":"rule","name":name,"action":if same {"unchanged"} else if old.is_some(){"update"}else{"create"}})); + if let Some(at) = draft.rules.iter().position(|r| r.id == new.id) { + draft.rules[at] = new; + } else { + draft.rules.push(new); + } + } + Ok((draft, Value::Array(changes))) +} + +pub fn export(doc: &WorkspaceDocument, draft: &Draft) -> Result { + crate::portable_v2::compact(&export_v1(doc, draft)?, draft) +} + +/// Keep small references and scalar collections on one line for hand editing. +pub fn format_config(value: &Value) -> String { + fn render(v: &Value, depth: usize) -> String { + let short = v.to_string(); + if short.len() + depth * 2 <= 110 || (!v.is_array() && !v.is_object()) { + return short; + } + let indent = " ".repeat(depth + 1); + let end = " ".repeat(depth); + match v { + Value::Array(a) => format!( + "[\n{}\n{}]", + a.iter() + .map(|v| format!("{}{}", indent, render(v, depth + 1))) + .collect::>() + .join(",\n"), + end + ), + Value::Object(o) => { + let preferred: &[&str] = if o.contains_key("format") { + &["format", "variables", "generators"] + } else if o.contains_key("outputs") { + &["name", "enabled", "for", "when", "outputs"] + } else if o.contains_key("type") && o.contains_key("key") { + &["key", "type", "name"] + } else { + &[] + }; + let keys: Vec<_> = preferred + .iter() + .filter_map(|k| o.get_key_value(*k).map(|(k, _)| k)) + .chain(o.keys().filter(|k| !preferred.contains(&k.as_str()))) + .collect(); + format!( + "{{\n{}\n{}}}", + keys.iter() + .map(|k| format!("{}{}: {}", indent, json!(k), render(&o[*k], depth + 1))) + .collect::>() + .join(",\n"), + end + ) + } + _ => short, + } + } + render(value, 0) +} diff --git a/src/portable_v2.rs b/src/portable_v2.rs new file mode 100644 index 0000000..fdf2c3f --- /dev/null +++ b/src/portable_v2.rs @@ -0,0 +1,537 @@ +//! Compact JSON syntax translates to the existing, validated portable importer. +use crate::{ + domain::WorkspaceDocument, + error::{Result, ensure}, + generation, + generation_service::Draft, + portable::{self, fail, object}, +}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; +fn arr(v: &Value) -> Result<&Vec> { + v.as_array().ok_or_else(|| fail(v)) +} +fn txt(v: &Value) -> Result<&str> { + v.as_str().filter(|s| !s.is_empty()).ok_or_else(|| fail(v)) +} +fn norm(s: &str) -> String { + s.trim().to_lowercase() +} +fn aliases(draft: &Draft) -> Result> { + let mut result = BTreeMap::new(); + for r in &draft.rules { + let mut used: BTreeSet = r + .blocks + .iter() + .filter_map(|b| b.portable_key.clone()) + .collect(); + ensure( + used.len() == r.blocks.iter().filter(|b| b.portable_key.is_some()).count() + && used.iter().all(|k| !k.trim().is_empty() && k.len() <= 120), + crate::i18n::text("transfer_v2_binding"), + )?; + for b in &r.blocks { + let key = b.portable_key.clone().unwrap_or_else(|| { + let mut key = b.kind.clone(); + let mut n = 2; + while used.contains(&key) { + key = format!("{}-{n}", b.kind); + n += 1; + } + used.insert(key.clone()); + key + }); + result.insert(b.id.to_string(), key); + } + } + Ok(result) +} +fn compact_node(n: &Value) -> Result { + Ok(match txt(&n["kind"])? { + "list" => Value::Array( + n["entries"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .map(compact_node) + .collect::>()?, + ), + "dictionary" => { + let entries = n["entries"].as_array().cloned().unwrap_or_default(); + let keys: Vec<_> = entries + .iter() + .map(|e| e["key"].as_str().unwrap_or_default()) + .collect(); + if keys.iter().any(|k| k.starts_with('$')) || !keys.windows(2).all(|w| w[0] < w[1]) { + json!({"$object":entries.iter().map(|e|Ok(json!([e["key"],compact_node(e)?]))).collect::>>()?}) + } else { + let mut values = serde_json::Map::new(); + for e in entries { + values.insert(txt(&e["key"])?.into(), compact_node(&e)?); + } + Value::Object(values) + } + } + "person" => n["value"].clone(), + "text" + if n["value"] + .as_str() + .is_some_and(email_address::EmailAddress::is_valid) => + { + json!({"$text":n["value"]}) + } + "text" | "number" | "boolean" => n["value"].clone(), + kind @ ("date" | "time" | "duration") => { + let mut o = serde_json::Map::new(); + o.insert(format!("${kind}"), n["value"].clone()); + Value::Object(o) + } + _ => return Err(fail(&n["kind"])), + }) +} +fn compact_binding(b: &Value, names: &BTreeMap) -> Result { + let kind = txt(&b["kind"])?; + let mut out = match kind { + "literal" => { + if b["value"].is_object() { + json!({"$literal":b["value"]}) + } else { + b["value"].clone() + } + } + "list" => { + json!({"$list":arr(&b["items"] )?.iter().map(|v|compact_binding(v,names)).collect::>>()?}) + } + "variable" | "item" | "key" => { + let key = if kind == "variable" { + "$var" + } else if kind == "item" { + "$item" + } else { + "$key" + }; + let root = if kind == "variable" { + b["variable"].clone() + } else { + b["iteration"].clone() + }; + let mut path = vec![root.clone()]; + if let Some(a) = b["path"].as_array() { + path.extend(a.iter().cloned()); + } + let mut o = serde_json::Map::new(); + o.insert(key.into(), if path.len() == 1 { root } else { json!(path) }); + Value::Object(o) + } + "output" => { + let pair = arr(&b["output"])?; + let alias = names.get(txt(&pair[1])?).ok_or_else(|| fail(&pair[1]))?; + json!({"$output":[pair[0],alias]}) + } + "lookup" => json!({"$lookup":[b["variable"],compact_binding(&b["key"],names)?]}), + _ => return Err(fail(kind)), + }; + let mut modifiers = serde_json::Map::new(); + for k in ["prefix", "suffix", "path"] { + if k == "path" && matches!(kind, "variable" | "item" | "key") { + continue; + } + if let Some(v) = b.get(k).filter(|v| { + !v.is_null() && v.as_str() != Some("") && !v.as_array().is_some_and(Vec::is_empty) + }) { + modifiers.insert(k.into(), v.clone()); + } + } + if !modifiers.is_empty() { + if kind == "literal" || !out.is_object() { + out = json!({"$literal":b["value"]}); + } + out.as_object_mut().unwrap().extend(modifiers); + } + Ok(out) +} +fn expand_binding(v: &Value) -> Result { + if !v.is_object() { + return Ok(json!({"kind":"literal","value":v})); + } + let o = v.as_object().unwrap(); + let tags: Vec<_> = o.keys().filter(|k| k.starts_with('$')).collect(); + ensure(tags.len() == 1, crate::i18n::text("transfer_v2_binding"))?; + let tag = tags[0].as_str(); + object(v, &[tag, "path", "prefix", "suffix"])?; + let mut out = match tag { + "$literal" => json!({"kind":"literal","value":v[tag]}), + "$var" | "$item" | "$key" => { + let (root, path) = if let Some(s) = v[tag].as_str() { + (json!(s), vec![]) + } else { + let a = arr(&v[tag])?; + ensure(!a.is_empty(), crate::i18n::text("transfer_v2_binding"))?; + (a[0].clone(), a[1..].to_vec()) + }; + txt(&root)?; + if tag == "$var" { + json!({"kind":"variable","variable":root,"path":path}) + } else { + json!({"kind":if tag=="$key"{"key"}else{"item"},"iteration":root,"path":path}) + } + } + "$output" => { + let a = arr(&v[tag])?; + ensure(a.len() == 2, crate::i18n::text("transfer_v2_binding"))?; + txt(&a[0])?; + txt(&a[1])?; + json!({"kind":"output","output":a}) + } + "$lookup" => { + let a = arr(&v[tag])?; + ensure(a.len() == 2, crate::i18n::text("transfer_v2_binding"))?; + txt(&a[0])?; + json!({"kind":"lookup","variable":a[0],"key":expand_binding(&a[1])?}) + } + "$list" => { + json!({"kind":"list","items":arr(&v[tag])?.iter().map(expand_binding).collect::>>()?}) + } + _ => return Err(fail(tag)), + }; + for key in ["path", "prefix", "suffix"] { + if let Some(val) = v.get(key) { + if key == "path" && out[key].as_array().is_some_and(|a| !a.is_empty()) { + return Err(fail("path")); + } + out[key] = val.clone(); + } + } + Ok(out) +} +fn default_field(k: &str, v: &Value) -> bool { + match k { + "handover" => v == "11:00" || v == "11:00:00", + "shift_days" => v == 7, + "repeat_count" | "repeat_wait" => v == 0, + "working" => v == false, + _ => false, + } +} +pub(super) fn compact(v1: &Value, draft: &Draft) -> Result { + let names = aliases(draft)?; + let mut variables = serde_json::Map::new(); + for v in arr(&v1["variables"])? { + variables.insert(txt(&v["name"])?.into(), compact_node(&v["node"])?); + } + let mut generators = vec![]; + for r in arr(&v1["generators"])? { + let mut rule = json!({"name":r["name"],"outputs":[]}); + if r["enabled"] == false { + rule["enabled"] = json!(false); + } + if let Some(iterations) = r["iterations"].as_array().filter(|a| !a.is_empty()) { + rule["for"] = Value::Array( + iterations + .iter() + .map(|i| Ok(json!({"as":i["name"],"in":compact_binding(&i["source"],&names)?}))) + .collect::>()?, + ); + } + if let Some(p) = r.get("when").filter(|v| !v.is_null()) { + rule["when"] = json!([ + compact_binding(&p["left"], &names)?, + p["operation"], + compact_binding(&p["right"], &names)? + ]); + } + for b in arr(&r["blocks"])? { + let mut block = json!({"key":names.get(txt(&b["id"] )?).ok_or_else(||fail(&b["id"]))?,"type":b["kind"]}); + for (field, binding) in b["fields"].as_object().ok_or_else(|| fail("fields"))? { + let value = compact_binding(binding, &names)?; + if !default_field(field, &value) { + block[field] = value; + } + } + if let Some(steps) = b["steps"].as_array().filter(|a| !a.is_empty()) { + block["steps"] = Value::Array( + steps + .iter() + .map(|s| { + let mut step = if s["team_all"] == true { + json!({"team":"all"}) + } else if s["team_random"] == true { + json!({"team":"random"}) + } else { + let key = if s["user"] == true { + "person" + } else { + "schedule" + }; + let mut m = serde_json::Map::new(); + m.insert(key.into(), compact_binding(&s["recipient"], &names)?); + Value::Object(m) + }; + let after = compact_binding(&s["delay"], &names)?; + if after != 0 { + step["after"] = after; + } + if s["condition"] == "if-not-closed" { + step["if"] = json!("not-closed"); + } + Ok(step) + }) + .collect::>()?, + ); + } + if let Some(conditions) = b["conditions"].as_array().filter(|a| !a.is_empty()) { + block["conditions"] = Value::Array( + conditions + .iter() + .map(|c| { + let mut a = vec![ + c["field"].clone(), + c["operation"].clone(), + compact_binding(&c["value"], &names)?, + ]; + if c["negate"] == true { + a.push(json!("not")); + } + Ok(json!(a)) + }) + .collect::>()?, + ); + } + rule["outputs"].as_array_mut().unwrap().push(block); + } + generators.push(rule); + } + Ok(json!({"format":"meguri/v2","variables":variables,"generators":generators})) +} +fn node_key(v: &Value) -> Option { + if let Some(name) = v.get("name").and_then(Value::as_str) { + Some(format!("name:{name}")) + } else if !v.is_array() && !v.is_object() { + Some(format!("value:{v}")) + } else { + None + } +} +fn expand_node(v: &Value, old: Option<&Value>, path: &str, depth: usize) -> Result { + ensure(depth <= 8, crate::i18n::text("transfer_limit"))?; + let mut n = if let Some(a) = v.as_array() { + let prior = old + .and_then(|o| o["entries"].as_array()) + .cloned() + .unwrap_or_default(); + let values = prior.iter().map(compact_node).collect::>>()?; + let mut used = BTreeSet::new(); + let mut matches = vec![None; a.len()]; + for (i, item) in a.iter().enumerate() { + let candidates: Vec<_> = values + .iter() + .enumerate() + .filter(|(j, x)| { + !used.contains(j) + && (*x == item + || node_key(item).is_some_and(|key| node_key(x) == Some(key))) + }) + .map(|(j, _)| j) + .collect(); + if candidates.len() == 1 { + matches[i] = Some(candidates[0]); + used.insert(candidates[0]); + } + } + // Preserve a lone in-place edit, but never borrow an identity already matched elsewhere. + for (i, m) in matches.iter_mut().enumerate() { + if m.is_none() && prior.len() == a.len() && !used.contains(&i) { + *m = Some(i); + used.insert(i); + } + } + let entries = a + .iter() + .enumerate() + .map(|(i, item)| { + expand_node( + item, + matches[i].map(|j| &prior[j]), + &format!("{path}/{i}"), + depth + 1, + ) + }) + .collect::>>()?; + json!({"kind":"list","entries":entries,"value":old.map(|v|v["value"].clone()).unwrap_or(Value::Null)}) + } else if let Some(o) = v.as_object() { + if let Some((tag, value)) = o.iter().find(|(k, _)| k.starts_with('$')) { + object(v, &[tag])?; + match tag.as_str() { + "$text" | "$person" | "$date" | "$time" | "$duration" => { + json!({"kind":&tag[1..],"value":value}) + } + "$object" => dictionary( + arr(value)? + .iter() + .map(|pair| { + let pair = arr(pair)?; + ensure(pair.len() == 2, crate::i18n::text("transfer_v2_binding"))?; + Ok((txt(&pair[0])?.to_owned(), pair[1].clone())) + }) + .collect::>>()?, + old, + path, + depth, + )?, + _ => return Err(fail(tag)), + } + } else { + dictionary( + o.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + old, + path, + depth, + )? + } + } else if let Some(s) = v.as_str() { + json!({"kind":if email_address::EmailAddress::is_valid(s){"person"}else{"text"},"value":v}) + } else if v.is_boolean() { + json!({"kind":"boolean","value":v}) + } else if v.is_number() { + json!({"kind":"number","value":v}) + } else { + return Err(fail(v)); + }; + n["id"] = old + .map(|o| o["id"].clone()) + .unwrap_or_else(|| json!(generation::stable_id(&json!(["v2-entry", path, v])))); + Ok(n) +} +fn dictionary( + entries: Vec<(String, Value)>, + old: Option<&Value>, + path: &str, + depth: usize, +) -> Result { + let mut seen = BTreeSet::new(); + let mut nodes = vec![]; + for (key, value) in entries { + ensure( + seen.insert(key.clone()), + crate::i18n::message("transfer_duplicate", &[("name", key.clone())]), + )?; + let previous = old + .and_then(|o| o["entries"].as_array()) + .and_then(|a| a.iter().find(|n| n["key"] == key)); + let mut node = expand_node(&value, previous, &format!("{path}/{key}"), depth + 1)?; + node["key"] = json!(key); + nodes.push(node); + } + Ok( + json!({"kind":"dictionary","entries":nodes,"value":old.map(|v|v["value"].clone()).unwrap_or(Value::Null)}), + ) +} +pub(super) fn expand(v: &Value, doc: &WorkspaceDocument, base: &Draft) -> Result { + object(v, &["format", "variables", "generators"])?; + let old = json!({"variables":base.variables.iter().map(|v|json!({"name":v.name,"node":portable::export_node(&v.node,doc).unwrap_or_else(|_|serde_json::to_value(&v.node).expect("typed node"))})).collect::>()}); + let names = aliases(base)?; + let mut vars = vec![]; + for (name, value) in v["variables"] + .as_object() + .ok_or_else(|| fail("variables"))? + { + let previous = old["variables"] + .as_array() + .unwrap() + .iter() + .find(|p| norm(p["name"].as_str().unwrap_or_default()) == norm(name)); + vars.push( + json!({"name":name,"node":expand_node(value,previous.map(|p|&p["node"]),name,0)?}), + ); + } + let mut outputs = BTreeMap::new(); + for r in &base.rules { + for b in &r.blocks { + outputs.insert( + json!([norm(&r.name), names[&b.id.to_string()]]).to_string(), + json!(b.id), + ); + } + } + for r in arr(&v["generators"])? { + for b in arr(&r["outputs"])? { + let key = txt(&b["key"])?; + outputs + .entry(json!([norm(txt(&r["name"])?), key]).to_string()) + .or_insert_with(|| json!(key)); + } + } + let mut rules = vec![]; + for r in arr(&v["generators"])? { + object(r, &["name", "enabled", "for", "when", "outputs"])?; + let name = txt(&r["name"])?; + let mut rule = json!({"name":name,"enabled":r.get("enabled").cloned().unwrap_or(json!(true)),"blocks":[]}); + if let Some(iterations) = r.get("for") { + rule["iterations"] = Value::Array( + arr(iterations)? + .iter() + .map(|i| { + object(i, &["as", "in"])?; + Ok(json!({"name":i["as"],"source":expand_binding(&i["in"])?})) + }) + .collect::>()?, + ); + } + if let Some(p) = r.get("when") { + let p = arr(p)?; + ensure(p.len() == 3, crate::i18n::text("transfer_v2_binding"))?; + rule["when"] = json!({"left":expand_binding(&p[0])?,"operation":p[1],"right":expand_binding(&p[2])?}); + } + for b in arr(&r["outputs"])? { + let key = txt(&b["key"])?; + let mut block = json!({"id":outputs.get(&json!([norm(name),key]).to_string()).ok_or_else(||fail(key))?,"portable_key":key,"kind":b["type"],"fields":{}}); + for (field, value) in b.as_object().ok_or_else(|| fail(b))? { + if !["key", "type", "steps", "conditions"].contains(&field.as_str()) { + block["fields"][field] = expand_binding(value)?; + } + } + if let Some(steps) = b.get("steps") { + block["steps"]=Value::Array(arr(steps)?.iter().map(|s|{ + object(s,&["after","if","person","schedule","team"])?; + let recipients:Vec<_>=["person","schedule","team"].into_iter().filter(|k|s.get(k).is_some()).collect();ensure(recipients.len()==1,crate::i18n::text("transfer_v2_binding"))?; + let condition=match s.get("if").and_then(Value::as_str){None|Some("not-acked")=>"if-not-acked",Some("not-closed")=>"if-not-closed",_=>return Err(fail(&s["if"]))}; + let recipient=if recipients[0]=="team"{ensure(s["team"]=="all" || s["team"]=="random",crate::i18n::text("transfer_v2_binding"))?;json!({"kind":"literal","value":null})}else{expand_binding(&s[recipients[0]])?}; + Ok(json!({"delay":expand_binding(s.get("after").unwrap_or(&json!(0)))?,"condition":condition,"user":recipients[0]=="person","team_all":s["team"]=="all","team_random":s["team"]=="random","recipient":recipient})) + }).collect::>()?); + } + if let Some(conditions) = b.get("conditions") { + block["conditions"]=Value::Array(arr(conditions)?.iter().map(|c|{let a=arr(c)?;ensure(a.len()==3||(a.len()==4&&a[3]=="not"),crate::i18n::text("transfer_v2_binding"))?;Ok(json!({"field":a[0],"operation":a[1],"value":expand_binding(&a[2])?,"negate":a.len()==4}))}).collect::>()?); + } + rule["blocks"].as_array_mut().unwrap().push(block); + } + remap_outputs(&mut rule, &outputs)?; + rules.push(rule); + } + Ok(json!({"format":"meguri/v1","variables":vars,"generators":rules})) +} +fn remap_outputs(v: &mut Value, outputs: &BTreeMap) -> Result<()> { + if v["kind"] == "literal" { + return Ok(()); + } + if v["kind"] == "output" { + let old = v["output"].clone(); + let id = outputs + .get(&json!([norm(txt(&old[0])?), old[1]]).to_string()) + .ok_or_else(|| fail(&old))?; + v["output"] = json!([old[0], id]); + } + match v { + Value::Object(o) => { + for v in o.values_mut() { + remap_outputs(v, outputs)?; + } + } + Value::Array(a) => { + for v in a { + remap_outputs(v, outputs)?; + } + } + _ => (), + }; + Ok(()) +} diff --git a/src/providers/jsm.rs b/src/providers/jsm.rs index 53ff3d0..b51e01b 100644 --- a/src/providers/jsm.rs +++ b/src/providers/jsm.rs @@ -8,6 +8,7 @@ use crate::{ use chrono::{DateTime, Timelike, Utc}; use reqwest::{Client, Method, StatusCode}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use uuid::Uuid; @@ -168,7 +169,13 @@ impl Jsm { let mut ids: BTreeSet<_> = plan .schedules .iter() - .flat_map(|s| s.periods.iter().map(|p| p.user_id)) + .flat_map(|s| { + s.periods.iter().map(|p| p.user_id).chain( + s.recurring + .iter() + .flat_map(|r| r.participants.iter().copied()), + ) + }) .collect(); ids.extend( plan.escalations @@ -241,6 +248,7 @@ impl Jsm { })?)) } async fn list(&self, path: &str) -> Result> { + const PAGE_SIZE: usize = 50; let mut all = vec![]; let mut offset = 0; loop { @@ -248,7 +256,7 @@ impl Jsm { let page = self .request( Method::GET, - &format!("{path}{sep}offset={offset}&size=100"), + &format!("{path}{sep}offset={offset}&size={PAGE_SIZE}"), None, ) .await? @@ -269,7 +277,7 @@ impl Jsm { .pointer("/links/next") .and_then(Value::as_str) .is_none_or(str::is_empty) - && count < 100 + && count < PAGE_SIZE { break; } @@ -326,8 +334,36 @@ impl Jsm { desired: &Value, previous: Option<&ProviderObjectRow>, overwrite: bool, + as_of: DateTime, ) -> Result { - let name = desired["name"].as_str().unwrap_or_default(); + let local_id = key + .split_once(':') + .and_then(|(_, id)| Uuid::parse_str(id).ok()) + .expect("provider object keys contain a resource UUID"); + // A deleted resource is absent desired state, not an edit to an existing + // resource. Clear stale identity before discovery so a lost replacement + // create response can be recovered by its stable ownership marker. + let mut previous = previous; + if let Some(old) = previous + && self.read(kind, &old.remote_id).await?.is_none() + { + db.forget(record.id, key).await?; + db.forget(record.id, &format!("pending:{key}")).await?; + if kind == "schedule" { + let prefix = format!("coverage:{local_id}:"); + for child in db + .objects(record.id) + .await? + .iter() + .filter(|r| r.object_key.starts_with(&prefix)) + { + db.forget(record.id, &child.object_key).await?; + } + } + previous = None; + } + let marker = ownership_marker(record.id, local_id); + let legacy_name = format!("meguri-{}-{}", record.id.simple(), local_id.simple()); let existing_id = if let Some(previous) = previous { Some(previous.remote_id.clone()) } else { @@ -335,7 +371,11 @@ impl Jsm { .list(&self.collection(kind)) .await? .into_iter() - .filter(|v| v["name"].as_str() == Some(name)) + .filter(|v| { + v["name"] + .as_str() + .is_some_and(|name| name.ends_with(&marker) || name == legacy_name) + }) .collect(); ensure( found.len() <= 1, @@ -354,18 +394,32 @@ impl Jsm { let expected = previous .map(|p| serde_json::from_str::(&p.content)) .transpose()?; - if previous.is_some() && current.is_none() && !overwrite { - return Err(Error::Conflict(crate::i18n::message( - "err_jsm_kind_was_deleted_externally_publication_paus_88543d", - &[("kind", (kind).to_string())], - ))); - } + let pending_key = format!("pending:{key}"); + let pending = + db.objects(record.id).await?.into_iter().find(|r| { + r.object_key == pending_key && Some(&r.remote_id) == existing_id.as_ref() + }); + let pending_expected = pending + .as_ref() + .map(|r| serde_json::from_str::(&r.content)) + .transpose()?; let matches_desired = current.as_ref().is_some_and(|c| equivalent(c, desired)); if let Some(current) = ¤t { let known = expected .as_ref() - .is_some_and(|last| equivalent(current, last)); - if !matches_desired && !known && !overwrite { + .is_some_and(|last| equivalent(current, last)) + || pending_expected + .as_ref() + .is_some_and(|next| equivalent(current, next)); + let legacy_matches = + if previous.is_none() && current["name"].as_str() == Some(&legacy_name) { + let mut legacy = desired.clone(); + legacy["name"] = json!(legacy_name); + equivalent(current, &legacy) + } else { + false + }; + if !matches_desired && !known && !legacy_matches && !overwrite { return Err(Error::Conflict(crate::i18n::message( "err_jsm_kind_changed_outside_meguri_publication_paus_ac8220", &[("kind", (kind).to_string())], @@ -373,48 +427,75 @@ impl Jsm { } } if kind == "schedule" + && current.is_some() && let Some(id) = &existing_id { - let overrides = self.list(&format!("/schedules/{id}/overrides")).await?; - if !overrides.is_empty() && !overwrite { - return Err(Error::Conflict( - crate::i18n::text( - "err_jsm_schedule_contains_external_overrides_publica_ff6a5d", - ) - .into(), - )); - } - for item in overrides { - let alias = item["alias"].as_str().ok_or_else(|| { - Error::Provider(crate::i18n::text("err_override_has_no_alias_0bab5d").into()) - })?; - ensure( - safe_id(alias), - crate::i18n::text("err_unexpected_jsm_override_identifier_896f12"), - )?; - self.request( - Method::DELETE, - &format!("/schedules/{id}/overrides/{alias}"), - None, - ) + self.check_coverage(db, record.id, local_id, id, overwrite, as_of) .await?; - } } let id = if matches_desired { existing_id.unwrap() } else if current.is_some() { let id = existing_id.unwrap(); - self.request( - Method::PATCH, - &format!("{}/{id}", self.collection(kind)), - Some(desired), - ) - .await? - .ok_or_else(|| { - Error::Conflict( - crate::i18n::text("err_jsm_resource_disappeared_during_publication").into(), + let current = current.as_ref().unwrap(); + if kind == "schedule" + && current["rotations"] + .as_array() + .is_some_and(|r| r.len() == 1) + && desired["rotations"] + .as_array() + .is_some_and(|r| r.len() == 1) + { + let rotation_id = current["rotations"][0]["id"] + .as_str() + .filter(|v| safe_id(v)) + .ok_or_else(|| { + Error::Provider( + crate::i18n::text("err_unexpected_jsm_resource_identifier_dedd70") + .into(), + ) + })?; + let mut metadata = desired.clone(); + metadata.as_object_mut().unwrap().remove("rotations"); + if !equivalent(current, &metadata) { + let mut intermediate = metadata.clone(); + intermediate["rotations"] = current["rotations"].clone(); + self.schedule_step( + db, + record.id, + key, + &id, + &format!("/schedules/{id}"), + &metadata, + &intermediate, + ) + .await?; + } + if !equivalent(¤t["rotations"][0], &desired["rotations"][0]) { + self.schedule_step( + db, + record.id, + key, + &id, + &format!("/schedules/{id}/rotations/{rotation_id}"), + &desired["rotations"][0], + desired, + ) + .await?; + } + } else { + self.request( + Method::PATCH, + &format!("{}/{id}", self.collection(kind)), + Some(desired), ) - })?; + .await? + .ok_or_else(|| { + Error::Conflict( + crate::i18n::text("err_jsm_resource_disappeared_during_publication").into(), + ) + })?; + } id } else { let response = self @@ -450,8 +531,303 @@ impl Jsm { ))); } db.checkpoint(record.id, key, &id, desired).await?; + db.forget(record.id, &pending_key).await?; Ok(id) } + #[allow(clippy::too_many_arguments)] + async fn schedule_step( + &self, + db: &Store, + workspace: Uuid, + key: &str, + id: &str, + path: &str, + body: &Value, + expected: &Value, + ) -> Result<()> { + let pending = format!("pending:{key}"); + db.checkpoint(workspace, &pending, id, expected).await?; + self.request(Method::PATCH, path, Some(body)) + .await? + .ok_or_else(|| { + Error::Conflict( + crate::i18n::text("err_jsm_resource_disappeared_during_publication").into(), + ) + })?; + let actual = self.read("schedule", id).await?.ok_or_else(|| { + Error::Provider( + crate::i18n::text("err_jsm_resource_is_missing_after_publication_3448ad").into(), + ) + })?; + ensure( + equivalent(&actual, expected), + crate::i18n::message( + "err_jsm_kind_read_back_does_not_match_the_requested_63440e", + &[("kind", "schedule".into())], + ), + )?; + db.checkpoint(workspace, key, id, expected).await?; + db.forget(workspace, &pending).await?; + Ok(()) + } + fn schedule_payloads( + &self, + schedule: &super::PublishedSchedule, + as_of: DateTime, + ) -> Result<(Vec, Vec)> { + if let Some(rotation) = &schedule.recurring { + let zone = schedule.timezone.parse::().map_err(|_| { + Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into()) + })?; + if half_hour_floor(rotation.start, zone) == rotation.start + && half_hour_floor(rotation.end, zone) == rotation.end + { + let participants = rotation + .participants + .iter() + .map(|id| self.mapped(*id).map(|id| json!({"type":"user","id":id}))) + .collect::>>()?; + let (kind, length) = if rotation.shift_days.is_multiple_of(7) { + ("weekly", rotation.shift_days / 7) + } else { + ("daily", rotation.shift_days) + }; + return Ok(( + vec![ + json!({"name":schedule.name,"startDate":rotation.start,"endDate":rotation.end,"type":kind,"length":length,"participants":participants}), + ], + vec![], + )); + } + } + if !needs_precise_coverage(schedule)? { + let zone = schedule.timezone.parse::().map_err(|_| { + Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into()) + })?; + let rotations = schedule.periods.iter().map(|p| Ok(json!({ + "name":if schedule.periods.len() == 1 { schedule.name.clone() } else { + format!("{} · {}", schedule.name, p.start.with_timezone(&zone).format("%d-%m-%Y %H:%M %:z")) + }, "startDate":p.start, "endDate":p.end, + "type":"daily", "length":1, "participants":[{"type":"user","id":self.mapped(p.user_id)?}] + }))).collect::>>()?; + return Ok((rotations, vec![])); + } + let zone = schedule.timezone.parse::().map_err(|_| { + Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into()) + })?; + let current = schedule + .periods + .iter() + .find(|p| p.start <= as_of && as_of < p.end); + let base_person = current.map(|p| self.mapped(p.user_id)).transpose()?; + let first = current.unwrap_or(&schedule.periods[0]); + let last_end = schedule.periods.last().unwrap().end; + let start = half_hour_floor(first.start, zone); + let floor_end = half_hour_floor(last_end, zone); + let end = if floor_end == last_end { + floor_end + } else { + floor_end + chrono::Duration::minutes(30) + }; + let participant = base_person.as_ref().map_or_else( + || json!({"type":"noone"}), + |id| json!({"type":"user","id":id}), + ); + let rotations = vec![ + json!({"name":schedule.name, "startDate":start, "endDate":end, + "type":"daily", "length":1, "participants":[participant]}), + ]; + let mut coverage = vec![]; + // JSM rejects new overrides starting in the past. The already effective + // assignee is the base; overrides describe only future transitions. + for p in schedule.periods.iter().filter(|p| p.start > as_of) { + coverage.push( + json!({"responder":{"type":"user","id":self.mapped(p.user_id)?}, + "startDate":milliseconds(p.start), "endDate":milliseconds(p.end)}), + ); + } + if base_person.is_some() { + for pair in schedule.periods.windows(2) { + if pair[0].end > as_of && pair[0].end < pair[1].start { + coverage.push(json!({"responder":{"type":"noone"}, + "startDate":milliseconds(pair[0].end), "endDate":milliseconds(pair[1].start)})); + } + } + if last_end > as_of && last_end < end { + coverage.push(json!({"responder":{"type":"noone"}, + "startDate":milliseconds(last_end), "endDate":end})); + } + } + Ok((rotations, coverage)) + } + + // Empty remote IDs are durable create intents, never verified publications. + async fn check_coverage( + &self, + db: &Store, + workspace: Uuid, + schedule: Uuid, + remote: &str, + overwrite: bool, + _as_of: DateTime, + ) -> Result<()> { + let prefix = format!("coverage:{schedule}:"); + let rows = db.objects(workspace).await?; + let mut owned = vec![]; + for row in rows.iter().filter(|r| r.object_key.starts_with(&prefix)) { + let content: Value = serde_json::from_str(&row.content)?; + if content["scheduleId"].as_str() == Some(remote) { + owned.push((row, content)); + } + } + let items = self.list(&format!("/schedules/{remote}/overrides")).await?; + for item in &items { + let alias = coverage_alias(item)?; + if let Some((_, content)) = owned.iter().find(|(row, _)| row.remote_id == alias) { + if !overwrite && !equivalent(item, &content["payload"]) { + return Err(Error::Conflict( + crate::i18n::text("jsm_coverage_conflict").into(), + )); + } + continue; + } + let pending: Vec<_> = owned + .iter() + .filter(|(row, content)| { + row.remote_id.is_empty() && equivalent(item, &content["payload"]) + }) + .collect(); + ensure( + pending.len() <= 1, + crate::i18n::text("jsm_coverage_conflict"), + )?; + if let Some((row, content)) = pending.first() { + ensure( + items + .iter() + .filter(|candidate| equivalent(candidate, &content["payload"])) + .count() + == 1, + crate::i18n::text("jsm_coverage_conflict"), + )?; + db.checkpoint(workspace, &row.object_key, alias, content) + .await?; + } else if overwrite { + self.request( + Method::DELETE, + &format!("/schedules/{remote}/overrides/{alias}"), + None, + ) + .await?; + } else { + return Err(Error::Conflict( + crate::i18n::text("jsm_coverage_conflict").into(), + )); + } + } + Ok(()) + } + + async fn publish_coverage( + &self, + db: &Store, + workspace: Uuid, + schedule: Uuid, + remote: &str, + desired: &[Value], + ) -> Result<()> { + let prefix = format!("coverage:{schedule}:"); + let rows = db.objects(workspace).await?; + let items = self.list(&format!("/schedules/{remote}/overrides")).await?; + let mut used = BTreeSet::new(); + for payload in desired { + let digest = Sha256::digest(serde_json::to_vec(payload)?); + let key = format!("{prefix}{digest:x}"); + used.insert(key.clone()); + let content = json!({"scheduleId":remote,"payload":payload}); + let previous = rows.iter().find(|r| r.object_key == key); + let alias = previous + .filter(|r| !r.remote_id.is_empty()) + .filter(|r| { + serde_json::from_str::(&r.content) + .ok() + .is_some_and(|v| v["scheduleId"].as_str() == Some(remote)) + }) + .map(|r| r.remote_id.as_str()); + let existing = alias.and_then(|alias| { + items + .iter() + .find(|item| item["alias"].as_str() == Some(alias)) + }); + let alias = if let (Some(alias), Some(existing)) = (alias, existing) { + if !equivalent(existing, payload) { + self.request( + Method::PUT, + &format!("/schedules/{remote}/overrides/{alias}"), + Some(payload), + ) + .await?; + } + alias.to_owned() + } else { + db.checkpoint(workspace, &key, "", &content).await?; + let created = self + .request( + Method::POST, + &format!("/schedules/{remote}/overrides"), + Some(payload), + ) + .await? + .ok_or_else(|| { + Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into()) + })?; + coverage_alias(&created)?.to_owned() + }; + let actual = self + .request( + Method::GET, + &format!("/schedules/{remote}/overrides/{alias}"), + None, + ) + .await? + .ok_or_else(|| { + Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into()) + })?; + ensure( + equivalent(&actual, payload), + crate::i18n::text("jsm_coverage_response_invalid"), + )?; + db.checkpoint(workspace, &key, &alias, &content).await?; + } + // Keep previous effective spans until all replacement writes are verified. + for row in rows + .iter() + .filter(|r| r.object_key.starts_with(&prefix) && !used.contains(&r.object_key)) + { + let content: Value = serde_json::from_str(&row.content)?; + if !row.remote_id.is_empty() { + let parent = content["scheduleId"] + .as_str() + .filter(|v| safe_id(v)) + .ok_or_else(|| { + Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into()) + })?; + ensure( + safe_id(&row.remote_id), + crate::i18n::text("jsm_coverage_response_invalid"), + )?; + self.request( + Method::DELETE, + &format!("/schedules/{parent}/overrides/{}", row.remote_id), + None, + ) + .await?; + } + db.forget(workspace, &row.object_key).await?; + } + Ok(()) + } + pub async fn publish( &self, db: &Store, @@ -459,6 +835,9 @@ impl Jsm { plan: &Publication, overwrite: bool, ) -> Result<()> { + for schedule in &plan.schedules { + validate_precise_intervals(schedule)?; + } // Preflight every recipient before creating or modifying any external object. self.resolve_people(record, plan) .await? @@ -481,8 +860,8 @@ impl Jsm { for s in &plan.schedules { let key = format!("schedule:{}", s.id); used.insert(key.clone()); - let rotations=s.periods.iter().map(|p|Ok(json!({"name":format!("shift-{}",p.start.timestamp()),"startDate":p.start,"endDate":p.end,"type":"daily","length":1,"participants":[{"type":"user","id":self.mapped(p.user_id)?}]}))).collect::>>()?; - let body = json!({"name":owned_name(record.id,s.id),"description":crate::i18n::message("err_managed_by_meguri_p0_e8ef37", &[("p0", (s.name).to_string())]),"teamId":self.team,"timezone":s.timezone,"enabled":true,"rotations":rotations}); + let (rotations, coverage) = self.schedule_payloads(s, plan.as_of)?; + let body = json!({"name":owned_name(record.id,s.id,&s.name),"description":crate::i18n::message("err_managed_by_meguri_p0_e8ef37", &[("p0", (s.name).to_string())]),"teamId":self.team,"timezone":s.timezone,"enabled":true,"rotations":rotations}); let remote = self .synchronize( db, @@ -492,8 +871,11 @@ impl Jsm { &body, previous.get(key.as_str()).copied(), overwrite, + plan.as_of, ) .await?; + self.publish_coverage(db, record.id, s.id, &remote, &coverage) + .await?; schedules.insert(s.id, remote); } for e in &plan.escalations { @@ -520,7 +902,7 @@ impl Jsm { }; rules.push(json!({"condition":step.condition,"notifyType":notify,"delay":step.delay_minutes,"recipient":{"type":kind,"id":id}})); } - let body = json!({"name":owned_name(record.id,e.id),"description":crate::i18n::message("err_managed_by_meguri_p0_e8ef37", &[("p0", (e.name).to_string())]),"enabled":true,"rules":rules,"repeat":{"count":e.repeat.count.max(1),"waitInterval":if e.repeat.count==0 {0}else{e.repeat.wait_minutes},"resetRecipientStates":e.repeat.reset_recipient_states,"closeAlertAfterAll":e.repeat.close_alert_after_all}}); + let body = json!({"name":owned_name(record.id,e.id,&e.name),"description":crate::i18n::message("err_managed_by_meguri_p0_e8ef37", &[("p0", (e.name).to_string())]),"enabled":true,"rules":rules,"repeat":{"count":e.repeat.count.max(1),"waitInterval":if e.repeat.count==0 {0}else{e.repeat.wait_minutes},"resetRecipientStates":e.repeat.reset_recipient_states,"closeAlertAfterAll":e.repeat.close_alert_after_all}}); let remote = self .synchronize( db, @@ -530,6 +912,7 @@ impl Jsm { &body, previous.get(key.as_str()).copied(), overwrite, + plan.as_of, ) .await?; policies.insert(e.id, remote); @@ -551,7 +934,7 @@ impl Jsm { } else { json!({"type":"match-all-conditions","conditions":conditions}) }; - let mut body = json!({"name":owned_name(record.id,route.id),"order":order,"timezone":record.document.timezone,"criteria":criteria,"notify":notify}); + let mut body = json!({"name":owned_name(record.id,route.id,&route.name),"order":order,"timezone":record.document.timezone,"criteria":criteria,"notify":notify}); if let Some(w) = &route.window { body["timezone"] = json!(w.timezone); let days = [ @@ -577,6 +960,7 @@ impl Jsm { &body, previous.get(key.as_str()).copied(), overwrite, + plan.as_of, ) .await?; } @@ -595,19 +979,21 @@ impl Jsm { .into(), )); } - if kind == "schedule" - && !overwrite - && !self - .list(&format!("/schedules/{}/overrides", old.remote_id)) - .await? - .is_empty() - { - return Err(Error::Conflict( - crate::i18n::text( - "err_an_obsolete_jsm_schedule_has_external_overrides_c6de63", - ) - .into(), - )); + if kind == "schedule" { + let local_id = old + .object_key + .split_once(':') + .and_then(|(_, id)| Uuid::parse_str(id).ok()) + .unwrap(); + self.check_coverage( + db, + record.id, + local_id, + &old.remote_id, + overwrite, + plan.as_of, + ) + .await?; } self.request( Method::DELETE, @@ -617,14 +1003,84 @@ impl Jsm { .await?; } db.forget(record.id, &old.object_key).await?; + db.forget(record.id, &format!("pending:{}", old.object_key)) + .await?; + if kind == "schedule" { + let prefix = format!( + "coverage:{}:", + old.object_key.strip_prefix("schedule:").unwrap() + ); + for child in db + .objects(record.id) + .await? + .iter() + .filter(|o| o.object_key.starts_with(&prefix)) + { + db.forget(record.id, &child.object_key).await?; + } + } } } Ok(()) } } -fn owned_name(team: Uuid, id: Uuid) -> String { - format!("meguri-{}-{}", team.simple(), id.simple()) +fn milliseconds(value: DateTime) -> DateTime { + DateTime::from_timestamp_millis(value.timestamp_millis()).unwrap() +} + +fn half_hour_floor(value: DateTime, zone: chrono_tz::Tz) -> DateTime { + let local = value.with_timezone(&zone); + value + - chrono::Duration::minutes(i64::from(local.minute() % 30)) + - chrono::Duration::seconds(i64::from(local.second())) + - chrono::Duration::nanoseconds(i64::from(local.nanosecond())) +} + +fn needs_precise_coverage(schedule: &super::PublishedSchedule) -> Result { + let zone = schedule + .timezone + .parse::() + .map_err(|_| Error::Provider(crate::i18n::text("jsm_invalid_coverage_interval").into()))?; + Ok(schedule.periods.iter().any(|p| { + [p.start, p.end].iter().any(|date| { + let local = date.with_timezone(&zone); + local.minute() % 30 != 0 || local.second() != 0 || local.nanosecond() != 0 + }) + })) +} + +fn validate_precise_intervals(schedule: &super::PublishedSchedule) -> Result<()> { + ensure( + !schedule.periods.is_empty() + && schedule + .periods + .iter() + .all(|p| milliseconds(p.start) < milliseconds(p.end)), + crate::i18n::text("jsm_invalid_coverage_interval"), + ) +} + +fn coverage_alias(value: &Value) -> Result<&str> { + value["alias"] + .as_str() + .filter(|v| safe_id(v)) + .ok_or_else(|| Error::Provider(crate::i18n::text("jsm_coverage_response_invalid").into())) +} + +fn ownership_marker(workspace: Uuid, id: Uuid) -> String { + let mut digest = Sha256::new(); + digest.update(workspace.as_bytes()); + digest.update(id.as_bytes()); + let digest = digest.finalize(); + format!( + "[meguri:{:032x}]", + u128::from_be_bytes(digest[..16].try_into().unwrap()) + ) +} + +fn owned_name(workspace: Uuid, id: Uuid, name: &str) -> String { + format!("{name} {}", ownership_marker(workspace, id)) } fn safe_id(id: &str) -> bool { !id.is_empty() @@ -646,9 +1102,45 @@ pub fn equivalent(actual: &Value, expected: &Value) -> bool { { return false; } - expected - .iter() - .all(|(k, v)| equivalent(actual.get(k).unwrap_or(&Value::Null), v)) + if expected.contains_key("responder") + && expected.contains_key("startDate") + && !expected.contains_key("rotationIds") + && actual.get("rotationIds").is_some_and(|ids| { + !ids.is_null() && ids.as_array().is_none_or(|ids| !ids.is_empty()) + }) + { + return false; + } + expected.iter().all(|(k, v)| { + if k == "repeat" + && expected.contains_key("rules") + && actual.get(k).is_none() + && v.get("waitInterval").and_then(Value::as_u64) == Some(0) + { + // JSM omits disabled repeats in escalation GET responses. + true + } else if k == "rotations" { + let Some(actual) = actual.get(k).and_then(Value::as_array) else { + return false; + }; + let Some(expected) = v.as_array() else { + return false; + }; + let mut remaining: Vec<_> = actual.iter().collect(); + for expected in expected { + let Some(index) = remaining + .iter() + .position(|actual| equivalent(actual, expected)) + else { + return false; + }; + remaining.remove(index); + } + remaining.is_empty() + } else { + equivalent(actual.get(k).unwrap_or(&Value::Null), v) + } + }) } Value::Array(expected) => actual.as_array().is_some_and(|a| { a.len() == expected.len() && a.iter().zip(expected).all(|(a, e)| equivalent(a, e)) @@ -670,6 +1162,34 @@ pub fn equivalent(actual: &Value, expected: &Value) -> bool { #[cfg(test)] mod tests { use super::*; + #[test] + fn omitted_escalation_repeat_only_matches_disabled_repeats() { + let mut expected = json!({"rules":[{"delay":0}],"repeat":{"count":1,"waitInterval":0,"resetRecipientStates":false,"closeAlertAfterAll":false}}); + let mut remote = json!({"rules":[{"delay":0}]}); + assert!(equivalent(&remote, &expected)); + remote["rules"][0]["delay"] = json!(10); + assert!(!equivalent(&remote, &expected)); + remote["rules"][0]["delay"] = json!(0); + expected["repeat"]["waitInterval"] = json!(5); + assert!(!equivalent(&remote, &expected)); + remote["repeat"] = expected["repeat"].clone(); + assert!(equivalent(&remote, &expected)); + for (field, changed) in [ + ("count", json!(2)), + ("waitInterval", json!(10)), + ("resetRecipientStates", json!(true)), + ("closeAlertAfterAll", json!(true)), + ] { + let mut changed_remote = remote.clone(); + changed_remote["repeat"][field] = changed; + assert!(!equivalent(&changed_remote, &expected)); + } + expected["repeat"]["waitInterval"] = json!(0); + assert!(!equivalent(&remote, &expected)); + remote["repeat"] = Value::Null; + assert!(!equivalent(&remote, &expected)); + } + #[test] fn normalization_ignores_ids_but_detects_extra_rotations_and_changed_owners() { let expected = json!({"rotations":[{"startDate":"2026-09-23T08:00:00Z","participants":[{"id":"alice","type":"user"}]}]}); diff --git a/src/providers/mod.rs b/src/providers/mod.rs index a3758a1..96dd02d 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -15,11 +15,14 @@ pub struct PublishedSchedule { pub name: String, pub timezone: String, pub periods: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recurring: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Publication { pub workspace_id: Uuid, pub revision: i64, + pub as_of: DateTime, pub from: DateTime, pub to: DateTime, pub schedules: Vec, @@ -149,6 +152,7 @@ pub fn compile( id: schedule.id, name: schedule.name.clone(), timezone: record.document.timezone.clone(), + recurring: crate::engine::recurring_rotation(&record.document, schedule, &periods)?, periods, }); } @@ -156,6 +160,7 @@ pub fn compile( Ok(Publication { workspace_id: record.id, revision: record.revision, + as_of: now, from: timeline.from, to: timeline.to, schedules, @@ -230,6 +235,45 @@ pub async fn publish( mod tests { use super::*; #[test] + fn jsm_team_identifier_preserves_operations_prefix_and_rejects_invalid_paths() { + let cloud = Uuid::new_v4().to_string(); + let team = Uuid::new_v4().to_string(); + for valid_team in [team.clone(), format!("og-{team}")] { + let mut doc = WorkspaceDocument::new("Platform".into()); + doc.integration = Integration::Jsm { + cloud_id: cloud.clone(), + team_id: valid_team.clone(), + advanced_escalations: false, + users: Default::default(), + }; + doc.validate(&Default::default()).unwrap(); + let saved: WorkspaceDocument = + serde_json::from_value(serde_json::to_value(&doc).unwrap()).unwrap(); + assert!( + matches!(saved.integration, Integration::Jsm { team_id, .. } if team_id == valid_team) + ); + for invalid in [ + "og-".to_owned(), + format!("og-og-{team}"), + format!("og-{team}/routing-rules"), + format!("og-{team}?x=1"), + ] { + if let Integration::Jsm { team_id, .. } = &mut doc.integration { + *team_id = invalid; + } + assert!(doc.validate(&Default::default()).is_err()); + } + if let Integration::Jsm { + cloud_id, team_id, .. + } = &mut doc.integration + { + *team_id = valid_team; + *cloud_id = format!("og-{cloud}"); + } + assert!(doc.validate(&Default::default()).is_err()); + } + } + #[test] fn unsupported_escalation_is_not_silently_dropped() { let mut doc = WorkspaceDocument::new("Platform".into()); doc.integration = Integration::Jsm { diff --git a/src/service.rs b/src/service.rs index 080feaf..3a6904a 100644 --- a/src/service.rs +++ b/src/service.rs @@ -38,10 +38,23 @@ pub async fn hash_password(password: String) -> Result { .map_err(|e| Error::Internal(e.to_string()))? } +pub fn normalize_email(value: &str) -> Result { + 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 { 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 { 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::>() - { - 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 { 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, +) -> BTreeSet { + legs.iter() + .flat_map(|l| [l.from_user, l.to_user]) + .filter(|id| *id != actor && members.contains(id)) + .collect() +} +fn replacement_permission(actor: Uuid, input: &NewRequest) -> Permission { + if input.force { + Permission::ForceOverrides + } else if input + .legs + .iter() + .any(|l| l.from_user != actor && l.to_user != actor) + { + Permission::EditSchedules + } else { + Permission::RequestOverrides + } +} +fn validate_request_participation( + actor: Uuid, + input: &NewRequest, + doc: &WorkspaceDocument, +) -> Result<()> { ensure( input.force - || input - .legs - .iter() - .all(|leg| leg.from_user == actor || leg.to_user == actor), + || input.legs.iter().all(|leg| { + leg.from_user == actor + || leg.to_user == actor + || planning_contact(doc, leg.from_user) + || planning_contact(doc, leg.to_user) + }), crate::i18n::text("err_you_can_give_your_own_shift_or_offer_to_cover_a_5e6f57"), ) } - -fn validate_request_access(input: &NewRequest, members: &BTreeSet) -> Result<()> { +fn validate_approval_access( + doc: &WorkspaceDocument, + legs: &[ReplacementLeg], + members: &BTreeSet, +) -> Result<()> { ensure( - input.force - || input - .legs - .iter() - .all(|l| members.contains(&l.from_user) && members.contains(&l.to_user)), - crate::i18n::text("err_both_people_need_team_access_to_approve_ask_a_le_7b0fc5"), + legs.iter() + .flat_map(|l| [l.from_user, l.to_user]) + .all(|id| members.contains(&id) || planning_contact(doc, id)), + crate::i18n::text("replacement_account_access"), ) } @@ -678,17 +723,15 @@ pub async fn preview_replacement( workspace: Uuid, mut input: NewRequest, ) -> Result { - let permission = if input.force { - Permission::ForceOverrides - } else { - Permission::RequestOverrides - }; + let permission = replacement_permission(actor, &input); let record = db .prepare(workspace, input.revision, actor, permission) .await?; - validate_request_participation(actor, &input)?; + validate_request_participation(actor, &input, &record.document)?; let members = db.member_ids(workspace).await?; - validate_request_access(&input, &members)?; + if !input.force { + validate_approval_access(&record.document, &input.legs, &members)?; + } ensure( input.reason.len() <= 1000, crate::i18n::text("err_comment_must_be_at_most_1000_characters_e5d5ef"), @@ -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 "#; diff --git a/src/site.rs b/src/site.rs new file mode 100644 index 0000000..e020e83 --- /dev/null +++ b/src/site.rs @@ -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 { + 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 { + 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::>(),"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::(&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::>(); + 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(()) +} diff --git a/src/store.rs b/src/store.rs index e353af5..e52d7e8 100644 --- a/src/store.rs +++ b/src/store.rs @@ -30,13 +30,37 @@ pub fn uuid(value: &str) -> Result { pub fn user(row: UserRow) -> Result { Ok(User { language: row.language, - id: uuid(&row.id)?, + person: Person { + id: uuid(&row.id)?, + user_id: Some(uuid(&row.id)?), + display_name: row.display_name, + email: 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 { if row.demo_deleted_at.is_some() { return Err(Error::NotFound); @@ -177,6 +201,22 @@ impl Store { details: Value, member: Option<(Uuid, Vec)>, ) -> 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> { let mut names = crate::audit::Names::new(); for person in self.directory(workspace).await? { - names.insert(person.id.to_string(), person.display_name); + names.insert(person.id.to_string(), person.display_name.clone()); } let current = self.workspace(workspace).await?; crate::audit::remember(&mut names, ¤t.document); @@ -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> { + let id = workspace.to_string(); + query!(SyncJobRow, $workspace_id == id) + .get(&self.db) + .await? + .and_then(|r| r.published_document) + .map(|s| serde_json::from_str(&s).map_err(Into::into)) + .transpose() + } pub async fn sync_status(&self, workspace: Uuid) -> Result { - let workspace = workspace.to_string(); + let record = self.workspace(workspace).await?; + let baseline = self.published_document(workspace).await?; + let changed = serde_json::to_value(&record.document)? != serde_json::to_value(&baseline)?; + let id = workspace.to_string(); + let mut status = match query!(SyncJobRow, $workspace_id == id) + .get(&self.db) + .await? + { + None => json!({"state":"draft"}), + Some(r) => { + let publication_busy = r.lease_until.is_some_and(|until| until > Utc::now()); + json!({"publication_busy":publication_busy,"lease_until":r.lease_until,"requested_revision":r.requested_revision,"authorized_revision":r.authorized_revision,"published_revision":r.published_revision,"state":r.state,"attempts":r.attempts,"last_error":r.last_error,"last_success":r.last_success,"window_end":r.window_end}) + } + }; + status["has_changes"] = json!(changed); + status["changes"] = publication_changes(&record.document, baseline.as_ref())?; + Ok(status) + } + pub async fn shared_draft(&self, workspace: Uuid) -> Result { + let id = workspace.to_string(); Ok( - match query!(SyncJobRow,$workspace_id==workspace) + match query!(WorkspaceDraftRow, $workspace_id == id) .get(&self.db) .await? { - None => json!({"state":"pending"}), - Some(r) => { - json!({"requested_revision":r.requested_revision,"published_revision":r.published_revision,"state":r.state,"attempts":r.attempts,"last_error":r.last_error,"last_success":r.last_success,"window_end":r.window_end}) + Some(row) => { + json!({"revision":row.revision,"payload":serde_json::from_str::(&row.payload)?}) } + None => json!({"revision":0,"payload":null}), }, ) } + pub async fn save_shared_draft( + &self, + workspace: Uuid, + actor: Uuid, + revision: i64, + payload: Value, + ) -> Result { + self.require(workspace, actor, Permission::EditSchedules) + .await?; + // Deserialize into the bounded typed input; never store arbitrary integration secrets. + let draft: crate::generation_service::Draft = serde_json::from_value(payload)?; + self.prepare(workspace, draft.revision, actor, Permission::EditSchedules) + .await?; + crate::error::ensure( + draft.variables.len() <= 100 && draft.rules.len() <= 50, + crate::i18n::text("err_shared_draft_limit"), + )?; + let payload = serde_json::to_string(&draft)?; + let changed = self.db.raw_with(r#" +WITH allowed AS (SELECT id FROM meguri__workspaces WHERE id=$1 AND revision=$5 AND demo_deleted_at IS NULL AND 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 '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 { + let desired = serde_json::to_value(document)?; + let old = serde_json::to_value(baseline)?; + let mut changes = vec![]; + let schedule_context_changed = [ + "timezone", + "people", + "holidays", + "absences", + "requests", + "horizon_days", + ] + .iter() + .any(|key| desired[key] != old[key]); + for (collection, kind) in [ + ("schedules", "schedule"), + ("escalations", "escalation"), + ("routes", "route"), + ] { + let empty = vec![]; + let before = old[collection].as_array().unwrap_or(&empty); + let after = desired[collection].as_array().unwrap_or(&empty); + for resource in after { + let previous = before.iter().find(|r| r["id"] == resource["id"]); + let action = match previous { + None => "create", + Some(prior) + if prior != resource + || (kind == "schedule" && schedule_context_changed) + || desired["integration"] != old["integration"] => + { + "update" + } + _ => "unchanged", + }; + changes.push( + json!({"id":resource["id"],"name":resource["name"],"kind":kind,"action":action}), + ); + } + for resource in before + .iter() + .filter(|r| !after.iter().any(|n| n["id"] == r["id"])) + { + changes.push( + json!({"id":resource["id"],"name":resource["name"],"kind":kind,"action":"remove"}), + ); + } + } + Ok(json!(changes)) +} diff --git a/src/web.rs b/src/web.rs index 6fec4e8..9da0694 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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 { .with_header("Cache-Control", "no-store") .into_response() } +async fn site_js() -> cot::Result { + Html::new(include_str!("../static/site.js")) + .with_header("Content-Type", "application/javascript; charset=utf-8") + .into_response() +} async fn index() -> cot::Result { let html = include_str!("../static/index.html") + .replace("", include_str!("../static/site.html")) .replace( "", include_str!("../static/generation-map.html"), @@ -230,6 +243,7 @@ async fn authenticate(db: &Store, request: &Request) -> Result { 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 { 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 { 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 { &db, credentials.username.clone(), credentials.display_name, + credentials.email, credentials.password.clone(), ) .await?; @@ -338,7 +355,7 @@ async fn dispatch(request: Request) -> Result { } 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 { } 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 { 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 { )?); } if action == "sync" && method == Method::POST { - db.retry(workspace, actor).await?; + db.retry(workspace, actor, revision(&body)?).await?; return response(json!({"ok":true})); } Err(Error::NotFound) diff --git a/src/worker.rs b/src/worker.rs index 4bb1571..4efa119 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -23,12 +23,16 @@ pub async fn run(db: Store) { pub async fn tick(db: &Store) -> Result<()> { let now = Utc::now(); let mut jobs = query!(SyncJobRow,$next_attempt<=now).all(&db.db).await?; - jobs.retain(|job| job.lease_until.is_none_or(|until| until <= now)); + jobs.retain(|job| { + job.authorized_revision.is_some() + && ["pending", "publishing", "failed", "conflict"].contains(&job.state.as_str()) + && job.lease_until.is_none_or(|until| until <= now) + }); jobs.sort_by_key(|job| job.next_attempt); for job in jobs.into_iter().take(20) { let token = Uuid::new_v4().to_string(); // Publication is bounded to four minutes; a five-minute lease survives a lost worker. - let claimed=db.db.raw_with("UPDATE meguri__sync_jobs SET lease_token=$1,lease_until=now()+interval '5 minutes',state='publishing' WHERE workspace_id=$2 AND (lease_until IS NULL OR lease_until Result<()> { }); match result { Ok((revision, end)) => { - db.db.raw_with("WITH published AS (UPDATE meguri__sync_jobs SET published_revision=$1,state=CASE WHEN requested_revision=$1 THEN 'synced' ELSE 'pending' END,attempts=0,last_error=NULL,last_success=now(),window_end=$2,next_attempt=CASE WHEN requested_revision=$1 THEN now()+interval '15 minutes' ELSE now() END,lease_token=NULL,lease_until=NULL WHERE workspace_id=$3 AND lease_token=$4 RETURNING *) INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT workspace_id,'publication.succeeded',$5,now() FROM published",&[&revision,&end,&job.workspace_id,&token,&json!({"revision":revision,"window_end":end}).to_string()]).await?; + db.db.raw_with("WITH published AS (UPDATE meguri__sync_jobs SET published_revision=$1,published_document=authorized_document,state='synced',attempts=0,last_error=NULL,last_success=now(),window_end=$2,next_attempt=now(),lease_token=NULL,lease_until=NULL WHERE workspace_id=$3 AND lease_token=$4 RETURNING *) INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT workspace_id,'publication.succeeded',$5,now() FROM published",&[&revision,&end,&job.workspace_id,&token,&json!({"revision":revision,"window_end":end}).to_string()]).await?; } Err(error) => { let conflict = matches!(error, Error::Conflict(_)); @@ -57,7 +61,7 @@ pub async fn tick(db: &Store) -> Result<()> { (5_i64 * 2_i64.pow(job.attempts.clamp(0, 9) as u32)).min(1800) }; let retry = Utc::now() + Duration::seconds(delay); - db.db.raw_with("UPDATE meguri__sync_jobs SET state=CASE WHEN requested_revision=$6 THEN $1 ELSE 'pending' END,last_error=CASE WHEN requested_revision=$6 THEN $2 ELSE NULL END,attempts=CASE WHEN requested_revision=$6 THEN attempts+1 ELSE 0 END,next_attempt=CASE WHEN requested_revision=$6 THEN $3 ELSE now() END,lease_token=NULL,lease_until=NULL WHERE workspace_id=$4 AND lease_token=$5",&[&state,&error.to_string(),&retry,&job.workspace_id,&token,&job.requested_revision]).await?; + db.db.raw_with("UPDATE meguri__sync_jobs SET state=$1,last_error=$2,attempts=attempts+1,next_attempt=$3,lease_token=NULL,lease_until=NULL WHERE workspace_id=$4 AND lease_token=$5",&[&state,&error.to_string(),&retry,&job.workspace_id,&token]).await?; tracing::warn!(workspace=%job.workspace_id,%error,"publication did not complete"); } } @@ -66,10 +70,20 @@ pub async fn tick(db: &Store) -> Result<()> { } pub async fn publish_one(db: &Store, workspace: Uuid) -> Result<(i64, chrono::DateTime)> { - let record = db.workspace(workspace).await?; + // Only the explicitly authorized snapshot may leave the application. + let id = workspace.to_string(); + let job = query!(SyncJobRow, $workspace_id == id) + .get(&db.db) + .await? + .ok_or(Error::NotFound)?; + let record = crate::store::WorkspaceRecord { + id: workspace, + revision: job.authorized_revision.ok_or(Error::NotFound)?, + document: serde_json::from_str(job.authorized_document.as_deref().ok_or(Error::NotFound)?)?, + }; let now = Utc::now(); let (from, to) = engine::publication_window(&record.document, now)?; - let timeline = db.historical_timeline(&record, from, to).await?; + let timeline = engine::timeline(&record.document, from, to)?; let plan = providers::compile(&record, timeline, now)?; providers::publish(db, &record, &plan, record.document.overwrite_drift).await?; Ok((record.revision, to)) diff --git a/static/app.css b/static/app.css index dc007ee..2a01480 100644 --- a/static/app.css +++ b/static/app.css @@ -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}} diff --git a/static/app.js b/static/app.js index 77f5cf8..47b1011 100644 --- a/static/app.js +++ b/static/app.js @@ -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) { async sendQuickRequest(){ if(this.matchingRequest && !this.form.force_mode && !this.form.custom_range && !this.form.swap){await this.decide(this.matchingRequest,'approve');if(!this.error){this.modal='';this.toast=tr('replacement_accepted_publication_is_queued_afea8f');}return;} if(!this.canSendRequest)return; - const force=this.form.force_mode; - await this.run(async()=>{await this.api(this.endpoint('/requests'),'POST',this.requestPayload());await this.loadWorkspace();this.modal='';},force?tr('replacement_applied_publication_is_queued_89e283'):tr('request_sent_waiting_for_your_colleague_b70b64')); + const immediate=this.form.force_mode || this.requestResult?.applies_immediately; + await this.run(async()=>{await this.api(this.endpoint('/requests'),'POST',this.requestPayload());await this.loadWorkspace();this.modal='';},immediate?tr('replacement_applied_publication_is_queued_89e283'):tr('request_sent_waiting_for_your_colleague_b70b64')); }, requestTitle(r){const leg=r.legs[0];return leg.to_user===r.requested_by?this.shortName(r.requested_by)+tr('offers_to_cover_935d75')+this.shortName(leg.from_user):this.shortName(r.requested_by)+tr('needs_cover_9e1ff7');}, async locateCoverage(gap){this.view='calendar';this.scheduleId=gap.schedule_id;const local=this.dayKey(new Date(gap.start),this.doc.timezone);this.startDay=this.addDays(local,-1);await this.run(()=>this.loadCalendar());const day=this.calendar.days.find(d=>Date.parse(d.start)<=Date.parse(gap.start) && Date.parse(gap.start)a.schedule_id===gap.schedule_id && a.user_id===gap.user_id && Date.parse(a.start)<=Date.parse(gap.start) && Date.parse(gap.start) { moveParticipant(index, delta) { const other = index + delta; if (other < 0 || other >= this.form.participants.length) return; [this.form.participants[index], this.form.participants[other]] = [this.form.participants[other], this.form.participants[index]]; }, addParticipant(id) { if (id && !this.form.participants.includes(id)) this.form.participants.push(id); }, async save(force = false) { + if(this.modal==='generation-transfer'){await this.applyTransfer();return;} if(this.bindingEditor || this.rulePeopleTarget){this.closeRuleEditor();return;} if(this.modal==='request'){await this.sendQuickRequest();return;} if(['variable','generation-rule'].includes(this.modal)){this.saveGenerationModal();return;} @@ -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'); }, diff --git a/static/binding-popover.html b/static/binding-popover.html index 22205e4..796e1b9 100644 --- a/static/binding-popover.html +++ b/static/binding-popover.html @@ -20,7 +20,7 @@

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

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