commit
a47c4a0398
96 files changed
+25721
No files matched your search
@@ -0,0 +1,7 @@
|
||||
target
|
||||
opsgenie-master
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
node_modules
|
||||
test-results
|
||||
@@ -0,0 +1,18 @@
|
||||
DATABASE_URL=postgresql://meguri:change-me@127.0.0.1:5432/meguri
|
||||
MEGURI_BIND=127.0.0.1:3000
|
||||
MEGURI_PUBLIC_URL=http://127.0.0.1:3000
|
||||
MEGURI_ROLE=all
|
||||
MEGURI_ALLOW_SIGNUP=false
|
||||
MEGURI_SEED_DEMO=false
|
||||
# Optional separately provisioned empty demo database. Protected databases are never reset.
|
||||
# MEGURI_DEMO_DATABASE_URL=postgresql://demo:change-me@127.0.0.1:5432/meguri_demo
|
||||
MEGURI_DEMO_WORKSPACE_TTL_SECONDS=86400
|
||||
MEGURI_DEMO_ACCOUNT_TTL_SECONDS=172800
|
||||
MEGURI_DEMO_REQUEST_TTL_SECONDS=7200
|
||||
MEGURI_DEMO_EVENT_TTL_SECONDS=21600
|
||||
MEGURI_DEMO_PURGE_GRACE_SECONDS=600
|
||||
MEGURI_SKIP_MIGRATIONS=false
|
||||
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
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Checks
|
||||
on: [push, pull_request]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
env:
|
||||
POSTGRES_PASSWORD: test-only
|
||||
POSTGRES_DB: meguri_test
|
||||
ports: ["5432:5432"]
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s --health-timeout 5s --health-retries 10
|
||||
env:
|
||||
MEGURI_TEST_DATABASE_URL: postgresql://postgres:test-only@127.0.0.1:5432/meguri_test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy --locked --all-targets -- -D warnings
|
||||
- run: cargo test --locked
|
||||
- run: cargo test --locked --test integration -- --ignored --test-threads=1
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- name: Browser and API checks
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:test-only@127.0.0.1:5432/meguri_test
|
||||
MEGURI_SEED_DEMO: "true"
|
||||
MEGURI_ALLOW_SIGNUP: "true"
|
||||
MEGURI_BROWSER_CHANNEL: chromium
|
||||
run: |
|
||||
cargo build --locked
|
||||
target/debug/meguri > server.log 2>&1 &
|
||||
for i in $(seq 1 60); do curl -fsS http://127.0.0.1:3000/health/ready && break || sleep 1; done
|
||||
npm run test:browser
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: browser-evidence
|
||||
path: |
|
||||
test-results/
|
||||
server.log
|
||||
@@ -0,0 +1,7 @@
|
||||
/target
|
||||
/opsgenie-master/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/node_modules/
|
||||
/test-results/
|
||||
@@ -0,0 +1,44 @@
|
||||
# Working on Meguri
|
||||
|
||||
Read `docs/product.md`, `docs/architecture.md`, `docs/development.md`, and `docs/testing.md` before changing behavior. They are the maintained specification. Update them with a behavior change, not after it. Keep this entry point short; put detailed decisions in `docs/`.
|
||||
|
||||
## Product boundaries
|
||||
|
||||
Meguri is the source of truth for on-call schedules, approved replacements, escalation policies, and their provider configuration. External providers receive the desired configuration and execute alerting. Meguri must never become an alert receiver or implement escalation timers/incident notifications.
|
||||
|
||||
All built-in UI text, labels, placeholders, accessible names and errors belong in the in-memory catalog `static/locales/messages.json`, with complete English, Russian and French entries. Templates and presentation code must use catalog keys, never inline human phrases. User-entered names/data remain unchanged. Store user language separately from scheduling state; unset follows a supported browser language, otherwise English. Source comments and repository documentation are English. Speak to collaborators in their language. Use ordinary forms, not a user-facing DSL.
|
||||
|
||||
## Module boundaries
|
||||
|
||||
- `domain`: typed state and permissions; no HTTP, database, or provider dependencies.
|
||||
- `engine`: deterministic schedule calculation with an explicit clock/range; no I/O.
|
||||
- `store` / `service`: Cot ORM and atomic PostgreSQL statement boundaries, authorization, optimistic versions, approval workflow, audit, durable sync requests.
|
||||
- `providers`: provider translation and HTTP only. Scheduling decisions belong in the engine. Never silently omit an unsupported provider feature.
|
||||
- `worker`: retryable reconciliation, drift checks, publication checkpoints. Multiple replicas must not publish a workspace concurrently.
|
||||
- `web`: thin authenticated handlers. Every workspace operation checks its workspace scope on the server.
|
||||
- `static`: localized HTML, CSS, Alpine.js. Never inject user text with `innerHTML` or `x-html`.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. Workspace permissions are checked on every API and background operation. Membership in one workspace never grants access to another. Workspace owners administer only their workspace. There is no workspace hierarchy or implicit team.
|
||||
2. A request is not an override until all required participants approve or a user with the force permission applies it. Every action is audited; its comment is optional, including forced application. Revalidate ownership, availability, and overlaps at application time. A subsequent approved replacement supersedes only overlapping effective spans; preserve original requests and history.
|
||||
3. Intervals are half-open `[start, end)`. Every schedule has its own handover in the workspace's IANA zone, snapshotted in workspace history. User timezone affects display only and defaults to the browser zone. Reject ambiguous/nonexistent local handovers instead of guessing. Never substitute a fixed UTC offset.
|
||||
4. Base rotations have an explicit ordered participant list and anchor. An override does not advance or reset the base rotation.
|
||||
5. Dependent coverage uses the effective fallback schedule at its own handover, including approved replacements. Country holidays skip only people in that country; general rotations remain continuous. Legacy global holidays retain direct-fallback behavior. Detect dependency cycles. Preserve approved history when rules change.
|
||||
6. Save state, audit, and the sync request in one transaction. Use revision checks to reject stale edits. Never hold a workspace write lock while calling a provider.
|
||||
7. External drift blocks publication by default. Overwrite is an explicit workspace policy. Reconcile only owned resources. Persist successful remote writes so partial failures can recover.
|
||||
8. Provider secrets come from the deployment secret store/environment, never API responses, audit snapshots, examples, or logs.
|
||||
9. Report approval and publication as separate statuses. Never label an unverified external write as synchronized.
|
||||
10. Scheduling contacts never gain login or workspace access through an email entry. Country defaults to CY. Imports use the same country codes and are add-only, server-previewed, and revision-checked. Resolve external identities exactly before remote writes; never guess accounts from similar names.
|
||||
|
||||
## Required workflow
|
||||
|
||||
Implement a small coherent change; add meaningful regression tests for scheduling, authorization, workflow, or reconciliation behavior it changes. Run `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, and the PostgreSQL integration suite described in `docs/development.md`. Inspect the actual browser for UI work. Do not add tests that only mirror constants or formatting.
|
||||
|
||||
Do not silently skip unavailable checks: record the limitation. Keep `Cargo.lock` committed for this application. New schema changes get a new migration; never edit an applied migration. Do not commit credentials, local database files, build output, or `opsgenie-master/`.
|
||||
|
||||
The ignored Terraform directory is reference material only. Store anonymized behavioral examples and tests in the repository so other agents do not depend on it. Preserve its business patterns, not its historical workarounds (manual DST tables, cached remote on-call lookup, multiple Terraform applies).
|
||||
|
||||
## Adding a provider
|
||||
|
||||
Document capability differences, credential requirements, identity mappings, ownership conventions, pagination, retries, idempotency, and drift semantics. Implement the shared provider contract, a fake-server contract test, and a dry-run path before enabling real writes. Do not hide capabilities behind vendor-specific scheduling logic in the engine. Live integration tests require a dedicated external test account; ordinary tests must never call a real provider.
|
||||
Generated
+3531
File diff suppressed because it is too large.
Load diff
+33
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "meguri"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
description = "Provider-independent on-call scheduling and configuration control plane"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5.3"
|
||||
cot = { version = "=0.7.0", default-features = false, features = ["postgres", "json", "test"] }
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
chrono-tz = "0.10.4"
|
||||
csv = "1.4"
|
||||
email_address = "0.2"
|
||||
reqwest = { version = "0.12.28", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.10.9"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["trace", "limit"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1.23", features = ["v4", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
FROM rust:1.97-bookworm AS build
|
||||
WORKDIR /build
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
COPY static ./static
|
||||
RUN cargo build --locked --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /build/target/release/meguri /usr/local/bin/meguri
|
||||
COPY static/vendor/ALPINE-LICENSE.md /usr/share/licenses/meguri/alpine-MIT.md
|
||||
USER 65532:65532
|
||||
ENV MEGURI_BIND=0.0.0.0:3000
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["/usr/local/bin/meguri"]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Meguri
|
||||
|
||||
A shared source of truth for on-call rotations, replacements, escalation policies, and routing. Rust + Cot 0.7 (HTTP, ORM, migrations), PostgreSQL, and locally vendored Alpine.js. The interface is English. Providers receive calculated intervals and execute notifications/escalation timers.
|
||||
|
||||
## Start locally
|
||||
|
||||
On Windows with the PostgreSQL server binaries installed:
|
||||
|
||||
```powershell
|
||||
./scripts/dev.ps1
|
||||
```
|
||||
|
||||
Open http://127.0.0.1:3000. Explicit demo accounts: `alice`, `bob`, `carol`; password `meguri-demo-only`. Stop with `./scripts/stop-dev.ps1`. Pass `-PostgresBin PATH` if PostgreSQL is elsewhere. Local data and logs live in ignored `target/local/`.
|
||||
|
||||
On other platforms, create a PostgreSQL database, set the environment variables in `.env.example`, then run `cargo run --locked`. Use `postgresql://` URLs. Enable `MEGURI_ALLOW_SIGNUP=true` to create accounts; `MEGURI_SEED_DEMO=true` works only on loopback. Environment files are not automatically loaded.
|
||||
|
||||
## Implemented
|
||||
|
||||
- Independent workspaces, local accounts, granular workspace grants, and durable audit/history.
|
||||
- Calendar-day rotations with IANA timezones, ordered participants, anchors, absence fallback, holidays, and escalation backup schedules.
|
||||
- Replacements and atomic reciprocal swaps; confirmation by the other employee or forced application with a reason.
|
||||
- Durable publication, optimistic revisions, worker leases, retries, checkpoints, external drift protection, and a downloadable preview.
|
||||
- Mock provider and a JSM Operations API adapter for schedules, escalations, and routing.
|
||||
|
||||
## Working through agents
|
||||
|
||||
Start with [AGENTS.md](AGENTS.md). Describe intended behavior and examples to the agent; it must update the specification, implementation, and relevant tests together. [Product](docs/product.md), [architecture](docs/architecture.md), [test strategy](docs/testing.md), [development](docs/development.md), and [deployment](docs/deployment.md) are maintained project inputs.
|
||||
|
||||
```text
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test
|
||||
cargo test --test integration -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
The integration command requires `MEGURI_TEST_DATABASE_URL` for a disposable PostgreSQL database. CI runs it explicitly; ordinary unit tests need no database or external account. Browser checks are in `tests/browser.cjs`.
|
||||
|
||||
## Current boundaries
|
||||
|
||||
OIDC identity linking has a schema boundary but no login adapter yet. JSM has local HTTP contract coverage and still needs acceptance testing against a dedicated JSM test team and your subscription. GKE manifests are deployment templates, not an already deployed environment. No alert ingestion or notification engine is included.
|
||||
|
||||
The ignored `opsgenie-master/` directory is local reference material. Its behavioral examples are captured in the product contract and tests; application and CI do not depend on it.
|
||||
|
||||
Demo mode is disposable: `MEGURI_SEED_DEMO=true` resets only positively marked demo data on restart and expires temporary resources while running. Unmarked/normal databases are protected and trigger an isolated demo database with a permanent warning. See [demo lifecycle and retention settings](docs/development.md#demo-lifecycle). Alice leads two generated examples; Bob demonstrates Engineering on-call, Carol daily Support shifts. All use `meguri-demo-only`.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Render placeholders before use. Apply the migration Job first, wait for completion,
|
||||
# then apply this file. See docs/deployment.md for IAM, secrets and ingress.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: meguri
|
||||
annotations:
|
||||
iam.gke.io/gcp-service-account: MEGURI_GSA@PROJECT_ID.iam.gserviceaccount.com
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: meguri-config
|
||||
data:
|
||||
MEGURI_BIND: 0.0.0.0:3000
|
||||
MEGURI_PUBLIC_URL: https://meguri.example.internal
|
||||
MEGURI_ROLE: all
|
||||
MEGURI_SKIP_MIGRATIONS: "true"
|
||||
MEGURI_ALLOW_SIGNUP: "false"
|
||||
MEGURI_SEED_DEMO: "false"
|
||||
RUST_LOG: meguri=info,cot=warn
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: meguri
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels: {app: meguri}
|
||||
template:
|
||||
metadata:
|
||||
labels: {app: meguri}
|
||||
spec:
|
||||
serviceAccountName: meguri
|
||||
terminationGracePeriodSeconds: 60
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile: {type: RuntimeDefault}
|
||||
initContainers:
|
||||
- name: cloud-sql-proxy
|
||||
restartPolicy: Always
|
||||
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.1
|
||||
args: ["--private-ip", "--structured-logs", "--port=5432", "--health-check", "--http-address=0.0.0.0", "--http-port=9090", "PROJECT_ID:REGION:INSTANCE"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: {drop: [ALL]}
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 128Mi}
|
||||
limits: {memory: 256Mi}
|
||||
startupProbe:
|
||||
httpGet: {path: /startup, port: 9090}
|
||||
periodSeconds: 2
|
||||
failureThreshold: 30
|
||||
containers:
|
||||
- name: meguri
|
||||
image: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/meguri:RELEASE
|
||||
ports: [{name: http, containerPort: 3000}]
|
||||
envFrom:
|
||||
- configMapRef: {name: meguri-config}
|
||||
- secretRef: {name: meguri-secrets}
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: {drop: [ALL]}
|
||||
resources:
|
||||
requests: {cpu: 250m, memory: 256Mi}
|
||||
limits: {memory: 512Mi}
|
||||
startupProbe:
|
||||
httpGet: {path: /health/ready, port: http}
|
||||
periodSeconds: 2
|
||||
failureThreshold: 60
|
||||
readinessProbe:
|
||||
httpGet: {path: /health/ready, port: http}
|
||||
livenessProbe:
|
||||
httpGet: {path: /health/live, port: http}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: meguri
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector: {app: meguri}
|
||||
ports: [{port: 80, targetPort: http}]
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: meguri
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels: {app: meguri}
|
||||
@@ -0,0 +1,46 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: meguri-migrate-RELEASE
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
activeDeadlineSeconds: 300
|
||||
ttlSecondsAfterFinished: 86400
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
serviceAccountName: meguri
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile: {type: RuntimeDefault}
|
||||
initContainers:
|
||||
- name: cloud-sql-proxy
|
||||
restartPolicy: Always
|
||||
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.1
|
||||
args: ["--private-ip", "--structured-logs", "--port=5432", "--health-check", "--http-address=0.0.0.0", "--http-port=9090", "PROJECT_ID:REGION:INSTANCE"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: {drop: [ALL]}
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 128Mi}
|
||||
limits: {memory: 256Mi}
|
||||
startupProbe:
|
||||
httpGet: {path: /startup, port: 9090}
|
||||
periodSeconds: 2
|
||||
failureThreshold: 30
|
||||
containers:
|
||||
- name: migrate
|
||||
image: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/meguri:RELEASE
|
||||
envFrom:
|
||||
- configMapRef: {name: meguri-config}
|
||||
- secretRef: {name: meguri-secrets}
|
||||
env:
|
||||
- {name: MEGURI_ROLE, value: migrate}
|
||||
- {name: MEGURI_SKIP_MIGRATIONS, value: "false"}
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: {drop: [ALL]}
|
||||
resources:
|
||||
requests: {cpu: 250m, memory: 128Mi}
|
||||
limits: {memory: 256Mi}
|
||||
@@ -0,0 +1,120 @@
|
||||
# Architecture
|
||||
|
||||
See [workspace variables and visual matrix rules](proposals/workspace-templates.md) for design rationale; implemented behavior is specified in product.md.
|
||||
|
||||
A modular Rust application serves the web/API and runs a durable publication worker. The same executable can run either or both roles. PostgreSQL is the sole persistence and coordination service. Production targets GKE with Cloud SQL for PostgreSQL; no Redis or message broker is required.
|
||||
|
||||
## Persistence and concurrency
|
||||
|
||||
Users, sessions, workspaces, memberships, audit, history, provider checkpoints, and sync jobs are Cot ORM models. Bounded typed documents use serialized JSON in text columns with a PostgreSQL JSON validity constraint and optimistic revision. Cot 0.7 lacks JSON/UUID model fields, so UUIDs also use validated strings. Do not introduce another ORM for these types.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Scheduling model
|
||||
|
||||
Rotation and priority-owner schedules are separate typed variants. Priority-owner schedules reference another schedule as fallback; the dependency graph must be acyclic. Evaluation splits at local handovers and override boundaries. Resolve a schedule's approved overrides before letting other schedules depend on it. Adjacent equivalent intervals merge; the result records the assignment reason.
|
||||
|
||||
The engine receives explicit state and time bounds. It must produce the same result regardless of database, network, or provider. Approved changes and dated revisions support historical inspection; future configuration changes must not rewrite past history.
|
||||
|
||||
`configuration` contains pure dependency-aware removal planning and route permutation operations. Services authorize, revalidate the plan against the submitted revision and explicit current time, then commit through the existing history/audit/outbox boundary. Providers still receive ordinary schedules, policies, and consecutively ordered routes.
|
||||
|
||||
`absence` computes pure, bounded multi-schedule replacement plans and before/after summaries. Range inputs use each schedule's own handover. Requests reference the absence that produced them, permitting approval to revalidate original ownership against the same document with that absence removed, while checking substitute availability against current state. Automatic coverage fallback remains an engine rule. Draft previews never write; absence and all generated requests commit as one state transition. Unavailable assignments are diagnostics, never eligible future publication periods, including single-participant rotations.
|
||||
|
||||
`generation` is a pure typed data/binding interpreter and materializer. `generation_service` validates one workspace revision and authorizes that workspace, then atomically persists its variables, generated resources, history, audit and sync request through the same commit boundary. Provider calls remain outside activation. Workspace documents retain exact variables, rule definitions, and resource ownership manifests so expansion is reproducible. Legacy migration 5 introduced variables; migration 8 consolidates them into the workspace document. No applied migration is changed.
|
||||
|
||||
## Identity and permissions
|
||||
|
||||
Session cookies are HttpOnly, SameSite=Lax, and Secure under HTTPS. Mutations require same-origin requests and a session CSRF token. Passwords use Argon2. Resource lookup always includes workspace context and server-side membership. External identity linking is a future adapter, never a replacement for internal user IDs.
|
||||
|
||||
Replacement preview is a read-only service using the same engine, revision, participant and permission checks as submission. Calendar actions use server-generated interval boundaries; inclusive range inputs are converted through schedule handovers on the server. Volunteering changes the initiator, not the approval requirements. Comments remain in the backward-compatible reason/force_reason fields but are optional action annotations. Current-shift clipping uses one explicit server timestamp per validation/application.
|
||||
|
||||
## Provider contract
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
JSM REST references (verified September 2026):
|
||||
- https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-schedules/
|
||||
- https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-schedule-rotations/
|
||||
- https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-schedule-overrides/
|
||||
- https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-escalations/
|
||||
- https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/api-group-routing-rules/
|
||||
|
||||
Random recipients and escalation repeats require JSM Premium/Enterprise. Provider validation must not pretend a feature is available merely because it is representable in JSON.
|
||||
|
||||
JSM documents `repeat.waitInterval=0` as disabling repeats even when count is nonzero; the reference Terraform uses this combination. Enabling repeats requires a positive interval. A disabled local count maps to provider count 1 / interval 0 because the provider count minimum is 1. The reference's immediate step uses delay 0; verify it in live acceptance because the published schema lists a minimum of 1. Never silently change immediate notification to a one-minute delay.
|
||||
## Scheduling metadata and import boundaries
|
||||
|
||||
WorkspaceDocument owns versioned scheduling people, country holidays, and per-schedule handovers. Contacts do not become authentication principals. Workspace-directory reads are workspace-scoped; identity IDs cannot be invented or linked through unverified email. CSV parsing and comparison are pure functions; preview is read-only, and import rechecks the workspace revision and commits history, audit, and outbox together. Working intervals, variables, schedules and access updates share the same workspace revision; working-interval changes atomically update its route snapshots.
|
||||
|
||||
Workspace creation atomically creates its document, owner membership, initial history, audit and sync request. There is no parent entity or hidden default team. Migration 8 replaces empty legacy tenant/team tables and refuses populated legacy databases; demo provenance checks run before migration.
|
||||
|
||||
Migration 3 upgrades old JSON snapshots without changing old handover boundaries or global-holiday meaning and introduces workspace working intervals. Migration 4 adds user country (CY) and an optional display timezone (null follows the browser). Current scheduling state copies the workspace timezone; historical documents retain the timezone effective at their revision. Country-profile changes propagate as dated workspace revisions, while display-timezone preferences remain outside the engine.
|
||||
|
||||
Email-only recipients resolve through [Jira user search](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-user-search/#api-rest-api-3-user-search-get) with an exact, active, unique email match. The authenticated service account needs Browse users and groups permission and appropriate Jira API scopes in addition to Operations access. Profile visibility may hide emails; explicit Atlassian account IDs remain the fallback. All required recipients resolve before the first provider write. Tests inject a localhost directory and never query real employee data.
|
||||
|
||||
## Demo runtime boundary
|
||||
|
||||
`demo_runtime` chooses the database before Cot migrations. A separate `public.meguri_runtime_guard` records last startup mode; proof and reset recheck it under a PostgreSQL advisory transaction lock. Empty-DB claiming checks user relations. Reset enumerates only Meguri application tables, uses no CASCADE, and preserves migration/provenance metadata. Distinct application names detect another active client. A protected configured DB triggers a separate database and an immutable process notice surfaced by `/api/session`; no credentials are exposed. Normal startup marks its database normal and never schedules cleanup.
|
||||
|
||||
Migration 6 adds nullable demo deletion timestamps and seed pins. Cleanup gates every write on demo provenance and uses explicit creation timestamps and a supplied clock. Workspaces expire as complete configuration aggregates, revisions invalidate in-flight edits, listings/access exclude expired resources, and a later CTE deletes dependent rows in one statement. Request expiry saves a new historical document and outbox job. History compaction retains a boundary snapshot. A minute timer refreshes expired seed workspaces through the ordinary services; only the five baseline account identities are permanent during a run. External provider writes are disabled in demo at both configuration and dispatch boundaries.
|
||||
|
||||
## Presentation catalog
|
||||
|
||||
Migration 7 adds nullable en/ru/fr user language. A LazyLock Rust catalog and eagerly loaded browser catalog share messages.json. Parameter substitution is single-pass text, never HTML. API diagnostics retain English compatibility and are localized at presentation. Account language updates use the profile write path without scheduling/outbox changes. Navigation uses browser history and ordinary links; stale workspace responses cannot replace the currently selected workspace.
|
||||
|
||||
Cover request date/time inputs are resolved through the engine's IANA wall-clock resolver on the server. Optional clocks preserve legacy schedule-handover API behavior when omitted. Preview and mutation share this normalization path and the same ownership/availability validation.
|
||||
|
||||
Audit reads enrich payloads from scoped history snapshots and current directory fallbacks without rewriting stored events. Workspace mutation audit timestamps reuse the effective timestamp returned by the history CTE, ensuring the event resolves against its own revision.
|
||||
|
||||
|
||||
Applied requests retain original legs and optionally store effective_legs (None means original legs for legacy documents; an empty list means fully superseded). Application subtracts new spans from older active spans, preserving both sides of partial intervals. Engine, provider identities and configuration protections use effective spans; all updates share the existing revision-checked transaction.
|
||||
|
||||
Binding preview uses a presentation-only formatter registry for people, schedules, escalation policies and routing rules. A map built from the already authorized client state resolves known IDs/exact emails to shallow name/type/identity objects in a recursive walk. Unknown references and scalar values remain unchanged; generated-output metadata stays an example. No extra network queries or changes to binding values, generator types or saved JSON.
|
||||
|
||||
Generator issues carry optional rule/block/variable UUIDs for navigation, independent of localized paths. The client never publishes when navigating diagnostic links. Diagnostics always stay within the authorized workspace. CoverRange optionally contains exact UTC bounds and a schedule ID, validated inside the absence and declared duty days. Overlap checks compare effective intervals per schedule; exact ranges on distinct schedules may coexist. Existing bulk duty-day ranges keep their behavior.
|
||||
|
||||
CoverRange.local carries optional schedule scope plus local date/time bounds. The engine resolves both via the workspace IANA zone, rejecting DST gaps/folds and invalid bounds. Existing day-only and exact-UTC API ranges remain compatible; the UI emits local ranges exclusively.
|
||||
|
||||
Coverage diagnostics distinguish pending approval from missing substitutes. Pending request boundaries split uncovered intervals so partial requests never disguise remaining gaps. The UI shows substitutes and outstanding approvers, links to requests, and keeps publication blocked until approval. Browser regression covers partial pending coverage, remaining gaps, approver filtering, and responsive spacing.
|
||||
|
||||
Schedule setup places narrower variable and generation-rule panels beside a stacked availability sidebar. Absences and holidays remain open; their lists scroll internally. Country holiday groups retain accordions inside the bounded list, with add/import actions opening dialogs. Responsive layout moves the sidebar below the editors. Browser checks cover column position, bounded holiday expansion, visible actions and mobile width.
|
||||
|
||||
Generated routing placement is shown only when manual routes and an enabled route-generating rule coexist. Options name the manual routes in evaluation order; the stored insertion index is unchanged. Browser checks verify hidden controls for generated-only setups and named options/order for mixed routing.
|
||||
|
||||
Resource inventory is a distinct UI component (`resource-inventory.html`). All expanded resource kinds share `resource-details.html`: labeled icon rows, consistent spacing, sequential connectors, and a common source-rule control. Presentation rows and monochrome SVG paths come from pure UI helpers; schedules, routing order and provider state are unchanged. Check rotation, fixed owner, priority fallback, escalation timing/repeats and routing conditions on desktop/mobile.
|
||||
|
||||
Expanded resources use compact type-specific horizontal widgets instead of parameter tables: rotation metadata plus ordered people, priority people plus fallback, escalation steps plus repeat footer, routing conditions plus destination. Shared icon, chip and connector styles keep them consistent. Chains wrap within narrow screens; source links and edit actions retain their common placement.
|
||||
|
||||
The generation-rule editor is a generic technical constructor: visible iteration aliases and collection bindings, an explicit optional condition, and typed output blocks. New rules start without implicit project/responsible-variable detection or generated blocks. Variable names carry no semantic meaning. Compact controls retain the existing typed binding editor, value preview, live server diagnostics and draft semantics. Check no-iteration rules, nested collection bindings, conditions and multiple output types on desktop/mobile.
|
||||
|
||||
Binding selection uses one anchored, viewport-clamped popover beside the originating control; the constructor remains visible and keeps its scroll position. All source kinds, nested lookups/lists, paths, text affixes and live value previews remain supported. Literal escalation delays are directly editable. Done/Escape closes the popover and returns focus. Server and change previews are collapsed summaries with counts and bounded change lists. Browser checks cover context retention, source editing, nested bindings, keyboard dismissal and mobile bounds.
|
||||
|
||||
Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration.
|
||||
|
||||
## Workspace aggregate
|
||||
|
||||
`WorkspaceRow` stores one `WorkspaceDocument` and one revision. The document owns working interval, drift policy, variables, rules, schedules, people, holidays, absences, requests and provider configuration. `WorkspaceMemberRow` stores per-user grants and ownership; there is no parent entity. History, audit, jobs and checkpoints reference one workspace ID. Generator activation and working-interval updates use the ordinary atomic workspace commit. JSM team IDs identify external provider destinations, not internal teams.
|
||||
|
||||
Variable list summaries render nested nodes by type. Object entries prefer a nonempty text `name` or `title`; otherwise they show a bounded key/count summary. People resolve through the scoped directory. Summaries never read a container’s null scalar slot as its content.
|
||||
|
||||
Variable editing uses a bounded, progressively disclosed tree: only the root starts expanded, object rows show names and collection summaries, and nested values expand in place. Values mode hides type/key controls; Structure mode exposes them without losing edits. Duplicate assigns fresh IDs recursively; ordering and live server validation remain intact. Check nested editing, duplication, mode switching and narrow viewports.
|
||||
|
||||
Variable-tree indentation uses stylesheet depth classes, not inline custom properties (blocked by the application CSP). All row contents share the inset, with vertical guides for ancestor levels and smaller but preserved mobile steps.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,16 @@
|
||||
# GKE and Cloud SQL
|
||||
|
||||
Target: GKE, Cloud SQL for PostgreSQL 17, and Cloud SQL Auth Proxy using Workload Identity. PostgreSQL is the durable queue and state store; no Redis is required. Templates are in `deploy/`. They have not been applied to a cloud project.
|
||||
|
||||
1. Provision a private-IP Cloud SQL instance, database and application login; enable backups/PITR. Use a VPC-native GKE cluster with access to the database VPC. Configure Workload Identity and grant its Google service account Cloud SQL Client. Follow [Google's GKE connection guide](https://cloud.google.com/sql/docs/postgres/connect-kubernetes-engine).
|
||||
2. Build `Dockerfile`, push your image, and replace project, region, instance, release, service-account, image, and public URL placeholders. Review/pin the proxy image for your rollout; the template uses the version in Google's documented example. Native sidecars are required. Docker/GKE deployment must be validated in your environment.
|
||||
3. Create the Kubernetes service account and ConfigMap from `gke.yaml`, then the `meguri-secrets` Secret through your deployment secret manager. It needs `DATABASE_URL=postgresql://USER:PERCENT_ENCODED_PASSWORD@127.0.0.1:5432/meguri`. Proxy-to-database transport is encrypted; loopback stays inside the pod. Add tenant-specific JSM `_EMAIL` / `_TOKEN` variables only for configured tenants. Never commit rendered secrets.
|
||||
4. Apply only `migrate.yaml`, wait for its successful completion, then apply the Deployment, Service and PDB. Migrations use the same image with `MEGURI_ROLE=migrate`. Replicas have `MEGURI_SKIP_MIGRATIONS=true`; do not race migrations during rollout.
|
||||
5. Add your internal HTTPS ingress. `MEGURI_PUBLIC_URL` must exactly match the browser origin (scheme, host, port); mutations and cookies depend on it. The default Service is ClusterIP. Keep access inside your organization while OIDC is being added. To bootstrap local accounts, temporarily enable signup in that restricted environment, create accounts, then disable signup. Never enable demo seeding outside loopback.
|
||||
6. Run health checks, a two-user replacement, a tenant-isolation check, and the provider acceptance checklist in `testing.md`. Enable real JSM integration only after preview and acceptance.
|
||||
|
||||
The default Deployment has two replicas serving both web and workers. Conditional database leases serialize each team's publication. Roles can later be split into `web` and `worker` deployments without changing the binary. On interruption, a completed remote step remains checkpointed; an unfinished lease expires after five minutes.
|
||||
|
||||
Monitor failed/conflict sync jobs, oldest `last_success`, and `window_end` approaching the current time. Meguri publishes a finite horizon and must keep running to extend it. Configure operational monitoring around these values; Meguri does not send incident notifications itself. Readiness checks PostgreSQL; liveness checks the process.
|
||||
|
||||
The current polling worker and history/audit reads target team-scale workloads. Benchmark and add query pagination/queue batching before broad multi-tenant SaaS rollout. Configure ingress-level rate limits in addition to per-process login throttling. Password recovery, session administration, OIDC, history archival, and live provider acceptance are subsequent operational work.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Development
|
||||
|
||||
Requirements: Rust 1.97 (Cot itself requires 1.94), PostgreSQL 17+, and a browser. Node is only needed for browser automation or upgrading vendored browser assets; serving the application has no Node build step.
|
||||
|
||||
Use `.env.example` as the list of supported settings. Environment files are not loaded automatically. Start a dedicated PostgreSQL database, set `DATABASE_URL`, then run `cargo run`. Database migrations run before the server accepts requests.
|
||||
|
||||
For an isolated Windows development database, use `scripts/dev.ps1`. It uses installed PostgreSQL binaries, creates data under ignored `target/`, binds the database and app to loopback, enables registration, and explicitly seeds demonstration accounts. `scripts/stop-dev.ps1` stops only the processes started for this workspace.
|
||||
|
||||
Required checks:
|
||||
|
||||
```text
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test
|
||||
```
|
||||
|
||||
PostgreSQL integration tests require `MEGURI_TEST_DATABASE_URL` pointing at a disposable database, with CREATEDB for the isolated demo-lifecycle tests. Run `cargo test --test integration -- --ignored --test-threads=1`. Most tests create isolated workspaces/users; lifecycle tests create and remove uniquely named databases to test protection, reset, retention, and reseeding. Never use a production database.
|
||||
|
||||
Browser checks: sign in; inspect the calendar; create a request as one user and approve it as another; verify the publication status separately; verify that a user outside the workspace cannot read it. Use both desktop and narrow viewports. A test failure is not a reason to relax an authorization or scheduling invariant.
|
||||
|
||||
## Operational limitations
|
||||
|
||||
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.
|
||||
|
||||
Read [testing.md](testing.md) when changing rules and [deployment.md](deployment.md) before preparing GKE deployment.
|
||||
|
||||
## Demo lifecycle
|
||||
|
||||
`MEGURI_SEED_DEMO=true` enables disposable demo mode, not just seed insertion. It is limited to loopback and a single `all` or `web` process. Startup verifies the previous database mode before migrations, resets only positively marked demo application data, migrates, and seeds two examples. Normal startup records `normal`, including migration/worker roles. A missing or unreadable provenance marker never grants deletion authority. The existing development `meguri` DB will therefore be preserved on its first transition to this mode.
|
||||
|
||||
When the configured DB is protected, Meguri selects a separate `meguri_demo_<stable fingerprint>_<slot>` DB. The application login needs CREATEDB, or an administrator must supply an empty `MEGURI_DEMO_DATABASE_URL` with suitable permissions. Credentials are never shown in the browser/log notice. Protected fallback candidates are skipped, never dropped or overwritten. Isolation failure aborts startup. Do not manually mark a production database as demo. Another active client blocks reset; stop the old demo process before restarting.
|
||||
|
||||
The permanent header explains creation-based expiry and any protected-DB fallback. Defaults: workspaces 24 hours; visitor accounts 48 hours; requests 2 hours; audit/history 6 hours; physical purge 10 minutes after soft deletion. Configure seconds with the `MEGURI_DEMO_*` settings in `.env.example` (60 seconds–30 days). Sweeps run every minute. Nested configuration expires with its owning aggregate to preserve dependencies. Seed accounts remain; baseline workspaces are reseeded after expiry. No cleanup runs in normal mode. Demo rejects real provider integrations/publication.
|
||||
|
||||
Alice (`meguri-demo-only`) leads both examples. Bob belongs to Engineering; Carol belongs to Support. Engineering uses `projects`, `responsible`, and `on_call`; Support has independent `queues`, `agents`, and `lead` variables. All generated state is activated through the regular preview/commit code path with the mock provider.
|
||||
|
||||
Additional browser suites: `browser-absence.cjs` checks collapsed holidays, DD-MM-YYYY input, live bulk absence impact, range replacements and force; `browser-generation.cjs` checks demos, their permanent notice and isolated memberships, visual data/binding editing, early errors, preview without writes, stable generated IDs, and mobile forms. Restart after browser tests to return to the four clean examples.
|
||||
|
||||
OIDC is intentionally deferred; local password authentication is implemented first. No external JSM account is needed to run the mock provider. JSM writes require explicit integration configuration and workspace-scoped environment credentials. Contract tests use a fake HTTP server, never a production account.
|
||||
|
||||
Workspace configuration edits take effect when saved. Historical queries use saved configuration revisions. There is no user-configurable delayed activation of a whole configuration revision in the first release.
|
||||
|
||||
`tests/browser-cover.cjs` exercises daily volunteering/approval and optional comments in a separate disposable workspace, with desktop and mobile screenshots. Configuration navigation is grouped under Setup.
|
||||
|
||||
Navigation and language coverage: tests/browser-navigation.cjs checks direct menu/workspace links, reload and browser history, saved language versus browser preference, Russian/French display, safe interpolation, catalog completeness and the prohibition on literal template copy. It runs in npm run test:browser.
|
||||
|
||||
|
||||
Demo workspaces import `src/demo-holidays-2026.csv` through the regular holiday service: 15 Cyprus dates supplied by the project owner and 18 Japanese holidays/days off from https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html (2026 table, verified 2026-09-24). This is a fixed 2026 example, not a recurring calendar or an annual-date extrapolation.
|
||||
|
||||
Demo workspaces: Acme Engineering (project priorities + weekly cover), Customer Support (daily queues), Regional Platform (dictionary regions → nested enabled services, region-specific handovers/rotation lengths, shared commander), Commerce Operations (product → tier dictionary lookup, variable escalation delays/repeats/working windows, shared weekly fallback). Alice owns all four; Bob is a member in Acme/Regional, Carol in Support/Commerce. Regional times use the workspace scheduling zone, not a per-region zone. Disabled Sandbox and Legacy export illustrate filtering; enabling the latter requires adding its missing retired tier or choosing an existing tier. All examples use normal generation preview/apply and demo cleanup; provider remains local preview.
|
||||
|
||||
The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`.
|
||||
|
||||
## Single workspace schema
|
||||
|
||||
Migration 8 replaces the former tenant/team tables with workspaces and workspace memberships. It requires an empty legacy schema; it refuses to erase a populated non-demo database. Demo startup resets only provenance-verified demo data before migrations and recreates four independent workspaces. No history conversion or runtime compatibility layer is provided. All settings and generation state share the workspace revision. API paths are `/api/workspaces/{workspace}`; navigation uses `?workspace=...`. PostgreSQL integration tests use the dedicated `meguri_workspace_test` database.
|
||||
|
||||
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.
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# Product contract
|
||||
|
||||
Meguri is a multi-tenant on-call configuration service. Workspace is the single tenant boundary: there are no nested teams. A user may belong to several independent workspaces. Workspace grants separately control reading, schedule editing, replacement requests, forced application, membership administration, provider configuration, and audit access. Workspace owners administer their workspace and its drift policy.
|
||||
|
||||
## Scheduling
|
||||
|
||||
- Ordered rotations with a local anchor date, local handover time, and a duration in calendar days (weekly defaults to seven).
|
||||
- Project primary coverage: the first available person in an ordered responsibility list (not a rotation), then the current effective fallback rotation if everyone is off. Absence and country holidays skip each person independently. The single UI/domain type is Owners by priority. Legacy two-person schedules normalize to an ordered pair when read, including history; IDs, handovers and fallback references are preserved. Legacy generation blocks normalize to priority blocks with an ordered binding list, retaining dynamic primary/secondary expressions.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
External edits are conflicts by default, not silent imports and not silently overwritten. A workspace may opt into overwrite of Meguri-owned resources. Unmanaged external resources remain outside Meguri ownership.
|
||||
|
||||
## Variables and generation
|
||||
|
||||
Workspace variables have arbitrary user-defined names and nested typed values: text, person/email, number, boolean, date, time, duration, list, and dictionary/record. Lists and dictionary entries have stable internal IDs separate from their values and keys. A visual generation rule contains ordered iterations, optional conditions, and schedule/escalation/routing output blocks. Fields bind to constants, variables, loop values/keys, dictionary lookups, fields/list positions, or other outputs; names can add fixed prefixes/suffixes. No executable user code is accepted.
|
||||
|
||||
Editing is a draft with debounced server validation and a complete create/update/remove preview. A missing dictionary key, empty participants, duplicate IDs/keys, bad types, invalid references, unavailable permissions, or excessive expansion prevents activation and identifies the offending rule/field/item. Variable changes recompile the consuming rules in the same workspace. Activation requires schedule-edit permission and compares the single workspace revision atomically; no other workspace is read or changed.
|
||||
|
||||
Generated resources are ordinary schedules and provider configuration with recorded source ownership. IDs derive from workspace, rule, stable iteration entries, and output block, preserving history/overrides across rename and reorder. Edit their source through the generator, not the individual generated resource. Current/future approved replacements prevent destructive generated schedule removal. Pending requests referencing removed schedules are cancelled explicitly in the previewed change. Generated routing stays in a contiguous ordered block at a chosen position among manual rules. Saved workspace history includes exact source variable snapshots and rule definitions.
|
||||
|
||||
Generation is bounded: at most 100 variables, 10,000 data nodes, four iterations per rule, 50 rules, 200 schedules, 200 policies, and 500 routing rules per workspace. Expansion fails atomically on excess; provider limits still apply. Generated contacts use exact existing workspace-email identities or deterministic email-contact IDs and never gain login/workspace access.
|
||||
|
||||
## User experience
|
||||
|
||||
Localized UI (English, Russian, French): calendar and coverage, schedule configuration, request inbox, account settings, workspace permissions, provider publication status, and audit trail. Saving/approval succeeds independently of external availability. Publication failures and drift must remain visible.
|
||||
|
||||
Public holidays are collapsed by country by default, with the total count beside the country name. Human-readable dates use DD-MM-YYYY, including editable date fields; timestamps additionally show the user's local time and UTC offset. API/storage dates remain ISO. CSV imports accept ISO or DD-MM-YYYY dates.
|
||||
|
||||
Schedule removal shows a server-generated dependency summary before applying. Users can remove its escalation steps when every affected policy retains at least one step, or redirect schedule references to another existing schedule. Coverage fallbacks require redirection. Future applied replacements block removal; pending requests touching the removed schedule are cancelled as whole requests. History is retained. Removal, dependency changes, cancellation, audit, and publication are committed together against the reviewed revision.
|
||||
|
||||
Routing rules are evaluated from top to bottom. Drag-and-drop and keyboard/touch move buttons save the ordered list of rule IDs; the server requires an exact permutation and assigns consecutive priorities. The rule editor selects an insertion position instead of exposing conflicting numeric priorities. Reordering requires schedule-edit permission and rejects stale versions.
|
||||
|
||||
Local password authentication is the initial identity adapter. User IDs, memberships, sessions, and audit identities are provider-independent; later OIDC identities bind `(issuer, subject)` to an existing local user ID. Demo mode must be explicitly enabled and uses only the local preview provider.
|
||||
|
||||
Daily use starts on the calendar with incoming requests and one-click Accept/Decline, outgoing pending requests, and the selected schedule's current/next personal shift. Schedules are searchable. Clicking a duty day prefills owner, schedule and exact local boundaries. Own days offer searchable teammates; other people's days preselect the signed-in employee. An exact incoming request can be accepted directly from that day. Split days explicitly choose a segment; past days are read-only. Start/end dates and clock times are always visible and are validated on the server before send. Reciprocal swaps are optional and have their own explicit start/end boundaries. Force permission adds a separate Apply without approval action.
|
||||
|
||||
Infrequent administration lives under Setup. Dialogs name their action. Project-to-people dictionaries use ordered person chips, name/email search and spreadsheet paste; simple lists use direct rows. The complete typed structure editor remains available for arbitrary nested data. Both editors share server validation and activation preview.
|
||||
|
||||
## Disposable demonstration mode
|
||||
|
||||
Before migrations or deletion, startup checks a separate database provenance marker recording the last runtime mode. Only positive previous-demo provenance permits resetting application data. An unmarked database, a normal-mode database, or an unreadable marker is protected. Demo startup switches to a separately named database (or an explicitly supplied empty demo database), with a permanent prominent header and server-log warning requiring administrator attention. An empty database can be claimed only after proving it has no user relations. If isolation cannot be established, startup fails without changing protected data. No DROP DATABASE, schema deletion, or TRUNCATE CASCADE is used. Migration history and runtime metadata survive resets; another active database client blocks reset.
|
||||
|
||||
During a run, a minute-based maintenance task soft-deletes visitor workspaces and their configuration graphs after 24 hours from creation, and visitor accounts after 48 hours. They disappear from access/listing immediately; dependent data is physically removed after a ten-minute grace period, preserving referential integrity. Nested schedules, contacts, variables, rules, absences, and memberships expire with their owning workspace rather than leaving broken references. Requests expire independently after two hours from creation; audit/history have six-hour retention with a historical boundary snapshot retained. Editing never extends creation-based lifetime. Times are configurable. Demo accounts remain usable; seeded workspaces refresh after expiry. The permanent header describes retention and restart reset. Normal mode never runs demo retention.
|
||||
|
||||
The minimal examples are Acme Engineering / Project matrix (six services with two or three ordered owners during working hours and a shared weekly Monday 11:00 Time off rotation) and Customer Support / Daily support (daily 09:00 shifts and a 15-minute escalation). Alice leads both, Bob, Dave and Erin demonstrate Engineering swaps; Carol participates in Engineering and demonstrates Support approvals. The Commerce example includes an email-only contact without login access.
|
||||
|
||||
## Anonymized reference behavior
|
||||
|
||||
Alice, Bob, and Carol rotate weekly from Monday at 11:00. Payments primary is Alice and secondary is Bob. If Alice is absent on a duty day, Bob covers Payments; if both are absent, that day's effective general on-call employee covers it. Public holidays follow each person's country; when both are off, coverage falls back to general on-call. A confirmed replacement of Carol by Alice on the general rotation must also change any project coverage that falls back to that rotation.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
A user's display timezone defaults to the browser's IANA zone and can be overridden in account settings. It never enters scheduling state. Calendar intervals and activity timestamps show local time and UTC offset; configuration dates and time inputs explicitly use the workspace/country calendar. Profile country changes update that user's scheduling-country snapshots and publication requests atomically; display-timezone changes do not enqueue publication.
|
||||
|
||||
Holidays have a date, name, and ISO 3166-1 alpha-2 country. Coverage skips a primary or secondary only when that person's country has a holiday (or they are absent); then it falls back to general on-call. Ordinary rotations remain continuous 24/7, including holidays. Legacy global holidays remain explicitly marked “All countries” and keep their original fallback behavior. A legacy person's unset country defaults to Cyprus.
|
||||
|
||||
CSV imports use `country,date,name` headers, ISO or DD-MM-YYYY dates, and country codes such as AM and CY. Pasting previews the missing holidays against current server state without saving. Import is add-only and revision-checked: identical duplicates are skipped, different names for an existing country/date are reported and preserved, and invalid rows block the whole import. A stale preview must be refreshed.
|
||||
|
||||
JSM schedule participants use Atlassian account IDs. A manual account-ID mapping takes precedence; otherwise the provider resolves a person's email against the Jira user directory before any remote writes. Only a unique exact active match is accepted. Hidden, missing, or ambiguous addresses block publication with an actionable error. Meguri does not provision Atlassian accounts or configure their phone numbers/contact methods.
|
||||
|
||||
## Localization and setup navigation
|
||||
|
||||
English, Russian and French are bundled in a single runtime catalog. The account language preference overrides the browser; an unset preference uses the first supported browser language, then English. Language affects presentation only, never schedule calculation or publication. User-entered values and third-party diagnostics remain opaque. All built-in text, including accessible labels and validation messages, must come from catalog entries.
|
||||
|
||||
Menu entries are real links carrying view and workspace. Reload, new tabs and Back/Forward restore the accessible workspace and section. Unknown sections fall back to Calendar; unauthorized workspace identifiers never grant access. Legacy schedules/policies/generation links open Schedule setup.
|
||||
|
||||
Schedule setup combines variables, generation rules, schedules, escalations and routing. Manual resources come first; generated resources follow a separator with a distinct style and source badge. Generated resources cannot be edited directly: their source button opens the owning rule. Routing badges retain actual evaluation priority even though resources are grouped by origin. Generation edits use ordinary inline values and direct variable/dictionary selections, with examples; custom loops, conditions and bindings remain available. Examples are illustrative; debounced server preview is authoritative before activation.
|
||||
|
||||
Uncovered future assignments show the employee, schedule and exact interval in the user's timezone with an action to arrange cover. Provider failure diagnostics include the first affected assignment. The UI lists at most 50 intervals and shows their total.
|
||||
|
||||
Cover requests expose start/end dates and clock times directly. Ask for cover defaults to one calendar day at the workspace working interval start, in its IANA zone. Clicking an existing duty segment preserves that exact segment; edits use explicit endpoints. Display timezone never changes input interpretation. Reciprocal legs have their own dates/times. The server resolves wall-clock endpoints and rejects ambiguous/nonexistent local times. Generation binding/person editors replace the long form with a focused subview and return to the originating field. Validation state and the first error stay visible in the sticky footer; all errors expand there.
|
||||
|
||||
|
||||
Generated names using an iteration value/key display the source token and editable suffix together in a single bordered row; an existing prefix is retained and displayed in that row too. Binding semantics and generated identities remain unchanged.
|
||||
|
||||
Generation value editors show a reactive, read-only, formatted JSON example in a compact scrollable monospace textarea. Iterated values use the first available row with its context shown; missing values clear the preview. Invalid field types still display the selected raw data while server validation remains authoritative. Generated references show resource metadata explicitly labeled as an example, not the resolved identifier. Preview output is bounded to 20,000 characters.
|
||||
|
||||
Resource source pickers distinguish existing schedules/policies from generated schedules/escalations in their labels. A priority-owner or rotation schedule is also a valid fallback; fallback cycles remain prohibited by server validation.
|
||||
|
||||
The generic binding source is “From a generated resource”; contextual choices say “Generated schedule” or “Generated escalation”, contrasted with existing resources. A short hint explains resolution during generation and current-item scope.
|
||||
|
||||
Generation pickers offer direct references only to manual schedules and escalation policies, in both simple and advanced editors. Generated instances are reached through generator outputs, never duplicated as existing choices; new escalation-step defaults follow the same rule. Legacy saved direct references are retained, with an editor warning to select a generator source or manual resource rather than silently changing their scope.
|
||||
|
||||
Variable and iteration bindings share a monospace template notation throughout generation forms: `{ var.responsible[Project.value] }`, `{ Project.key }`, and one-based nested paths such as `{ var.people[1] }`. Names requiring escaping use quoted bracket notation. Prefixes/suffixes remain outside braces. This is presentation only, not an editable expression language; binding data and server interpretation are unchanged.
|
||||
|
||||
Template notation remains visible in all form controls, but inputs, selects and buttons keep their standard styling. The monospace token background is reserved for non-interactive text blocks and template previews.
|
||||
|
||||
Returning from generation value/person editors restores the parent modal scroll after the main form is visible and laid out; focus restoration must not move the viewport.
|
||||
|
||||
Audit details resolve known person, schedule, escalation, routing, variable and rule IDs into name/id pairs. Names use workspace-scoped historical snapshots up to the event time, retaining last-known labels for removed resources; current directory/state fills gaps when history is unavailable. Unknown IDs and free-text comments remain unchanged. Display timestamps use the viewer timezone, with original immutable JSON and UTC timestamps available in a separate disclosure.
|
||||
|
||||
Variables expose one keyed collection type: Dictionary / object. Legacy `record` nodes normalize recursively to `dictionary` during deserialization, preserving node IDs, keys, values, nesting and generation references, including stored history. No separate record type appears in the editor.
|
||||
|
||||
Generated-resource references show both their resource type and name/template directly on binding buttons, including escalation recipients, without opening the editor.
|
||||
|
||||
Schedule setup begins with small collapsed absence/holiday widgets, followed by variables and generation rules and a compact expandable review/apply bar. There is no anchor menu or starter-example action; demo seeding supplies examples. One resource inventory groups manual resources first and generated resources after a divider. Schedules, escalation policies and routing rules use the same collapsed rows, with type/name in the summary, generated star and distinct color, and details/actions inside. Routing retains explicit evaluation positions and ordering controls. Three creation controls above the inventory create schedules (with a type chooser), policies and routes. Generated resources remain read-only with a source-rule action.
|
||||
|
||||
An approved replacement can itself be replaced from the calendar, using the current effective owner. Pending requests leave the existing cover active; approval or authorized force replaces only the selected interval. Original requests and audit history remain intact. Ownership and substitute availability are rechecked on application.
|
||||
|
||||
Rule summaries show iteration sources in template notation only when iterations exist, and count/type summaries for outputs. Collapsed resource rows include concise rotation/owner, escalation-step or route-condition details. Every generated resource has the same top-right source-rule link inside its expanded body, labeled with the rule name; viewers without edit permission see the attribution without an enabled edit action.
|
||||
|
||||
Schedule types are Rotation and First available. A permanent assignee uses a one-participant rotation. The generation block picker groups schedules, escalation policies and routing rules separately. The former fixed-assignee type is removed without compatibility or history conversion.
|
||||
|
||||
Data previews expand known person/resource references into compact named objects (with original identity retained), including nested lists and dictionaries. This is a readable display of references, not a conversion of stored values to objects.
|
||||
|
||||
Validation is a permanent status/details control in edit forms and generation setup. Live previews show checking/valid/error states; forms without a preview explicitly say not checked yet and validate on save. Diagnostic details are collapsed, with scoped source-rule/variable edit actions that retain the current local draft. Secondary generation errors are marked as possible consequences. Absence impact rows offer Choose a substitute: it adds an exact, schedule-specific interval and opens the person picker; other schedules and automatic project fallbacks remain unchanged.
|
||||
|
||||
Absence ranges use one editor for manual creation and impact-row shortcuts: schedule scope (one or all affected), start/end dates and times in the workspace zone, and substitute. Endpoints are half-open. Shortcut ranges remain fully editable. All-schedule ranges intersect the absence window of each schedule; explicit single-schedule ranges must fit its absence window. The absence itself retains inclusive duty-day dates.
|
||||
|
||||
Coverage diagnostics distinguish pending approval from missing substitutes. Pending request boundaries split uncovered intervals so partial requests never disguise remaining gaps. The UI shows substitutes and outstanding approvers, links to requests, and keeps publication blocked until approval. Browser regression covers partial pending coverage, remaining gaps, approver filtering, and responsive spacing.
|
||||
|
||||
Schedule setup places narrower variable and generation-rule panels beside a stacked availability sidebar. Absences and holidays remain open; their lists scroll internally. Country holiday groups retain accordions inside the bounded list, with add/import actions opening dialogs. Responsive layout moves the sidebar below the editors. Browser checks cover column position, bounded holiday expansion, visible actions and mobile width.
|
||||
|
||||
Generated routing placement is shown only when manual routes and an enabled route-generating rule coexist. Options name the manual routes in evaluation order; the stored insertion index is unchanged. Browser checks verify hidden controls for generated-only setups and named options/order for mixed routing.
|
||||
|
||||
The workspace-variable list scrolls within a 360px maximum height; its heading and add action remain outside the scroll area. The list supports keyboard scrolling and retains natural height when short.
|
||||
|
||||
Generation-rule summaries explicitly prefix rotation and priority output types with a localized Schedule label, distinguishing schedules from escalation policies and routing rules.
|
||||
|
||||
The generation-rule list uses the same bounded, keyboard-scrollable area as variables. Its add action and routing placement control remain outside the scrolling list.
|
||||
|
||||
Resource inventory is a distinct UI component (`resource-inventory.html`). All expanded resource kinds share `resource-details.html`: labeled icon rows, consistent spacing, sequential connectors, and a common source-rule control. Presentation rows and monochrome SVG paths come from pure UI helpers; schedules, routing order and provider state are unchanged. Check rotation, fixed owner, priority fallback, escalation timing/repeats and routing conditions on desktop/mobile.
|
||||
|
||||
Expanded resources use compact type-specific horizontal widgets instead of parameter tables: rotation metadata plus ordered people, priority people plus fallback, escalation steps plus repeat footer, routing conditions plus destination. Shared icon, chip and connector styles keep them consistent. Chains wrap within narrow screens; source links and edit actions retain their common placement.
|
||||
|
||||
The generation-rule editor is a generic technical constructor: visible iteration aliases and collection bindings, an explicit optional condition, and typed output blocks. New rules start without implicit project/responsible-variable detection or generated blocks. Variable names carry no semantic meaning. Compact controls retain the existing typed binding editor, value preview, live server diagnostics and draft semantics. Check no-iteration rules, nested collection bindings, conditions and multiple output types on desktop/mobile.
|
||||
|
||||
Binding selection uses one anchored, viewport-clamped popover beside the originating control; the constructor remains visible and keeps its scroll position. All source kinds, nested lookups/lists, paths, text affixes and live value previews remain supported. Literal escalation delays are directly editable. Done/Escape closes the popover and returns focus. Server and change previews are collapsed summaries with counts and bounded change lists. Browser checks cover context retention, source editing, nested bindings, keyboard dismissal and mobile bounds.
|
||||
|
||||
Output block names use one standard input and the shared ellipsis binding picker. Plain literal names are editable inline; bound names show their complete expression including affixes, with binding edits confined to the popup. Separate suffix/prefix fields are no longer shown in the constructor.
|
||||
|
||||
The rule editor materializes absent optional escalation repeat count/wait fields as literal zero in its draft, matching server defaults. Explicit bindings and invalid values remain unchanged for validation; opening/cancelling does not mutate saved rules.
|
||||
|
||||
Server-preview changes use labeled green/create, blue/update, red/remove and gray/unchanged badges. The expanded list scrolls at 320px (280px on mobile), with a compact footer toggle.
|
||||
|
||||
The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`.
|
||||
|
||||
Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration.
|
||||
|
||||
Numeric, date and time controls use compact value-sized widths throughout forms. Generator literal fields keep their binding button adjacent; dynamic references retain space for descriptive names. Date controls include room for the DD-MM-YYYY value and calendar button.
|
||||
|
||||
Generation field labels occupy their own grid row; each value and its picker share the next row. Boolean inputs stay checkbox-sized beside their labels, including working-hours bindings.
|
||||
|
||||
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.
|
||||
|
||||
Variable editing uses a bounded, progressively disclosed tree: only the root starts expanded, object rows show names and collection summaries, and nested values expand in place. Values mode hides type/key controls; Structure mode exposes them without losing edits. Duplicate assigns fresh IDs recursively; ordering and live server validation remain intact. Check nested editing, duplication, mode switching and narrow viewports.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Proposal: workspace data and schedule templates
|
||||
|
||||
This is a design-rationale document. Current behavior is defined in product.md and architecture.md: workspace is the sole scope, with no nested teams or cross-workspace bindings. Historical sketches below are not an implementation contract.
|
||||
|
||||
Status: the first implementation is available. See `product.md` for the maintained contract. This document preserves the broader design rationale; reusable parameterized template libraries, derived variables, AND/OR predicate groups, and separate row-level grants remain future work.
|
||||
|
||||
Implemented: arbitrary typed trees, stable list/dictionary entry IDs, up to four nested iterations, one optional predicate per rule, constant/variable/item/key/lookup/output bindings, text prefixes/suffixes, ordered priority coverage and other schedule outputs, policies, routing, debounced server validation, impact preview, and atomic activation of variables and generated configuration within one workspace. Edit/activation uses EditSchedules permission in that workspace. Singleton outputs may be shared between rules; iterated outputs stay scoped to their row. Limits are 200 schedules, 200 policies, and 500 routes; a 30-project matrix is covered by regression tests.
|
||||
|
||||
## Intended workflow
|
||||
|
||||
A workspace member with the appropriate grants creates their own named variables and builds reusable rules from visual blocks. Neither variable names nor record fields are fixed to projects/primary/secondary. For example, one person can define a shared list of on-call emails, a team-lead value, and a dictionary mapping each project to a different ordered list of responsible people. Another team can model regions, services, or shifts with different fields. The product supplies types and operations, not a mandatory project schema.
|
||||
|
||||
In the UI there are three views: **Variables**, **Rules**, and **Preview**. Lists are editable rows; dictionaries and records are tables/forms with user-defined columns. Rules contain “For each”, “If”, and “Create …” blocks. Each field offers “Fixed value” or “Use variable”; the picker shows compatible variables and fields of enclosing loop items. A breadcrumb such as “Current project → Responsible people” replaces an expression to type. List selection can choose the whole list, first person, or person at a specified position; dictionary selection can look up a key from another variable/current item. Missing positions/keys require an explicit fallback or produce a row-specific validation error. A rule can be saved as a reusable template, but an administrator does not have to prebuild every template for users.
|
||||
|
||||
Changing an input automatically regenerates the draft preview, showing resource counts and errors beside the originating row/block. Applying activates one consistent revision and queues publication. Automatic draft calculation must not publish an unreviewed destructive edit. No Rust, JavaScript, Terraform, or textual expression language is required.
|
||||
|
||||
### Separate project list and responsibility dictionary
|
||||
|
||||
The user's concrete input model is two independent variables, represented here as data examples, not a configuration language the UI requires:
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": ["A", "B", "C"],
|
||||
"responsible": {
|
||||
"A": ["alice@example.com", "bob@example.com"],
|
||||
"B": ["carol@example.com", "alice@example.com"],
|
||||
"C": ["bob@example.com", "carol@example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The visual rule is **For each item in projects → Create rotation**. Its name binds to the current project; its participants bind to **Variable responsible → Entry with key: current project → Entire list**. This is semantically `responsible[current_project]`, built by selectors rather than typed syntax. Anchor, shift length, and handover can be fixed values or additional bindings. One schedule block creates A, B, and C rotations with different participant sequences; list order is the rotation order, not an escalation order. Extra escalation/routing blocks are optional.
|
||||
|
||||
This lookup joins collections by their values, never their row positions. A reordered responsibility dictionary has no effect. Duplicate project values, a missing dictionary key, an empty participant list, repeated participants, or an invalid email fail preview with the project and binding identified; there is no implicit fallback to another project. Unused responsibility entries may be shown as unused but create no schedules. A deliberate “Use default when key is absent” binding is possible; it must be visible in the rule. The entire activation fails on invalid rows rather than publishing a partial matrix.
|
||||
|
||||
Generated schedule identity follows the stable entry ID in the iterated project list and the create block ID. Editing/reordering the participant list changes future base ownership without recreating that schedule; it never resets the stored rotation anchor or drops applied replacements. Renaming a project keeps its hidden entry ID, but the user must update the dictionary lookup key as part of the same valid draft. Tests must distinguish reordering projects, reordering dictionary entries, and reordering a project's participants: only the last changes that rotation's ownership sequence.
|
||||
|
||||
Another supported layout is a single table of records. It is optional: users never have to merge the separate variables above into this format.
|
||||
|
||||
| Stable key | Project name | Primary | Secondary | Handover | Target team |
|
||||
|---|---|---|---|---|---|
|
||||
| first | First | Alice | Bob | 11:00 | Platform |
|
||||
| second | Second | Carol | Alice | 09:00 | Platform |
|
||||
|
||||
The column types refer to real people, teams, times, and other resources. A dropdown selects references; a free-form name is not an identity. A useful starter type is “Person”: an existing workspace user or an email contact without login access. Plain strings and lists of strings are still allowed. When used as recipients, email strings need an explicit validated conversion to people; non-email strings are not guessed to be usernames. The team can edit allowed cells and add/remove rows without editing the rule structure.
|
||||
|
||||
For the minimal dictionary example, a single block can iterate `projects`, where each entry is a project key and its ordered list of people. Inside it, “Create rotation” takes its title from the current key and its participants from the current value; a second “Create rotation” takes the shared team lead as its only participant; “Create escalation” refers to these two outputs; “Create routing rule” matches the current project and points at that escalation. Thirty dictionary entries then produce thirty connected groups from the same four create blocks. If each project needs separate primary/secondary participant lists, change the entry to a record with those fields and bind two rotation blocks to them. Both are data models the user can choose.
|
||||
|
||||
Shared resources can be created outside a loop and referenced inside it, for example one single-participant rotation for the team lead instead of thirty identical rotations. Multiple rules consuming the same dictionary keep independent instance/output identities and explicit precedence; a lookup alone does not create a resource.
|
||||
|
||||
“Create alert” is deliberately not an action here: the generated routing/escalation configuration tells the external provider how to process alerts. Meguri continues to own configuration, not incident delivery.
|
||||
|
||||
## Four boundaries
|
||||
|
||||
1. **Workspace data.** Versioned, typed values: string, boolean, number, date, local time, duration, working interval, person/team/schedule reference, list, record, and dictionary. Time-of-day, duration, and timestamp are distinct types. A local time needs an explicit workspace/interval IANA zone before it can identify a schedule boundary; user display timezone is never an input. “Dictionary” means a declared schema or `map<string, T>`, not arbitrary unvalidated JSON. Secrets remain in deployment secret storage.
|
||||
2. **Rules, templates, and bindings.** Persist a versioned, typed block tree with stable block IDs: `ForEach(collection, item)`, `If(predicate)`, and `Create(kind, fields)`. Value nodes are constants, variable references, loop-item/record fields, dictionary lookup, explicit default, name formatting, or references to a create block's output. A picker creates these nodes; users do not write the internal representation. Initial predicates can be equality, contains, and empty/not-empty with AND/OR. Each create block is a stable output slot (schedule, escalation, route). References use IDs and loop keys rather than interpolated display names. Reusable templates parameterize these blocks; an instance binds values and allowed target teams. Renaming a variable does not break references.
|
||||
3. **Pure expansion and validation.** Resolve source variables and any explicitly derived values as an acyclic dependency graph, expand bounded loops into ordinary domain objects, resolve output references, then run existing schedule/dependency/provider-capability validators. Nested collection iterations may use outer fields; a Cartesian product is an explicit operation with a visible output count. Scope checking prevents a reference to an item outside its loop; referencing a conditional output requires compatible guards or an explicit fallback. No arbitrary code, eval, recursive templates, network calls, clock-dependent expressions, or unbounded loops. Reject cycles, invalid types, duplicate keys, missing identities, illegal grants, excessive nesting, and output limits before activation. Freedom to compose variables and blocks does not require a general-purpose programming language.
|
||||
4. **Activation and publication.** Materialize the candidate team documents and a diff of create/update/remove operations. Show affected teams and dependent policies, and preserve an expansion manifest. After review, compare all input revisions and atomically persist the source revision, affected team snapshots, history, audit, and outbox jobs. External providers receive the same canonical publication contract as today. External writes can still partially fail; report their status separately from activation.
|
||||
|
||||
## Source of truth and stable identity
|
||||
|
||||
The source is the active combination of variable revisions, template versions, instance bindings, manual resources, and approved replacements. Generated team documents are immutable derived snapshots, never an independently editable second source of truth. Keep the exact inputs/manifest for reproducing a past expansion without loading today's variables.
|
||||
|
||||
Generated identity is based on `(instance UUID, stable loop-entry ID path, output block ID)`, for example via namespaced UUIDs. Renaming a project, variable, dictionary key, or reordering the table must not recreate schedules, lose provider mappings, reset a rotation anchor, or orphan replacement history. Every output records its source instance, entry path, block, and version. Lists and dictionaries have hidden persistent entry IDs separate from labels, keys, and ordinal positions. Dictionary keys remain unique for lookup; changing a referenced lookup key requires validation, while generated identity stays stable. Copying a block/entry deliberately creates a new ID. Rotation anchors are explicit stored inputs or creation-time values preserved in the manifest, never recomputed from today's date.
|
||||
|
||||
Generated objects show “Managed by template … / project …” and link to their source row. Direct edits redirect to the matrix or template. Permit only explicitly declared per-row parameters. If later needed, “Detach” copies the effective configuration into manual ownership with a recorded identity/ownership transition; it is not a silent local patch that the next expansion overwrites.
|
||||
|
||||
## Deletions, replacements, and rule order
|
||||
|
||||
Removing a row previews every removed output and its inbound references. References from surviving/manual resources require an explicit replacement or edit; never silently remove unrelated policies. Current/future approved replacements block destructive changes to their schedule. Pending swaps are cancelled only as an explicit part of the reviewed activation. Historical snapshots remain intact. Changing participants or anchors warns about changed future owners and preserves applied overrides using existing engine rules.
|
||||
|
||||
Order is semantic because routing uses first match. A template instance owns an ordered block of generated rules, with declared order within each row and a stable order of rows. Drag the block among manual rules; edit row/template order at its source. Do not derive priority from dictionary iteration, UUID ordering, or names. Cross-instance precedence is an explicit workspace/team setting.
|
||||
|
||||
## Permissions and multi-team changes
|
||||
|
||||
Separate rights to create/edit variables and their schema, edit data rows, build rules/templates, and activate changes. These grants can be delegated to team members, not only workspace administrators. Shared templates can restrict which columns/rows and target teams editors manage. A row cannot name an arbitrary tenant or team. Scheduling a person still does not grant them access. Workspace-wide data is intentionally readable to its workspace audience; never place team-private data in a globally readable variable.
|
||||
|
||||
Editing shared inputs produces a draft. Activation requires authority over every affected team, or approvals from those teams before one activation; do not partly activate an input revision. The diff lists the complete impact even when one row looks harmless. Source and team revision checks reject stale approvals. Keep activation local and bounded; no database write lock spans provider HTTP calls.
|
||||
|
||||
## Small first version
|
||||
|
||||
Start with user-defined primitive variables, people/email lists, records and dictionaries; constant/variable/current-item bindings; one `for each` collection; schedule + escalation + route create blocks; stable identities; preview/diff; and atomic activation for one target team. A starter “Project on-call” template is a convenience, not a special hard-coded model. This first version already handles different responsible lists per project plus shared team-lead/default values. Add conditional/nested iterations and reusable parameterized templates next, then explicit Cartesian products and multi-team activation. Reuse the existing engine, provider contract, and outbox. Do not start by prescribing a fixed table or introducing a programming language.
|
||||
|
||||
Before rollout, size domain quotas for this use case: the current limit is 50 schedules per team, so 30 projects with two schedules each already exceeds it. Establish tested resource/expansion budgets and provider-specific limits, show counts before activation, and reject excess atomically. Raising a cap alone is insufficient without measuring engine, history size, and publication cost.
|
||||
|
||||
Required tests: deterministic expansion; stable identity across rename/reorder; matrix limits and key collisions; type/reference/cycle validation; scope enforcement; deleting rows with inbound references or approved replacements; stale previews and concurrent activation; history reproducibility; no changes to unrelated manual resources; routing precedence; retrying publication of the same expansion.
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
# Test architecture
|
||||
|
||||
The daily cover workflow has a PostgreSQL regression for read-only preview, volunteering, owner approval, denied self-approval/third-party transfers, force permissions, empty comments and current-interval clipping. `tests/browser-cover.cjs` creates its own workspace and checks calendar → offer → owner acceptance, own-day request → exact-request acceptance, the privileged third action without a comment, early range errors, comment visibility, display timezone and mobile layouts. Browser navigation opens Setup explicitly; daily flows do not need it.
|
||||
|
||||
Scheduling errors are product failures. A change to ownership, time boundaries, approvals, access, or synchronization must include a regression at the boundary that could fail. Test observable outcomes rather than repeating an implementation formula.
|
||||
|
||||
| Layer | Location | Dependencies | Contract |
|
||||
|---|---|---|---|
|
||||
| Pure engine | `src/engine.rs` tests | None | Owners and intervals, DST, fallback, pending/applied overrides, validation |
|
||||
| Configuration graph | `src/configuration.rs` tests | None | Dependency removal/redirect, last-step and fallback protection, pending swap cancellation, approved-history retention, exact route permutation |
|
||||
| CSV import | `src/holidays.rs` tests | None | Country/date keys, quoting, BOM, duplicates, conflicts, invalid rows |
|
||||
| Provider compilation | `src/providers/` tests | None | Unsupported capabilities fail; normalization detects changed ownership |
|
||||
| Application/persistence | `tests/integration.rs`, `tests/support/` | Disposable PostgreSQL | Real Cot migrations, scoped authorization, concurrent edits, atomic audit/history/outbox, approval revalidation |
|
||||
| HTTP provider contract | Integration suite + stateful local fake | PostgreSQL + loopback | Wire methods/payloads, partial 429 failure, checkpoint recovery, read-back, drift, external overrides, owned-only deletion |
|
||||
| Browser/API | `tests/browser.cjs`, `tests/browser-calendars.cjs`, `tests/browser-configuration.cjs` | Seeded local app + Chromium | Forms, calendar, approval, workspace isolation, CSRF/origin, people/imports/timezones, removal dependencies, native routing drag/drop and position editing, desktop/mobile |
|
||||
| Live acceptance | Dedicated JSM test team | Test credentials | Actual provider timeline, subscription-specific escalations and routing; never ordinary CI |
|
||||
|
||||
## Invariants
|
||||
|
||||
- Every schedule covers the requested range without gaps or overlaps. Tests exercise a year across five zones and five shift lengths.
|
||||
- Spring and autumn weekly shifts can be 167 or 169 hours while handing over at 11:00 local. Exact boundaries belong to the incoming employee. Invalid local handovers fail.
|
||||
- Overrides change only their interval, not the base sequence. Pending requests have no effect. Overlapping approved state is an error.
|
||||
- Coverage uses secondary, then the effective fallback including overrides. Country holidays skip only affected people; legacy global holidays select fallback directly. Cycles fail validation.
|
||||
- Swaps apply both legs or neither. Requesters cannot approve for someone else. Application rechecks availability and ownership. Force requires a grant and is audited; comments are optional.
|
||||
- One of two edits with the same revision wins. A failed constraint leaves document, revision, history, audit, and job unchanged.
|
||||
- Schedule removal rewrites references, cancels affected pending requests in full, and commits one history/audit/outbox change. It cannot erase a current/future approved replacement or leave an empty escalation or broken fallback.
|
||||
- Route ordering requires every current ID exactly once. Missing, repeated, foreign, stale, or unauthorized requests change nothing. Changing priority preserves conditions/recipients and survives reloads.
|
||||
- Publication snapshots cannot read newer revisions. Past absence flags do not block a now-correct future plan.
|
||||
- Competing workers cannot claim the same unexpired lease. Retrying a lost checkpoint does not create duplicate remote resources.
|
||||
- External changes and overrides block writes/deletion by default; unmanaged resources survive reconciliation.
|
||||
|
||||
## Running and extending
|
||||
|
||||
`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.
|
||||
|
||||
Before enabling a provider in production: publish a preview to its dedicated test team, compare the actual timeline, repeat unchanged, interrupt a partial publication, introduce external drift, verify conflict and explicit overwrite, and exercise escalation/routing on the target subscription. Record evidence and limitations.
|
||||
## Additional regression cases
|
||||
|
||||
Cover independent handovers and dependent fallback boundaries, DST rejection, country-specific coverage, scoped directory selection, email contacts without access, import quoting/BOM/duplicates/errors/stale previews, atomic working-interval propagation, and exact JSM email resolution before writes. Browser coverage includes person selection by email, absence selection, reactive CSV preview/import, and readable escalation/routing flows on desktop and mobile.
|
||||
|
||||
## Variables, absence planning, and demo lifecycle
|
||||
|
||||
`src/absence.rs` tests range splitting across independent handovers, automatic project promotion, uncovered diagnostics, preserved owners, and replacement validation. `tests/generation.rs` checks deterministic 30-project expansion, stable IDs on rename/reorder, typed dictionary errors, loop scope, fallback cycles, protected applied replacements, workspace-specific IDs, three-person priority, country holidays, and a Monday rotation crossing autumn DST.
|
||||
|
||||
The PostgreSQL suite checks read-only previews, grouped absence approvals, atomic workspace-variable activation, permission isolation, stale reviews, concurrent activation, country snapshots, and no contact-to-login grants. Demo tests use uniquely named disposable databases with CREATEDB: unmarked/normal protection, safe isolation, application-only reset with unrelated sentinel data preserved, repeated migration/seed, creation-based soft deletion, later physical purge, and reseeding. Never repoint these tests at a production DB.
|
||||
|
||||
The two additional browser suites (`browser-absence.cjs`, `browser-generation.cjs`) exercise actual forms and early server errors, persistent/reloaded state, the permanent demo banner and login guide, generated source editing, country accordions, DD-MM-YYYY, bulk replacements, and desktop/mobile layouts. Inspect screenshots rather than trusting DOM assertions alone.
|
||||
|
||||
Localization/navigation regressions check supported browser fallback, persistent account preference, parameter safety, complete catalogs, no literal template text, direct links, reload, workspace changes and browser history. Generation browser checks retain server errors, atomic activation and stable generated IDs while exercising inline controls and the source-owner link.
|
||||
|
||||
Exact-time cover regressions exercise same-day partial cover, workspace clock defaults despite a different display zone, server ownership checks, malformed clock values, DST folds/gaps and 23/25-hour calendar days. Generator browser tests assert that binding edits replace the main form and live validation remains inside the viewport even at the top of a long form.
|
||||
|
||||
|
||||
Legacy compatibility regressions deserialize two-person schedules and dynamic generation bindings into priority-owner resources while preserving participant order, resource identity, fallback, handover and routing references.
|
||||
|
||||
`tests/browser-binding-preview.cjs` checks reactive formatted binding values beside server type errors, clearing missing selections, first-row lookup context and mobile sizing without saving draft changes.
|
||||
|
||||
`browser-generation-scroll.cjs` checks scroll restoration from an editor near the bottom of a long rule on desktop/mobile and verifies template styling is limited to non-interactive blocks.
|
||||
`audit` unit tests preserve comments and unknown IDs while resolving references. PostgreSQL regression checks historical names after rename, raw UUID preservation and cross-workspace lookup isolation.
|
||||
|
||||
`browser-setup-layout.cjs` checks compact review state, country holiday counts, collapsed widgets, import access and mobile width. Demo holiday imports also run through integration seed/reset checks.
|
||||
|
||||
|
||||
Repeated-cover regressions check approval gating, partial interval splitting, preserved original legs, stale-owner rejection, forced corrections and calendar interaction on an already replaced day.
|
||||
|
||||
The setup inventory browser checks cover collapsed resource rows, the schedule-type creation chooser, top utility widgets, desktop/mobile sizing, and absence of the old anchor/example controls. Configuration browser checks expand manual rows before deletion/reordering and retain drag-and-drop routing coverage.
|
||||
|
||||
Advanced demo regression checks dependent iteration filtering, inherited regional handovers/participants, tier-based escalation settings and working windows, shared tier edits with stable resource IDs, and early validation of a newly enabled entry with a missing tier. Integration seed/reset verifies all four workspaces.
|
||||
|
||||
`browser-advanced-demos.cjs` opens both advanced generation editors, verifies clean server previews, and checks the demo workspace access matrix for Alice, Bob and Carol.
|
||||
|
||||
Setup layout checks also verify iteration-source summaries, omission of zero iterations, collapsed resource descriptions and source-rule navigation for all three resource kinds.
|
||||
|
||||
Diagnostics regression covers bad participant data, primary/secondary errors, scoped source links, draft-preserving navigation, always-visible status and recovery to valid. Absence tests cover exact interval selection, independent schedule ranges, rejection of overlap/out-of-absence bounds, and unchanged bulk range behavior.
|
||||
|
||||
`browser-diagnostics.cjs` exercises invalid participant data, rule/variable links with local draft retention, and recovery to a valid status on mobile. `browser-absence.cjs` additionally selects an uncovered impact row and checks exact schedule/time bounds before the existing bulk workflow. PostgreSQL tests verify exact absence coverage remains pending until approval and leaves surrounding hours uncovered.
|
||||
|
||||
Unified absence editor tests change the start time of a shortcut-created range, verify resulting server boundaries, and use the same date/time controls for manual ranges. Unit coverage includes local range scope, half-open bounds and DST rejection.
|
||||
|
||||
Coverage diagnostics distinguish pending approval from missing substitutes. Pending request boundaries split uncovered intervals so partial requests never disguise remaining gaps. The UI shows substitutes and outstanding approvers, links to requests, and keeps publication blocked until approval. Browser regression covers partial pending coverage, remaining gaps, approver filtering, and responsive spacing.
|
||||
|
||||
Schedule setup places narrower variable and generation-rule panels beside a stacked availability sidebar. Absences and holidays remain open; their lists scroll internally. Country holiday groups retain accordions inside the bounded list, with add/import actions opening dialogs. Responsive layout moves the sidebar below the editors. Browser checks cover column position, bounded holiday expansion, visible actions and mobile width.
|
||||
|
||||
Generated routing placement is shown only when manual routes and an enabled route-generating rule coexist. Options name the manual routes in evaluation order; the stored insertion index is unchanged. Browser checks verify hidden controls for generated-only setups and named options/order for mixed routing.
|
||||
|
||||
Resource inventory is a distinct UI component (`resource-inventory.html`). All expanded resource kinds share `resource-details.html`: labeled icon rows, consistent spacing, sequential connectors, and a common source-rule control. Presentation rows and monochrome SVG paths come from pure UI helpers; schedules, routing order and provider state are unchanged. Check rotation, fixed owner, priority fallback, escalation timing/repeats and routing conditions on desktop/mobile.
|
||||
|
||||
Expanded resources use compact type-specific horizontal widgets instead of parameter tables: rotation metadata plus ordered people, priority people plus fallback, escalation steps plus repeat footer, routing conditions plus destination. Shared icon, chip and connector styles keep them consistent. Chains wrap within narrow screens; source links and edit actions retain their common placement.
|
||||
|
||||
The generation-rule editor is a generic technical constructor: visible iteration aliases and collection bindings, an explicit optional condition, and typed output blocks. New rules start without implicit project/responsible-variable detection or generated blocks. Variable names carry no semantic meaning. Compact controls retain the existing typed binding editor, value preview, live server diagnostics and draft semantics. Check no-iteration rules, nested collection bindings, conditions and multiple output types on desktop/mobile.
|
||||
|
||||
Binding selection uses one anchored, viewport-clamped popover beside the originating control; the constructor remains visible and keeps its scroll position. All source kinds, nested lookups/lists, paths, text affixes and live value previews remain supported. Literal escalation delays are directly editable. Done/Escape closes the popover and returns focus. Server and change previews are collapsed summaries with counts and bounded change lists. Browser checks cover context retention, source editing, nested bindings, keyboard dismissal and mobile bounds.
|
||||
|
||||
`browser-repeat-defaults.cjs` verifies absent escalation defaults render as editable numbers, edited values survive reopening the draft, and explicit dictionary lookups remain intact.
|
||||
|
||||
The main Acme Engineering demo contains six services (Payments, Search, Billing, Identity, Data platform, Developer portal), each with two or three ordered owners drawn from five login members: alice, bob, carol, dave, erin. All five share the weekly Monday 11:00 Time off rotation. Dave and Erin only access Acme; Carol also retains Support/Commerce access. Other demo workspaces remain separate. Login password remains `meguri-demo-only`.
|
||||
|
||||
Generation rule results use the actual authorized server expansion, including schedules, escalation policies and routes, with resolved names and compact settings. The panel is always present with explicit checking, invalid, disabled and empty states; it respects iteration filters and does not simulate generation in the browser. Preview data contains only scheduling resources and scoped people, never integration configuration.
|
||||
|
||||
Permanent assignees are tested as single-participant rotations, including absence, replacement approval and publication blocking. The removed fixed-assignee schedule and generation types are rejected rather than normalized.
|
||||
|
||||
Workspace isolation regressions exercise independent memberships and variables, a single revision for configuration and working-time changes, read-only generation preview, stale review rejection, atomic activation/history/outbox, and owner settings permissions.
|
||||
|
||||
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.
|
||||
|
||||
Variable editing uses a bounded, progressively disclosed tree: only the root starts expanded, object rows show names and collection summaries, and nested values expand in place. Values mode hides type/key controls; Structure mode exposes them without losing edits. Duplicate assigns fresh IDs recursively; ordering and live server validation remain intact. Check nested editing, duplication, mode switching and narrow viewports.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
Generated
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "meguri-browser-tests",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meguri-browser-tests",
|
||||
"devDependencies": {
|
||||
"playwright": "1.62.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "1.62.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
param([switch]$DatabaseOnly, [int]$DatabasePort = 55432, [int]$Port = 3000,
|
||||
[string]$PostgresBin = 'C:\Program Files\PostgreSQL\17\bin')
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$portableBin = Join-Path $repo 'target/postgresql/pgsql/bin'
|
||||
if (!$PSBoundParameters.ContainsKey('PostgresBin') -and (Test-Path (Join-Path $portableBin 'postgres.exe'))) { $PostgresBin = $portableBin }
|
||||
if (!(Test-Path (Join-Path $PostgresBin '../share/postgres.bki'))) { throw 'A complete PostgreSQL server distribution is required. Pass -PostgresBin PATH; client-only installations lack share/postgres.bki.' }
|
||||
$localDir = Join-Path $repo 'target/local'
|
||||
$dataDir = Join-Path $localDir 'postgres'
|
||||
New-Item -ItemType Directory -Force $localDir | Out-Null
|
||||
if (!(Test-Path (Join-Path $dataDir 'PG_VERSION'))) {
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $dataDir -U meguri --auth=trust --encoding=UTF8 --locale=C
|
||||
if ($LASTEXITCODE) { throw 'initdb failed' }
|
||||
}
|
||||
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $dataDir status *> $null
|
||||
if ($LASTEXITCODE) {
|
||||
$pgArgs = @('-D',('"'+$dataDir+'"'),'-l',('"'+(Join-Path $localDir 'postgres.log')+'"'),'-o',('"-h 127.0.0.1 -p '+$DatabasePort+'"'),'-w','start')
|
||||
$pgStart = Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArgs -WindowStyle Hidden -PassThru
|
||||
$pgStart.WaitForExit()
|
||||
if ($pgStart.ExitCode) { throw 'PostgreSQL did not start; inspect target/local/postgres.log' }
|
||||
}
|
||||
foreach ($dbName in @('meguri','meguri_workspace_test')) {
|
||||
$exists = & (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $DatabasePort -U meguri -d postgres -Atc "SELECT 1 FROM pg_database WHERE datname='$dbName'"
|
||||
if ($exists -ne '1') {
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $DatabasePort -U meguri $dbName
|
||||
if ($LASTEXITCODE) { throw 'Could not create development database' }
|
||||
}
|
||||
}
|
||||
@{ postgresBin=$PostgresBin; databasePort=$DatabasePort; port=$Port } | ConvertTo-Json | Set-Content (Join-Path $localDir 'settings.json')
|
||||
$env:DATABASE_URL = "postgresql://meguri@127.0.0.1:$DatabasePort/meguri"
|
||||
$env:MEGURI_TEST_DATABASE_URL = "postgresql://meguri@127.0.0.1:$DatabasePort/meguri_workspace_test"
|
||||
if ($DatabaseOnly) { Write-Output "Database ready. Test URL: $env:MEGURI_TEST_DATABASE_URL"; return }
|
||||
$pidFile = Join-Path $localDir 'app.pid'
|
||||
$appExecutable = Join-Path $localDir 'meguri-dev.exe'
|
||||
if (Test-Path $pidFile) {
|
||||
$old = Get-Process -Id ([int](Get-Content $pidFile)) -ErrorAction SilentlyContinue
|
||||
if ($old -and $old.Path -eq $appExecutable) { throw 'Meguri is already running. Run scripts/stop-dev.ps1 first.' }
|
||||
}
|
||||
Push-Location $repo
|
||||
try { cargo build --locked; if ($LASTEXITCODE) { throw 'Build failed' } } finally { Pop-Location }
|
||||
$env:MEGURI_BIND = "127.0.0.1:$Port"
|
||||
$env:MEGURI_PUBLIC_URL = "http://127.0.0.1:$Port"
|
||||
$env:MEGURI_ALLOW_SIGNUP = 'true'
|
||||
$env:MEGURI_SEED_DEMO = 'true'
|
||||
$env:MEGURI_ROLE = 'all'
|
||||
Copy-Item -LiteralPath (Join-Path $repo 'target/debug/meguri.exe') -Destination $appExecutable
|
||||
$app = Start-Process -FilePath $appExecutable -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $localDir 'app.log') -RedirectStandardError (Join-Path $localDir 'app-error.log')
|
||||
$app.Id | Set-Content $pidFile
|
||||
Write-Output "Meguri starting at $env:MEGURI_PUBLIC_URL — demo: alice / meguri-demo-only"
|
||||
@@ -0,0 +1,13 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
$localDir = Join-Path $repo 'target/local'
|
||||
$pidFile = Join-Path $localDir 'app.pid'
|
||||
if (Test-Path $pidFile) {
|
||||
$app = Get-Process -Id ([int](Get-Content $pidFile)) -ErrorAction SilentlyContinue
|
||||
if ($app -and $app.Path -eq (Join-Path $localDir 'meguri-dev.exe')) { Stop-Process -Id $app.Id }
|
||||
Remove-Item -LiteralPath $pidFile
|
||||
}
|
||||
if (Test-Path (Join-Path $localDir 'settings.json')) {
|
||||
$settings = Get-Content (Join-Path $localDir 'settings.json') | ConvertFrom-Json
|
||||
& (Join-Path $settings.postgresBin 'pg_ctl.exe') -D (Join-Path $localDir 'postgres') -m fast -w stop
|
||||
}
|
||||
+714
@@ -0,0 +1,714 @@
|
||||
//! Pure absence impact planning. Explicit ranges expand across affected schedules.
|
||||
use crate::{
|
||||
domain::*,
|
||||
engine,
|
||||
error::{Result, ensure},
|
||||
};
|
||||
use chrono::{DateTime, Duration, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ExactCover {
|
||||
pub schedule_id: Uuid,
|
||||
pub start: DateTime<Utc>,
|
||||
pub end: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct LocalCover {
|
||||
#[serde(default)]
|
||||
pub schedule_id: Option<Uuid>,
|
||||
pub start: chrono::NaiveDateTime,
|
||||
pub end: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct CoverRange {
|
||||
#[serde(default)]
|
||||
pub local: Option<LocalCover>,
|
||||
#[serde(default)]
|
||||
pub exact: Option<ExactCover>,
|
||||
pub start: NaiveDate,
|
||||
pub end: NaiveDate,
|
||||
pub replacement: Uuid,
|
||||
#[serde(default)]
|
||||
pub replace_projects: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Draft {
|
||||
pub absence: Absence,
|
||||
#[serde(default)]
|
||||
pub ranges: Vec<CoverRange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Impact {
|
||||
pub start_day: NaiveDate,
|
||||
pub end_day: NaiveDate,
|
||||
pub schedule_id: Uuid,
|
||||
pub schedule_name: String,
|
||||
pub start: DateTime<Utc>,
|
||||
pub end: DateTime<Utc>,
|
||||
pub before: Uuid,
|
||||
pub after: Option<Uuid>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Plan {
|
||||
pub rows: Vec<Impact>,
|
||||
pub groups: Vec<Vec<ReplacementLeg>>,
|
||||
pub warnings: Vec<String>,
|
||||
pub errors: Vec<String>,
|
||||
pub schedules: usize,
|
||||
pub uncovered: usize,
|
||||
}
|
||||
|
||||
pub fn synthetic_request(
|
||||
absence: Uuid,
|
||||
legs: Vec<ReplacementLeg>,
|
||||
now: DateTime<Utc>,
|
||||
) -> ReplacementRequest {
|
||||
ReplacementRequest {
|
||||
effective_legs: None,
|
||||
id: Uuid::nil(),
|
||||
absence_id: Some(absence),
|
||||
requested_by: Uuid::nil(),
|
||||
created_at: now,
|
||||
reason: crate::i18n::text("err_absence_coverage_2fccc7").into(),
|
||||
legs,
|
||||
required_approvers: BTreeSet::new(),
|
||||
approvals: BTreeSet::new(),
|
||||
status: RequestStatus::Applied,
|
||||
applied_at: Some(now),
|
||||
forced_by: None,
|
||||
force_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate against original ownership, but preserve all other absences and overrides.
|
||||
pub fn validate_request(
|
||||
doc: &WorkspaceDocument,
|
||||
request: &ReplacementRequest,
|
||||
members: &BTreeSet<Uuid>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<()> {
|
||||
let absence = doc
|
||||
.absences
|
||||
.iter()
|
||||
.find(|a| Some(a.id) == request.absence_id)
|
||||
.ok_or_else(|| {
|
||||
crate::error::Error::Validation(
|
||||
crate::i18n::text("err_this_absence_no_longer_exists_8d51ae").into(),
|
||||
)
|
||||
})?;
|
||||
ensure(
|
||||
!request.legs.is_empty() && request.legs.len() <= 500,
|
||||
crate::i18n::text("err_absence_coverage_supports_1_500_affected_interva_d61c7a"),
|
||||
)?;
|
||||
let mut original = doc.clone();
|
||||
original.absences.retain(|a| a.id != absence.id);
|
||||
for leg in &request.legs {
|
||||
ensure(
|
||||
leg.from_user == absence.user_id,
|
||||
crate::i18n::text("err_absence_coverage_can_replace_only_the_absent_emp_a99c60"),
|
||||
)?;
|
||||
ensure(
|
||||
leg.start >= engine::schedule_handover(doc, leg.schedule_id, absence.start)?
|
||||
&& leg.end
|
||||
<= engine::schedule_handover(
|
||||
doc,
|
||||
leg.schedule_id,
|
||||
absence.end + Duration::days(1),
|
||||
)?,
|
||||
crate::i18n::text("err_replacement_must_stay_inside_the_absence_8ec5a5"),
|
||||
)?;
|
||||
engine::validate_legs(&original, std::slice::from_ref(leg), members, now)?;
|
||||
}
|
||||
for (index, leg) in request.legs.iter().enumerate() {
|
||||
ensure(
|
||||
request.legs[..index].iter().all(|other| {
|
||||
other.schedule_id != leg.schedule_id
|
||||
|| other.end <= leg.start
|
||||
|| leg.end <= other.start
|
||||
}),
|
||||
crate::i18n::text("err_absence_replacement_intervals_overlap_d053f4"),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn range_interval(
|
||||
doc: &WorkspaceDocument,
|
||||
range: &CoverRange,
|
||||
schedule: &Schedule,
|
||||
now: DateTime<Utc>,
|
||||
absence: &Absence,
|
||||
) -> Result<Option<(DateTime<Utc>, DateTime<Utc>)>> {
|
||||
if let Some(local) = &range.local {
|
||||
if local.schedule_id.is_some_and(|id| id != schedule.id)
|
||||
|| (local.schedule_id.is_none()
|
||||
&& matches!(schedule.kind, ScheduleKind::Priority { .. })
|
||||
&& !range.replace_projects)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let start = engine::local_time(doc, local.start.date(), local.start.time())?;
|
||||
let end = engine::local_time(doc, local.end.date(), local.end.time())?;
|
||||
let first = engine::schedule_handover(doc, schedule.id, absence.start)?;
|
||||
let last = engine::schedule_handover(doc, schedule.id, absence.end + Duration::days(1))?;
|
||||
if local.schedule_id.is_some() {
|
||||
ensure(
|
||||
start >= first && end <= last,
|
||||
crate::i18n::text("err_replacement_must_stay_inside_the_absence_8ec5a5"),
|
||||
)?;
|
||||
}
|
||||
let span = (start.max(first).max(now), end.min(last));
|
||||
return Ok((span.0 < span.1).then_some(span));
|
||||
}
|
||||
if let Some(exact) = &range.exact {
|
||||
return Ok((exact.schedule_id == schedule.id).then_some((exact.start.max(now), exact.end)));
|
||||
}
|
||||
if matches!(schedule.kind, ScheduleKind::Priority { .. }) && !range.replace_projects {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some((
|
||||
engine::schedule_handover(doc, schedule.id, range.start)?.max(now),
|
||||
engine::schedule_handover(doc, schedule.id, range.end + Duration::days(1))?,
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn plan(
|
||||
doc: &WorkspaceDocument,
|
||||
draft: &Draft,
|
||||
members: &BTreeSet<Uuid>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<Plan> {
|
||||
let absence = &draft.absence;
|
||||
ensure(
|
||||
absence.start <= absence.end && (absence.end - absence.start).num_days() < 366,
|
||||
crate::i18n::text("err_choose_an_absence_of_1_366_duty_days_090954"),
|
||||
)?;
|
||||
ensure(
|
||||
absence.end >= engine::duty_day(doc, now)?
|
||||
&& absence.end <= now.date_naive() + Duration::days(366),
|
||||
crate::i18n::text("err_absence_must_end_within_the_next_year_767689"),
|
||||
)?;
|
||||
ensure(
|
||||
!doc.absences.iter().any(|a| {
|
||||
a.id == absence.id
|
||||
|| (a.user_id == absence.user_id
|
||||
&& a.start <= absence.end
|
||||
&& absence.start <= a.end)
|
||||
}),
|
||||
crate::i18n::text("err_this_person_already_has_an_overlapping_absence_f17a77"),
|
||||
)?;
|
||||
ensure(
|
||||
draft.ranges.len() <= 24,
|
||||
crate::i18n::text("err_use_at_most_24_replacement_ranges_52203c"),
|
||||
)?;
|
||||
let mut candidate = doc.clone();
|
||||
candidate.absences.push(absence.clone());
|
||||
candidate.validate(members)?;
|
||||
let people = candidate.person_ids(members);
|
||||
let mut ranges = draft.ranges.clone();
|
||||
ranges.sort_by_key(|r| r.start);
|
||||
for range in &ranges {
|
||||
if let Some(local) = &range.local {
|
||||
ensure(
|
||||
range.exact.is_none(),
|
||||
crate::i18n::text("absence_invalid_interval"),
|
||||
)?;
|
||||
if let Some(id) = local.schedule_id {
|
||||
ensure(
|
||||
doc.schedule(id).is_some(),
|
||||
crate::i18n::text("err_schedule_does_not_exist_637210"),
|
||||
)?;
|
||||
}
|
||||
let start = engine::local_time(doc, local.start.date(), local.start.time())?;
|
||||
let end = engine::local_time(doc, local.end.date(), local.end.time())?;
|
||||
ensure(
|
||||
start < end
|
||||
&& end - start <= Duration::days(90)
|
||||
&& local.start.date() >= absence.start
|
||||
&& local.end.date() <= absence.end + Duration::days(1),
|
||||
crate::i18n::text("absence_invalid_interval"),
|
||||
)?;
|
||||
} else {
|
||||
ensure(
|
||||
range.start >= absence.start
|
||||
&& range.end <= absence.end
|
||||
&& range.start <= range.end
|
||||
&& (range.end - range.start).num_days() < 90,
|
||||
crate::i18n::text("err_each_replacement_range_must_cover_1_90_duty_days_f18f21"),
|
||||
)?;
|
||||
}
|
||||
if let Some(exact) = &range.exact {
|
||||
ensure(
|
||||
doc.schedule(exact.schedule_id).is_some(),
|
||||
crate::i18n::text("err_schedule_does_not_exist_637210"),
|
||||
)?;
|
||||
ensure(
|
||||
exact.start < exact.end
|
||||
&& exact.start
|
||||
>= engine::schedule_handover(doc, exact.schedule_id, range.start)?
|
||||
&& exact.end
|
||||
<= engine::schedule_handover(
|
||||
doc,
|
||||
exact.schedule_id,
|
||||
range.end + Duration::days(1),
|
||||
)?,
|
||||
crate::i18n::text("err_replacement_must_stay_inside_the_absence_8ec5a5"),
|
||||
)?;
|
||||
}
|
||||
ensure(
|
||||
range.replacement != absence.user_id && people.contains(&range.replacement),
|
||||
crate::i18n::text("err_choose_a_different_workspace_user_or_email_conta_a0a168"),
|
||||
)?;
|
||||
}
|
||||
for (i, range) in ranges.iter().enumerate() {
|
||||
for other in &ranges[..i] {
|
||||
for schedule in &doc.schedules {
|
||||
if let (Some((a, b)), Some((c, d))) = (
|
||||
range_interval(doc, range, schedule, now, absence)?,
|
||||
range_interval(doc, other, schedule, now, absence)?,
|
||||
) {
|
||||
ensure(
|
||||
b <= c || d <= a,
|
||||
crate::i18n::text("err_replacement_ranges_must_not_overlap_bf6aad"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut plan = Plan {
|
||||
rows: vec![],
|
||||
groups: vec![],
|
||||
warnings: vec![],
|
||||
errors: vec![],
|
||||
schedules: 0,
|
||||
uncovered: 0,
|
||||
};
|
||||
for range in ranges {
|
||||
let mut legs = vec![];
|
||||
for schedule in &doc.schedules {
|
||||
let Some((start, end)) = range_interval(doc, &range, schedule, now, absence)? else {
|
||||
continue;
|
||||
};
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
let before = engine::timeline(doc, start, end)?;
|
||||
for assignment in before
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == schedule.id && a.user_id == absence.user_id)
|
||||
{
|
||||
legs.push(ReplacementLeg {
|
||||
schedule_id: schedule.id,
|
||||
start: assignment.start,
|
||||
end: assignment.end,
|
||||
from_user: absence.user_id,
|
||||
to_user: range.replacement,
|
||||
});
|
||||
}
|
||||
}
|
||||
if legs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let request = synthetic_request(absence.id, legs.clone(), now);
|
||||
match validate_request(&candidate, &request, members, now) {
|
||||
Ok(()) => {
|
||||
engine::supersede_overrides(&mut candidate, &request.legs);
|
||||
candidate.requests.push(request);
|
||||
plan.groups.push(legs);
|
||||
}
|
||||
Err(error) => plan.errors.push(format!(
|
||||
"{} – {}: {error}",
|
||||
range.start.format("%d-%m-%Y"),
|
||||
range.end.format("%d-%m-%Y")
|
||||
)),
|
||||
}
|
||||
}
|
||||
for schedule in &doc.schedules {
|
||||
let start = engine::schedule_handover(doc, schedule.id, absence.start)?.max(now);
|
||||
let end = engine::schedule_handover(doc, schedule.id, absence.end + Duration::days(1))?;
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
let before = engine::timeline(doc, start, end)?;
|
||||
let after = engine::timeline(&candidate, start, end)?;
|
||||
let old: Vec<_> = before
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == schedule.id)
|
||||
.collect();
|
||||
let new: Vec<_> = after
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == schedule.id)
|
||||
.collect();
|
||||
let mut bounds = BTreeSet::from([start, end]);
|
||||
for a in old.iter().chain(&new) {
|
||||
bounds.insert(a.start);
|
||||
bounds.insert(a.end);
|
||||
}
|
||||
let bounds: Vec<_> = bounds.into_iter().collect();
|
||||
for pair in bounds.windows(2) {
|
||||
let a = old
|
||||
.iter()
|
||||
.find(|a| a.start <= pair[0] && pair[0] < a.end)
|
||||
.unwrap_or_else(|| {
|
||||
panic!("{}", crate::i18n::text("err_timeline_covers_range_6c1844"))
|
||||
});
|
||||
let b = new
|
||||
.iter()
|
||||
.find(|a| a.start <= pair[0] && pair[0] < a.end)
|
||||
.unwrap_or_else(|| {
|
||||
panic!("{}", crate::i18n::text("err_timeline_covers_range_6c1844"))
|
||||
});
|
||||
if a.user_id != absence.user_id
|
||||
&& a.user_id == b.user_id
|
||||
&& a.unavailable == b.unavailable
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let proposed = plan
|
||||
.groups
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|l| l.schedule_id == schedule.id && l.start <= pair[0] && pair[0] < l.end);
|
||||
let status = if b.unavailable {
|
||||
"uncovered"
|
||||
} else if proposed {
|
||||
"replacement"
|
||||
} else {
|
||||
"automatic"
|
||||
};
|
||||
let after = (!b.unavailable).then_some(b.user_id);
|
||||
if let Some(last) = plan.rows.last_mut()
|
||||
&& last.schedule_id == schedule.id
|
||||
&& last.end == pair[0]
|
||||
&& last.before == a.user_id
|
||||
&& last.after == after
|
||||
&& last.status == status
|
||||
{
|
||||
last.end = pair[1];
|
||||
last.end_day = engine::schedule_duty_day(
|
||||
doc,
|
||||
schedule.id,
|
||||
pair[1] - Duration::nanoseconds(1),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
plan.rows.push(Impact {
|
||||
start_day: engine::schedule_duty_day(doc, schedule.id, pair[0])?,
|
||||
end_day: engine::schedule_duty_day(
|
||||
doc,
|
||||
schedule.id,
|
||||
pair[1] - Duration::nanoseconds(1),
|
||||
)?,
|
||||
schedule_id: schedule.id,
|
||||
schedule_name: schedule.name.clone(),
|
||||
start: pair[0],
|
||||
end: pair[1],
|
||||
before: a.user_id,
|
||||
after,
|
||||
status: status.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
plan.schedules = plan
|
||||
.rows
|
||||
.iter()
|
||||
.map(|r| r.schedule_id)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len();
|
||||
plan.uncovered = plan.rows.iter().filter(|r| r.after.is_none()).count();
|
||||
for policy in &doc.escalations {
|
||||
if policy.steps.iter().any(|s| {
|
||||
matches!(s.recipient,Recipient::User{id} if id==absence.user_id)
|
||||
|| matches!(s.recipient, Recipient::TeamAll | Recipient::TeamRandom)
|
||||
}) {
|
||||
plan.warnings.push(crate::i18n::message(
|
||||
"err__p0_direct_user_team_notifications_are_not_chang_66a117",
|
||||
&[("p0", (policy.name).to_string())],
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn fixture() -> (
|
||||
WorkspaceDocument,
|
||||
Draft,
|
||||
BTreeSet<Uuid>,
|
||||
DateTime<Utc>,
|
||||
[Uuid; 3],
|
||||
) {
|
||||
let people = [Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()];
|
||||
let mut doc = WorkspaceDocument::new("Platform".into());
|
||||
doc.timezone = "UTC".into();
|
||||
let start: NaiveDate = "2027-01-04".parse().unwrap();
|
||||
let rotation = Uuid::new_v4();
|
||||
doc.schedules.push(Schedule {
|
||||
id: rotation,
|
||||
name: "General".into(),
|
||||
handover: default_handover(),
|
||||
kind: ScheduleKind::Rotation {
|
||||
participants: vec![people[0]],
|
||||
anchor: start,
|
||||
shift_days: 7,
|
||||
},
|
||||
});
|
||||
doc.schedules.push(Schedule {
|
||||
id: Uuid::new_v4(),
|
||||
name: "Backup".into(),
|
||||
handover: "09:00:00".parse().unwrap(),
|
||||
kind: ScheduleKind::Rotation {
|
||||
participants: vec![people[0]],
|
||||
anchor: "2026-09-21".parse().unwrap(),
|
||||
shift_days: 7,
|
||||
},
|
||||
});
|
||||
for i in 0..5 {
|
||||
doc.schedules.push(Schedule {
|
||||
id: Uuid::new_v4(),
|
||||
name: format!("Project {i}"),
|
||||
handover: default_handover(),
|
||||
kind: ScheduleKind::Priority {
|
||||
participants: vec![people[0], people[2]],
|
||||
fallback: rotation,
|
||||
},
|
||||
});
|
||||
}
|
||||
let draft = Draft {
|
||||
absence: Absence {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: people[0],
|
||||
start,
|
||||
end: start + Duration::days(13),
|
||||
},
|
||||
ranges: vec![
|
||||
CoverRange {
|
||||
local: None,
|
||||
exact: None,
|
||||
start,
|
||||
end: start + Duration::days(6),
|
||||
replacement: people[1],
|
||||
replace_projects: false,
|
||||
},
|
||||
CoverRange {
|
||||
local: None,
|
||||
exact: None,
|
||||
start: start + Duration::days(7),
|
||||
end: start + Duration::days(13),
|
||||
replacement: people[2],
|
||||
replace_projects: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
(
|
||||
doc,
|
||||
draft,
|
||||
people.into(),
|
||||
"2027-01-01T00:00:00Z".parse().unwrap(),
|
||||
people,
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn two_ranges_cover_all_rotations_and_backups_and_promote_all_projects() {
|
||||
let (doc, draft, people, now, ids) = fixture();
|
||||
let plan = plan(&doc, &draft, &people, now).unwrap();
|
||||
assert!(plan.errors.is_empty());
|
||||
assert_eq!(plan.schedules, 7);
|
||||
assert_eq!(plan.uncovered, 0);
|
||||
assert_eq!(plan.groups.len(), 2);
|
||||
assert_eq!(plan.groups[0].len(), 2);
|
||||
assert_eq!(plan.groups[1].len(), 2);
|
||||
assert!(plan.groups[0].iter().all(|l| l.to_user == ids[1]));
|
||||
assert!(plan.groups[1].iter().all(|l| l.to_user == ids[2]));
|
||||
assert!(
|
||||
plan.rows
|
||||
.iter()
|
||||
.filter(|r| r.schedule_name.starts_with("Project"))
|
||||
.all(|r| r.after == Some(ids[2]) && r.status == "automatic")
|
||||
);
|
||||
assert!(doc.absences.is_empty());
|
||||
assert!(doc.requests.is_empty());
|
||||
assert!(
|
||||
plan.groups[0]
|
||||
.iter()
|
||||
.any(|l| l.start.time().to_string() == "09:00:00")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn uncovered_ranges_unavailable_substitutes_and_explicit_project_cover_are_distinct() {
|
||||
let (mut doc, mut draft, people, now, ids) = fixture();
|
||||
draft.ranges.clear();
|
||||
let uncovered = plan(&doc, &draft, &people, now).unwrap();
|
||||
assert_eq!(uncovered.uncovered, 2);
|
||||
assert_eq!(uncovered.groups.len(), 0);
|
||||
draft.ranges.push(CoverRange {
|
||||
local: None,
|
||||
exact: None,
|
||||
start: draft.absence.start,
|
||||
end: draft.absence.end,
|
||||
replacement: ids[1],
|
||||
replace_projects: true,
|
||||
});
|
||||
let covered = plan(&doc, &draft, &people, now).unwrap();
|
||||
assert_eq!(covered.groups[0].len(), 7);
|
||||
assert_eq!(covered.uncovered, 0);
|
||||
doc.absences.push(Absence {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: ids[1],
|
||||
start: draft.absence.start,
|
||||
end: draft.absence.start,
|
||||
});
|
||||
assert!(!plan(&doc, &draft, &people, now).unwrap().errors.is_empty());
|
||||
draft.ranges.push(draft.ranges[0].clone());
|
||||
assert!(plan(&doc, &draft, &people, now).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn planned_substitutions_preserve_other_owners_and_can_replace_current_cover() {
|
||||
let (mut doc, draft, people, now, ids) = fixture();
|
||||
if let ScheduleKind::Rotation { participants, .. } = &mut doc.schedules[0].kind {
|
||||
participants.push(ids[1]);
|
||||
}
|
||||
let covered = plan(&doc, &draft, &people, now).unwrap();
|
||||
assert_eq!(
|
||||
covered.groups[1].len(),
|
||||
1,
|
||||
"Second week already belongs to someone else on the rotation"
|
||||
);
|
||||
let mut existing = synthetic_request(
|
||||
Uuid::new_v4(),
|
||||
vec![ReplacementLeg {
|
||||
schedule_id: doc.schedules[0].id,
|
||||
start: covered.groups[0][0].start,
|
||||
end: covered.groups[0][0].end,
|
||||
from_user: ids[1],
|
||||
to_user: ids[0],
|
||||
}],
|
||||
now,
|
||||
);
|
||||
existing.absence_id = None;
|
||||
doc.requests.push(existing);
|
||||
let preview = plan(&doc, &draft, &people, now).unwrap();
|
||||
assert!(preview.errors.is_empty());
|
||||
assert_eq!(preview.uncovered, 0);
|
||||
assert!(
|
||||
preview
|
||||
.groups
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|leg| leg.from_user == ids[0])
|
||||
);
|
||||
assert!(
|
||||
doc.requests[0].effective_legs.is_none(),
|
||||
"Preview must not modify the approved request"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn local_ranges_share_scoping_bounds_and_reject_dst_discontinuities() {
|
||||
let (mut doc, mut draft, people, now, ids) = fixture();
|
||||
draft.ranges = vec![CoverRange {
|
||||
local: Some(LocalCover {
|
||||
schedule_id: Some(doc.schedules[0].id),
|
||||
start: "2027-01-04T12:30:00".parse().unwrap(),
|
||||
end: "2027-01-05T14:00:00".parse().unwrap(),
|
||||
}),
|
||||
exact: None,
|
||||
start: draft.absence.start,
|
||||
end: draft.absence.end,
|
||||
replacement: ids[1],
|
||||
replace_projects: false,
|
||||
}];
|
||||
let result = plan(&doc, &draft, &people, now).unwrap();
|
||||
assert_eq!(result.groups[0].len(), 1);
|
||||
assert_eq!(
|
||||
result.groups[0][0].start,
|
||||
"2027-01-04T12:30:00Z".parse::<DateTime<Utc>>().unwrap()
|
||||
);
|
||||
draft.ranges[0].local.as_mut().unwrap().schedule_id = None;
|
||||
assert_eq!(plan(&doc, &draft, &people, now).unwrap().groups[0].len(), 2);
|
||||
draft.ranges.push(draft.ranges[0].clone());
|
||||
assert!(plan(&doc, &draft, &people, now).is_err());
|
||||
draft.ranges.pop();
|
||||
doc.timezone = "Europe/Nicosia".into();
|
||||
for date in ["2027-03-28", "2027-10-31"] {
|
||||
let day: NaiveDate = date.parse().unwrap();
|
||||
draft.absence.start = day - Duration::days(1);
|
||||
draft.absence.end = day + Duration::days(1);
|
||||
let local = draft.ranges[0].local.as_mut().unwrap();
|
||||
local.start = day.and_hms_opt(3, 30, 0).unwrap();
|
||||
local.end = day.and_hms_opt(5, 0, 0).unwrap();
|
||||
assert!(plan(&doc, &draft, &people, now).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_impact_cover_is_scoped_and_rejects_overlaps_or_outside_bounds() {
|
||||
let (doc, mut draft, people, now, ids) = fixture();
|
||||
draft.ranges.clear();
|
||||
let impact = plan(&doc, &draft, &people, now).unwrap();
|
||||
let gaps: Vec<_> = impact
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.status == "uncovered")
|
||||
.collect();
|
||||
assert!(gaps.len() >= 2);
|
||||
let gap = gaps[0];
|
||||
assert_eq!(
|
||||
gap.end_day, draft.absence.end,
|
||||
"Merged impact retains its final duty day"
|
||||
);
|
||||
let exact = ExactCover {
|
||||
schedule_id: gap.schedule_id,
|
||||
start: gap.start + Duration::hours(2),
|
||||
end: gap.end - Duration::hours(2),
|
||||
};
|
||||
draft.ranges.push(CoverRange {
|
||||
local: None,
|
||||
start: gap.start_day,
|
||||
end: gap.end_day,
|
||||
replacement: ids[1],
|
||||
replace_projects: false,
|
||||
exact: Some(exact.clone()),
|
||||
});
|
||||
let proposed = plan(&doc, &draft, &people, now).unwrap();
|
||||
assert!(proposed.errors.is_empty());
|
||||
assert_eq!(proposed.groups[0].len(), 1);
|
||||
let leg = &proposed.groups[0][0];
|
||||
assert_eq!(
|
||||
(leg.schedule_id, leg.start, leg.end),
|
||||
(exact.schedule_id, exact.start, exact.end)
|
||||
);
|
||||
let other = gaps[1];
|
||||
draft.ranges.push(CoverRange {
|
||||
local: None,
|
||||
start: other.start_day,
|
||||
end: other.end_day,
|
||||
replacement: ids[2],
|
||||
replace_projects: false,
|
||||
exact: Some(ExactCover {
|
||||
schedule_id: other.schedule_id,
|
||||
start: other.start,
|
||||
end: other.end,
|
||||
}),
|
||||
});
|
||||
assert!(plan(&doc, &draft, &people, now).unwrap().errors.is_empty());
|
||||
draft.ranges.push(draft.ranges[0].clone());
|
||||
assert!(plan(&doc, &draft, &people, now).is_err());
|
||||
draft.ranges.pop();
|
||||
draft.ranges[0].exact.as_mut().unwrap().start = gap.start - Duration::hours(1);
|
||||
assert!(plan(&doc, &draft, &people, now).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Read-only audit presentation; persisted event payloads are never rewritten.
|
||||
use crate::domain::WorkspaceDocument;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub type Names = BTreeMap<String, String>;
|
||||
|
||||
pub fn remember(names: &mut Names, doc: &WorkspaceDocument) {
|
||||
for person in &doc.people {
|
||||
names.insert(person.id.to_string(), person.display_name.clone());
|
||||
if let Some(id) = person.user_id {
|
||||
names.insert(id.to_string(), person.display_name.clone());
|
||||
}
|
||||
}
|
||||
for (id, name) in doc
|
||||
.schedules
|
||||
.iter()
|
||||
.map(|s| (s.id, &s.name))
|
||||
.chain(doc.escalations.iter().map(|s| (s.id, &s.name)))
|
||||
.chain(doc.routes.iter().map(|s| (s.id, &s.name)))
|
||||
.chain(doc.generation.rules.iter().map(|s| (s.id, &s.name)))
|
||||
.chain(doc.generation.variables.iter().map(|s| (s.id, &s.name)))
|
||||
{
|
||||
names.insert(id.to_string(), name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(value: &Value, names: &Names) -> Value {
|
||||
match value {
|
||||
Value::String(id) => names
|
||||
.get(id)
|
||||
.map(|name| json!({"name":name,"id":id}))
|
||||
.unwrap_or_else(|| value.clone()),
|
||||
Value::Array(items) => Value::Array(items.iter().map(|v| resolve(v, names)).collect()),
|
||||
Value::Object(fields) => Value::Object(
|
||||
fields
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
// Free text must remain verbatim, even if a comment happens to equal a UUID.
|
||||
let reference = key == "id"
|
||||
|| key.ends_with("_id")
|
||||
|| matches!(
|
||||
key.as_str(),
|
||||
"from_user"
|
||||
| "to_user"
|
||||
| "requested_by"
|
||||
| "forced_by"
|
||||
| "approvals"
|
||||
| "required_approvers"
|
||||
| "participants"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "fallback"
|
||||
| "user"
|
||||
| "redirect_to"
|
||||
| "escalation"
|
||||
| "variable"
|
||||
| "iteration"
|
||||
| "output"
|
||||
);
|
||||
let resolved = if reference || value.is_object() || value.is_array() {
|
||||
resolve(value, names)
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
(key.clone(), resolved)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn references_expand_without_changing_comments_unknown_ids_or_raw_details() {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let names = Names::from([(id.clone(), "Alice".into())]);
|
||||
let raw = json!({"from_user":id,"approvals":[id],"reason":id,"unknown_id":"missing","legs":[{"to_user":id}]});
|
||||
let resolved = resolve(&raw, &names);
|
||||
assert_eq!(resolved["from_user"]["name"], "Alice");
|
||||
assert_eq!(resolved["approvals"][0]["id"], id);
|
||||
assert_eq!(resolved["legs"][0]["to_user"]["name"], "Alice");
|
||||
assert_eq!(resolved["reason"], id);
|
||||
assert_eq!(resolved["unknown_id"], "missing");
|
||||
assert_eq!(raw["from_user"], id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Pure edits of the configuration graph. No storage or provider side effects.
|
||||
use crate::{
|
||||
domain::*,
|
||||
error::{Error, Result, ensure},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NamedResource {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EscalationUse {
|
||||
pub policy_id: Uuid,
|
||||
pub policy_name: String,
|
||||
pub step_number: usize,
|
||||
pub delay_minutes: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScheduleRemovalPlan {
|
||||
pub schedule: NamedResource,
|
||||
pub escalation_steps: Vec<EscalationUse>,
|
||||
pub coverage_fallbacks: Vec<NamedResource>,
|
||||
pub policies_emptied: Vec<NamedResource>,
|
||||
pub pending_requests: Vec<Uuid>,
|
||||
pub active_replacements: Vec<Uuid>,
|
||||
}
|
||||
|
||||
pub fn schedule_removal_plan(
|
||||
doc: &WorkspaceDocument,
|
||||
id: Uuid,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<ScheduleRemovalPlan> {
|
||||
ensure(
|
||||
!doc.generation.managed.iter().any(|m| m.id == id),
|
||||
crate::i18n::text("err_this_schedule_is_generated_remove_it_through_var_6da973"),
|
||||
)?;
|
||||
let schedule = doc.schedule(id).ok_or(Error::NotFound)?;
|
||||
let mut plan = ScheduleRemovalPlan {
|
||||
schedule: NamedResource {
|
||||
id,
|
||||
name: schedule.name.clone(),
|
||||
},
|
||||
escalation_steps: vec![],
|
||||
coverage_fallbacks: vec![],
|
||||
policies_emptied: vec![],
|
||||
pending_requests: vec![],
|
||||
active_replacements: vec![],
|
||||
};
|
||||
for policy in &doc.escalations {
|
||||
let mut removed = 0;
|
||||
for (index, step) in policy.steps.iter().enumerate() {
|
||||
if matches!(step.recipient, Recipient::Schedule { id: target } if target==id) {
|
||||
removed += 1;
|
||||
plan.escalation_steps.push(EscalationUse {
|
||||
policy_id: policy.id,
|
||||
policy_name: policy.name.clone(),
|
||||
step_number: index + 1,
|
||||
delay_minutes: step.delay_minutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
if removed > 0 && removed == policy.steps.len() {
|
||||
plan.policies_emptied.push(NamedResource {
|
||||
id: policy.id,
|
||||
name: policy.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for schedule in &doc.schedules {
|
||||
if matches!(schedule.kind, ScheduleKind::Priority { fallback,.. } if fallback==id) {
|
||||
plan.coverage_fallbacks.push(NamedResource {
|
||||
id: schedule.id,
|
||||
name: schedule.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for request in &doc.requests {
|
||||
if request.status == RequestStatus::Pending
|
||||
&& request.legs.iter().any(|leg| leg.schedule_id == id)
|
||||
{
|
||||
plan.pending_requests.push(request.id);
|
||||
}
|
||||
if request.status == RequestStatus::Applied
|
||||
&& request
|
||||
.effective_legs()
|
||||
.iter()
|
||||
.any(|leg| leg.schedule_id == id && leg.end > now)
|
||||
{
|
||||
plan.active_replacements.push(request.id);
|
||||
}
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub fn remove_schedule(
|
||||
doc: &WorkspaceDocument,
|
||||
id: Uuid,
|
||||
redirect: Option<Uuid>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<WorkspaceDocument> {
|
||||
let plan = schedule_removal_plan(doc, id, now)?;
|
||||
ensure(
|
||||
plan.active_replacements.is_empty(),
|
||||
crate::i18n::text("err_this_schedule_has_current_or_future_approved_rep_49a098"),
|
||||
)?;
|
||||
if let Some(target) = redirect {
|
||||
ensure(
|
||||
target != id && doc.schedule(target).is_some(),
|
||||
crate::i18n::text("err_choose_a_different_existing_schedule_for_the_ref_ea3955"),
|
||||
)?;
|
||||
} else {
|
||||
ensure(
|
||||
plan.coverage_fallbacks.is_empty(),
|
||||
crate::i18n::message(
|
||||
"err_choose_a_replacement_fallback_for_p0_6e17d4",
|
||||
&[(
|
||||
"p0",
|
||||
(plan
|
||||
.coverage_fallbacks
|
||||
.iter()
|
||||
.map(|s| s.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "))
|
||||
.to_string(),
|
||||
)],
|
||||
),
|
||||
)?;
|
||||
ensure(
|
||||
plan.policies_emptied.is_empty(),
|
||||
crate::i18n::message(
|
||||
"err_choose_a_replacement_schedule_or_edit_these_poli_f9b0c9",
|
||||
&[(
|
||||
"p0",
|
||||
(plan
|
||||
.policies_emptied
|
||||
.iter()
|
||||
.map(|s| s.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "))
|
||||
.to_string(),
|
||||
)],
|
||||
),
|
||||
)?;
|
||||
}
|
||||
let mut result = doc.clone();
|
||||
result.schedules.retain(|s| s.id != id);
|
||||
for policy in &mut result.escalations {
|
||||
policy.steps.retain_mut(|step| {
|
||||
if matches!(step.recipient, Recipient::Schedule { id: target } if target==id) {
|
||||
if let Some(target) = redirect {
|
||||
step.recipient = Recipient::Schedule { id: target };
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
for schedule in &mut result.schedules {
|
||||
if let ScheduleKind::Priority { fallback, .. } = &mut schedule.kind
|
||||
&& *fallback == id
|
||||
{
|
||||
*fallback = redirect.expect("A fallback redirect was validated above");
|
||||
}
|
||||
}
|
||||
for request in &mut result.requests {
|
||||
if plan.pending_requests.contains(&request.id) {
|
||||
request.status = RequestStatus::Cancelled;
|
||||
}
|
||||
}
|
||||
crate::generation::validate_manual_edit(doc, &mut result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn reorder_routes(doc: &mut WorkspaceDocument, ids: &[Uuid]) -> Result<()> {
|
||||
let expected: BTreeSet<_> = doc.routes.iter().map(|r| r.id).collect();
|
||||
let supplied: BTreeSet<_> = ids.iter().copied().collect();
|
||||
ensure(
|
||||
ids.len() <= 500
|
||||
&& ids.len() == doc.routes.len()
|
||||
&& supplied.len() == ids.len()
|
||||
&& supplied == expected,
|
||||
crate::i18n::text("err_the_ordered_list_must_contain_every_current_rout_a7854b"),
|
||||
)?;
|
||||
let mut routes = Vec::with_capacity(ids.len());
|
||||
for (index, id) in ids.iter().enumerate() {
|
||||
let mut route = doc
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.id == *id)
|
||||
.unwrap_or_else(|| panic!("{}", crate::i18n::text("err_validated_permutation_74a9c2")))
|
||||
.clone();
|
||||
route.order = index as u16;
|
||||
routes.push(route);
|
||||
}
|
||||
doc.routes = routes;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
fn fixture() -> (WorkspaceDocument, Uuid, Uuid, Uuid) {
|
||||
let mut doc = WorkspaceDocument::new("Platform".into());
|
||||
let primary = Uuid::new_v4();
|
||||
let secondary = Uuid::new_v4();
|
||||
let person = Uuid::new_v4();
|
||||
doc.schedules = [(primary, "Primary"), (secondary, "Secondary")]
|
||||
.into_iter()
|
||||
.map(|(id, name)| Schedule {
|
||||
id,
|
||||
name: name.into(),
|
||||
handover: default_handover(),
|
||||
kind: ScheduleKind::Rotation {
|
||||
participants: vec![person],
|
||||
anchor: "2026-09-21".parse().unwrap(),
|
||||
shift_days: 7,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
doc.escalations.push(Escalation {
|
||||
id: Uuid::new_v4(),
|
||||
name: "Payments escalation".into(),
|
||||
steps: vec![
|
||||
EscalationStep {
|
||||
delay_minutes: 0,
|
||||
condition: EscalationCondition::IfNotAcked,
|
||||
recipient: Recipient::Schedule { id: primary },
|
||||
},
|
||||
EscalationStep {
|
||||
delay_minutes: 10,
|
||||
condition: EscalationCondition::IfNotAcked,
|
||||
recipient: Recipient::Schedule { id: secondary },
|
||||
},
|
||||
],
|
||||
repeat: Default::default(),
|
||||
});
|
||||
(doc, primary, secondary, person)
|
||||
}
|
||||
#[test]
|
||||
fn remove_backup_prunes_only_its_steps_and_cancels_pending_requests_as_a_whole() {
|
||||
let (mut doc, primary, secondary, person) = fixture();
|
||||
let now = DateTime::parse_from_rfc3339("2027-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
let mut request = ReplacementRequest {
|
||||
effective_legs: None,
|
||||
id: Uuid::new_v4(),
|
||||
absence_id: None,
|
||||
requested_by: person,
|
||||
created_at: now,
|
||||
reason: "Swap".into(),
|
||||
legs: vec![
|
||||
ReplacementLeg {
|
||||
schedule_id: secondary,
|
||||
start: now + Duration::days(1),
|
||||
end: now + Duration::days(2),
|
||||
from_user: person,
|
||||
to_user: Uuid::new_v4(),
|
||||
},
|
||||
ReplacementLeg {
|
||||
schedule_id: primary,
|
||||
start: now + Duration::days(8),
|
||||
end: now + Duration::days(9),
|
||||
from_user: Uuid::new_v4(),
|
||||
to_user: person,
|
||||
},
|
||||
],
|
||||
required_approvers: Default::default(),
|
||||
approvals: Default::default(),
|
||||
status: RequestStatus::Pending,
|
||||
applied_at: None,
|
||||
forced_by: None,
|
||||
force_reason: None,
|
||||
};
|
||||
doc.requests.push(request.clone());
|
||||
let plan = schedule_removal_plan(&doc, secondary, now).unwrap();
|
||||
assert_eq!(plan.escalation_steps[0].step_number, 2);
|
||||
assert_eq!(plan.pending_requests, vec![request.id]);
|
||||
let removed = remove_schedule(&doc, secondary, None, now).unwrap();
|
||||
assert_eq!(doc.schedules.len(), 2, "Input remains unchanged");
|
||||
assert_eq!(removed.schedules.len(), 1);
|
||||
assert_eq!(removed.escalations[0].steps.len(), 1);
|
||||
assert_eq!(removed.requests[0].status, RequestStatus::Cancelled);
|
||||
assert_eq!(removed.requests[0].legs.len(), 2);
|
||||
request.status = RequestStatus::Applied;
|
||||
doc.requests[0] = request;
|
||||
assert!(
|
||||
remove_schedule(&doc, secondary, Some(primary), now).is_err(),
|
||||
"Redirecting references cannot rewrite an approved replacement"
|
||||
);
|
||||
let past = remove_schedule(&doc, secondary, None, now + Duration::days(10)).unwrap();
|
||||
assert_eq!(past.requests[0].status, RequestStatus::Applied);
|
||||
}
|
||||
#[test]
|
||||
fn last_policy_step_and_coverage_fallback_require_an_explicit_valid_redirect() {
|
||||
let (mut doc, primary, secondary, person) = fixture();
|
||||
let now = Utc::now();
|
||||
doc.escalations[0].steps.remove(0);
|
||||
assert!(remove_schedule(&doc, secondary, None, now).is_err());
|
||||
let redirect = remove_schedule(&doc, secondary, Some(primary), now).unwrap();
|
||||
assert!(
|
||||
matches!(redirect.escalations[0].steps[0].recipient,Recipient::Schedule{id} if id==primary)
|
||||
);
|
||||
doc.schedules[0].kind = ScheduleKind::Priority {
|
||||
participants: vec![person, Uuid::new_v4()],
|
||||
fallback: secondary,
|
||||
};
|
||||
assert_eq!(
|
||||
schedule_removal_plan(&doc, secondary, now)
|
||||
.unwrap()
|
||||
.coverage_fallbacks
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let cycle = remove_schedule(&doc, secondary, Some(primary), now).unwrap();
|
||||
let people = match &doc.schedules[0].kind {
|
||||
ScheduleKind::Priority { participants, .. } => participants.iter().copied().collect(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(
|
||||
cycle.validate(&people).is_err(),
|
||||
"The service must validate the rewritten graph before committing"
|
||||
);
|
||||
assert!(remove_schedule(&doc, secondary, Some(Uuid::new_v4()), now).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn order_requires_an_exact_permutation_and_preserves_rule_meaning() {
|
||||
let mut doc = WorkspaceDocument::new("Team".into());
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
doc.routes = vec![
|
||||
RoutingRule {
|
||||
id: first,
|
||||
name: "Specific".into(),
|
||||
order: 10,
|
||||
conditions: vec![RouteCondition {
|
||||
field: "tags".into(),
|
||||
operation: "contains".into(),
|
||||
value: "prod".into(),
|
||||
negate: false,
|
||||
}],
|
||||
window: None,
|
||||
escalation_id: None,
|
||||
},
|
||||
RoutingRule {
|
||||
id: second,
|
||||
name: "Fallback".into(),
|
||||
order: 9999,
|
||||
conditions: vec![],
|
||||
window: None,
|
||||
escalation_id: None,
|
||||
},
|
||||
];
|
||||
let before = serde_json::to_value(&doc.routes).unwrap();
|
||||
for ids in [vec![first], vec![first, first], vec![first, Uuid::new_v4()]] {
|
||||
assert!(reorder_routes(&mut doc, &ids).is_err());
|
||||
assert_eq!(serde_json::to_value(&doc.routes).unwrap(), before);
|
||||
}
|
||||
reorder_routes(&mut doc, &[second, first]).unwrap();
|
||||
assert_eq!(doc.routes[0].id, second);
|
||||
assert_eq!(doc.routes[0].order, 0);
|
||||
assert_eq!(doc.routes[1].order, 1);
|
||||
assert_eq!(doc.routes[1].conditions[0].value, "prod");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
country,date,name
|
||||
CY,2026-01-01,New Year's Day
|
||||
CY,2026-01-06,Epiphany
|
||||
CY,2026-02-23,Green Monday
|
||||
CY,2026-03-25,Greek Independence Day
|
||||
CY,2026-04-01,Cyprus National Day
|
||||
CY,2026-04-10,Orthodox Good Friday
|
||||
CY,2026-04-13,Orthodox Easter Monday
|
||||
CY,2026-05-01,Labour Day
|
||||
CY,2026-06-01,Whit Monday
|
||||
CY,2026-08-15,Assumption Day
|
||||
CY,2026-10-01,Cyprus Independence Day
|
||||
CY,2026-10-28,Ohi Day
|
||||
CY,2026-12-24,Christmas Eve
|
||||
CY,2026-12-25,Christmas Day
|
||||
CY,2026-12-26,Boxing Day
|
||||
JP,2026-01-01,New Year's Day
|
||||
JP,2026-01-12,Coming of Age Day
|
||||
JP,2026-02-11,National Foundation Day
|
||||
JP,2026-02-23,Emperor's Birthday
|
||||
JP,2026-03-20,Vernal Equinox Day
|
||||
JP,2026-04-29,Showa Day
|
||||
JP,2026-05-03,Constitution Memorial Day
|
||||
JP,2026-05-04,Greenery Day
|
||||
JP,2026-05-05,Children's Day
|
||||
JP,2026-05-06,Substitute Holiday
|
||||
JP,2026-07-20,Marine Day
|
||||
JP,2026-08-11,Mountain Day
|
||||
JP,2026-09-21,Respect for the Aged Day
|
||||
JP,2026-09-22,Citizens' Holiday
|
||||
JP,2026-09-23,Autumnal Equinox Day
|
||||
JP,2026-10-12,Sports Day
|
||||
JP,2026-11-03,Culture Day
|
||||
JP,2026-11-23,Labour Thanksgiving Day
|
||||
|
+373
@@ -0,0 +1,373 @@
|
||||
//! Small opt-in development examples, activated through the real generator service.
|
||||
use crate::{
|
||||
domain::*,
|
||||
error::Result,
|
||||
generation::{Node, Rule, Variable},
|
||||
generation_service::{self, Draft},
|
||||
models::{UserRow, WorkspaceRow},
|
||||
service,
|
||||
store::{self, Store},
|
||||
};
|
||||
use chrono::{Datelike, Duration, NaiveDate, Utc};
|
||||
use cot::db::{Model, query};
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn node(value: Value) -> Node {
|
||||
let (kind, entries) = match &value {
|
||||
Value::Array(items) => ("list", items.iter().cloned().map(node).collect()),
|
||||
Value::Object(items) => (
|
||||
"dictionary",
|
||||
items
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
let mut n = node(value.clone());
|
||||
n.key = key.clone();
|
||||
n
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Value::String(s) if Uuid::parse_str(s).is_ok() || s.contains('@') => ("person", vec![]),
|
||||
Value::String(_) => ("text", vec![]),
|
||||
Value::Number(_) => ("number", vec![]),
|
||||
_ => ("boolean", vec![]),
|
||||
};
|
||||
Node {
|
||||
id: Uuid::new_v4(),
|
||||
kind: kind.into(),
|
||||
key: String::new(),
|
||||
value: if entries.is_empty() {
|
||||
value
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
entries,
|
||||
}
|
||||
}
|
||||
fn variable(name: &str, value: Value) -> Variable {
|
||||
Variable {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.into(),
|
||||
node: node(value),
|
||||
}
|
||||
}
|
||||
fn fixed(value: impl serde::Serialize) -> Value {
|
||||
json!({"kind":"literal","value":value})
|
||||
}
|
||||
fn reference(id: Uuid) -> Value {
|
||||
json!({"kind":"variable","variable":id})
|
||||
}
|
||||
fn output(id: Uuid) -> Value {
|
||||
json!({"kind":"output","output":id})
|
||||
}
|
||||
fn item(id: Uuid, suffix: &str) -> Value {
|
||||
json!({"kind":"item","iteration":id,"suffix":suffix})
|
||||
}
|
||||
fn step(delay: u16, id: Uuid) -> Value {
|
||||
json!({"delay":fixed(delay),"recipient":output(id),"condition":"if-not-acked"})
|
||||
}
|
||||
|
||||
/// Projects keep fixed responsibility priority; one weekly rotation covers off hours.
|
||||
pub fn project_matrix(monday: NaiveDate, alice: Uuid, bob: Uuid) -> (Vec<Variable>, Vec<Rule>) {
|
||||
let projects = variable("projects", json!(["Payments", "Search", "Billing"]));
|
||||
let responsible = variable(
|
||||
"responsible",
|
||||
json!({"Payments":[alice,bob,"sam@example.com"],"Search":[bob,alice],"Billing":[alice,"sam@example.com",bob]}),
|
||||
);
|
||||
let on_call = variable("on_call", json!([alice, bob]));
|
||||
let duty = Uuid::new_v4();
|
||||
let general = Uuid::new_v4();
|
||||
let iteration = Uuid::new_v4();
|
||||
let coverage = Uuid::new_v4();
|
||||
let policy = Uuid::new_v4();
|
||||
let rules = json!([
|
||||
{"id":Uuid::new_v4(),"name":"Weekly Time off","enabled":true,"blocks":[
|
||||
{"id":duty,"kind":"rotation","fields":{"name":fixed("Time off"),"participants":reference(on_call.id),"anchor":fixed(monday),"shift_days":fixed(7),"handover":fixed("11:00")}},
|
||||
{"id":general,"kind":"escalation","fields":{"name":fixed("Time off")},"steps":[step(0,duty)]}
|
||||
]},
|
||||
{"id":Uuid::new_v4(),"name":"Project owners during working hours","enabled":true,"iterations":[{"id":iteration,"name":"Project","source":reference(projects.id)}],"blocks":[
|
||||
{"id":coverage,"kind":"priority","fields":{"name":item(iteration," · owners"),"participants":{"kind":"lookup","variable":responsible.id,"key":item(iteration,"")},"fallback":output(duty),"handover":fixed("11:00")}},
|
||||
{"id":policy,"kind":"escalation","fields":{"name":item(iteration," · daytime")},"steps":[step(0,coverage)]},
|
||||
{"id":Uuid::new_v4(),"kind":"route","fields":{"name":item(iteration," · working hours"),"escalation":output(policy),"working":fixed(true)},"conditions":[{"field":"tags","operation":"contains","value":{"kind":"item","iteration":iteration,"prefix":"project:"}}]}
|
||||
]},
|
||||
{"id":Uuid::new_v4(),"name":"Off hours and unmatched alerts","enabled":true,"blocks":[
|
||||
{"id":Uuid::new_v4(),"kind":"route","fields":{"name":fixed("Otherwise → Time off"),"escalation":output(general),"working":fixed(false)}}
|
||||
]}
|
||||
]);
|
||||
(
|
||||
vec![projects, responsible, on_call],
|
||||
serde_json::from_value(rules).expect("typed demo rules"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Main interactive demo: six services with distinct owners and five real workspace members.
|
||||
pub fn engineering_matrix(monday: NaiveDate, people: [Uuid; 5]) -> (Vec<Variable>, Vec<Rule>) {
|
||||
let [alice, bob, carol, dave, erin] = people;
|
||||
let (mut variables, rules) = project_matrix(monday, alice, bob);
|
||||
variables[0].node = node(json!([
|
||||
{"name":"Payments","responsible":[alice,bob,dave]},
|
||||
{"name":"Search","responsible":[bob,erin]},
|
||||
{"name":"Billing","responsible":[carol,alice,erin]},
|
||||
{"name":"Identity","responsible":[dave,carol]},
|
||||
{"name":"Data platform","responsible":[erin,dave,bob]},
|
||||
{"name":"Developer portal","responsible":[carol,erin]}
|
||||
]));
|
||||
variables.remove(1);
|
||||
variables[1].node = node(json!(people));
|
||||
let mut rules = serde_json::to_value(rules).expect("serializable demo rules");
|
||||
let iteration = serde_json::from_value(rules[1]["iterations"][0]["id"].clone())
|
||||
.expect("demo iteration UUID");
|
||||
for block in rules[1]["blocks"].as_array_mut().expect("demo blocks") {
|
||||
block["fields"]["name"]["path"] = json!(["name"]);
|
||||
}
|
||||
rules[1]["blocks"][0]["fields"]["participants"] = field(iteration, &["responsible"]);
|
||||
rules[1]["blocks"][2]["conditions"][0]["value"]["path"] = json!(["name"]);
|
||||
let rules = serde_json::from_value(rules).expect("nested project demo rules");
|
||||
(variables, rules)
|
||||
}
|
||||
|
||||
/// A separate tenant demonstrates daily coverage and a delayed escalation step.
|
||||
pub fn support_queues(anchor: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec<Variable>, Vec<Rule>) {
|
||||
let queues = variable("queues", json!(["Customers", "Partners"]));
|
||||
let agents = variable("agents", json!([carol, alice]));
|
||||
let lead = variable("lead", json!(alice));
|
||||
let backup = Uuid::new_v4();
|
||||
let iteration = Uuid::new_v4();
|
||||
let rotation = Uuid::new_v4();
|
||||
let policy = Uuid::new_v4();
|
||||
let rules = json!([
|
||||
{"id":Uuid::new_v4(),"name":"Support lead","enabled":true,"blocks":[{"id":backup,"kind":"rotation","fields":{"name":fixed("Support lead"),"participants":{"kind":"list","items":[reference(lead.id)]},"anchor":fixed(anchor),"shift_days":fixed(7),"handover":fixed("09:00")}}]},
|
||||
{"id":Uuid::new_v4(),"name":"Daily queue coverage","enabled":true,"iterations":[{"id":iteration,"name":"Queue","source":reference(queues.id)}],"blocks":[
|
||||
{"id":rotation,"kind":"rotation","fields":{"name":item(iteration," · daily"),"participants":reference(agents.id),"anchor":fixed(anchor),"shift_days":fixed(1),"handover":fixed("09:00")}},
|
||||
{"id":policy,"kind":"escalation","fields":{"name":item(iteration," · escalation")},"steps":[step(0,rotation),step(15,backup)]},
|
||||
{"id":Uuid::new_v4(),"kind":"route","fields":{"name":item(iteration," · 24/7"),"escalation":output(policy),"working":fixed(false)},"conditions":[{"field":"tags","operation":"contains","value":{"kind":"item","iteration":iteration,"prefix":"queue:"}}]}
|
||||
]}
|
||||
]);
|
||||
(
|
||||
vec![queues, agents, lead],
|
||||
serde_json::from_value(rules).expect("typed demo rules"),
|
||||
)
|
||||
}
|
||||
|
||||
fn field(iteration: Uuid, path: &[&str]) -> Value {
|
||||
json!({"kind":"item","iteration":iteration,"path":path})
|
||||
}
|
||||
fn lookup(variable: Uuid, key: Value, path: &[&str]) -> Value {
|
||||
json!({"kind":"lookup","variable":variable,"key":key,"path":path})
|
||||
}
|
||||
|
||||
/// Dependent iterations: each regional portfolio supplies its own services and rota settings.
|
||||
pub fn regional_platform(monday: NaiveDate, alice: Uuid, bob: Uuid) -> (Vec<Variable>, Vec<Rule>) {
|
||||
let regions = variable(
|
||||
"regions",
|
||||
json!({
|
||||
"Europe":{"handover":"11:00","shift_days":7,"responders":[alice,bob],"services":[{"name":"EU · Gateway","tag":"eu-gateway","enabled":true},{"name":"EU · Storage","tag":"eu-storage","enabled":true}]},
|
||||
"Pacific":{"handover":"05:00","shift_days":2,"responders":[bob,alice],"services":[{"name":"Pacific · Gateway","tag":"pacific-gateway","enabled":true},{"name":"Pacific · Sandbox","tag":"pacific-sandbox","enabled":false}]}
|
||||
}),
|
||||
);
|
||||
let commander = variable("incident_commander", json!(alice));
|
||||
let lead = Uuid::new_v4();
|
||||
let region = Uuid::new_v4();
|
||||
let service = Uuid::new_v4();
|
||||
let rotation = Uuid::new_v4();
|
||||
let policy = Uuid::new_v4();
|
||||
let rules = json!([
|
||||
{"id":Uuid::new_v4(),"name":"Shared incident commander","enabled":true,"blocks":[{"id":lead,"kind":"rotation","fields":{"name":fixed("Incident commander"),"participants":{"kind":"list","items":[reference(commander.id)]},"anchor":fixed(monday),"shift_days":fixed(7),"handover":fixed("11:00")}}]},
|
||||
{"id":Uuid::new_v4(),"name":"Region → enabled services","enabled":true,"iterations":[{"id":region,"name":"Region","source":reference(regions.id)},{"id":service,"name":"Service","source":field(region,&["services"])}],"when":{"left":field(service,&["enabled"]),"operation":"equals","right":fixed(true)},"blocks":[
|
||||
{"id":rotation,"kind":"rotation","fields":{"name":field(service,&["name"]),"participants":field(region,&["responders"]),"anchor":fixed(monday),"shift_days":field(region,&["shift_days"]),"handover":field(region,&["handover"])}},
|
||||
{"id":policy,"kind":"escalation","fields":{"name":field(service,&["name"])},"steps":[step(0,rotation),step(10,lead)]},
|
||||
{"id":Uuid::new_v4(),"kind":"route","fields":{"name":field(service,&["name"]),"escalation":output(policy),"working":fixed(false)},"conditions":[{"field":"tags","operation":"contains","value":field(service,&["tag"])}]}
|
||||
]}
|
||||
]);
|
||||
(
|
||||
vec![regions, commander],
|
||||
serde_json::from_value(rules).expect("regional demo rules"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Per-product tier lookup controls coverage windows, escalation delay and repetition.
|
||||
pub fn service_tiers(monday: NaiveDate, alice: Uuid, carol: Uuid) -> (Vec<Variable>, Vec<Rule>) {
|
||||
let products = variable(
|
||||
"products",
|
||||
json!({
|
||||
"Checkout":{"tier":"critical","owners":[carol,alice],"enabled":true},
|
||||
"Analytics":{"tier":"standard","owners":[alice,carol],"enabled":true},
|
||||
"Merchant portal":{"tier":"standard","owners":[carol,"lee@example.com",alice],"enabled":true},
|
||||
"Legacy export":{"tier":"retired","owners":[alice],"enabled":false}
|
||||
}),
|
||||
);
|
||||
let tiers = variable(
|
||||
"service_tiers",
|
||||
json!({"critical":{"working_only":false,"delay":5,"repeats":2,"wait":10},"standard":{"working_only":true,"delay":30,"repeats":0,"wait":0}}),
|
||||
);
|
||||
let duty = variable("shared_on_call", json!([alice, carol]));
|
||||
let rotation = Uuid::new_v4();
|
||||
let general = Uuid::new_v4();
|
||||
let product = Uuid::new_v4();
|
||||
let owners = Uuid::new_v4();
|
||||
let policy = Uuid::new_v4();
|
||||
let tier = |path: &str| lookup(tiers.id, field(product, &["tier"]), &[path]);
|
||||
let name = || json!({"kind":"key","iteration":product});
|
||||
let rules = json!([
|
||||
{"id":Uuid::new_v4(),"name":"Shared weekly safety net","enabled":true,"blocks":[
|
||||
{"id":rotation,"kind":"rotation","fields":{"name":fixed("Shared time off"),"participants":reference(duty.id),"anchor":fixed(monday),"shift_days":fixed(7),"handover":fixed("11:00")}},
|
||||
{"id":general,"kind":"escalation","fields":{"name":fixed("Shared time off")},"steps":[step(0,rotation)]}
|
||||
]},
|
||||
{"id":Uuid::new_v4(),"name":"Product → service tier","enabled":true,"iterations":[{"id":product,"name":"Product","source":reference(products.id)}],"when":{"left":field(product,&["enabled"]),"operation":"equals","right":fixed(true)},"blocks":[
|
||||
{"id":owners,"kind":"priority","fields":{"name":name(),"participants":field(product,&["owners"]),"fallback":output(rotation),"handover":fixed("11:00")}},
|
||||
{"id":policy,"kind":"escalation","fields":{"name":name(),"repeat_count":tier("repeats"),"repeat_wait":tier("wait")},"steps":[step(0,owners),{"delay":tier("delay"),"recipient":output(rotation),"condition":"if-not-acked"}]},
|
||||
{"id":Uuid::new_v4(),"kind":"route","fields":{"name":name(),"escalation":output(policy),"working":tier("working_only")},"conditions":[{"field":"tags","operation":"contains","value":name()}]}
|
||||
]},
|
||||
{"id":Uuid::new_v4(),"name":"Unmatched and off-hours safety net","enabled":true,"blocks":[{"id":Uuid::new_v4(),"kind":"route","fields":{"name":fixed("Otherwise → shared time off"),"escalation":output(general),"working":fixed(false)}}]}
|
||||
]);
|
||||
(
|
||||
vec![products, tiers, duty],
|
||||
serde_json::from_value(rules).expect("service tier demo rules"),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn seed(db: &Store) -> Result<()> {
|
||||
let mut users = vec![];
|
||||
for (username, display) in [
|
||||
("alice", "Alice Morgan"),
|
||||
("bob", "Bob Chen"),
|
||||
("carol", "Carol Davis"),
|
||||
("dave", "Dave Wilson"),
|
||||
("erin", "Erin Patel"),
|
||||
] {
|
||||
let user = match query!(UserRow,$username==username).get(&db.db).await? {
|
||||
Some(row) => store::user(row)?,
|
||||
None => {
|
||||
service::register(
|
||||
db,
|
||||
username.into(),
|
||||
display.into(),
|
||||
"meguri-demo-only".into(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
users.push(user);
|
||||
}
|
||||
for user in &users {
|
||||
db.db
|
||||
.raw_with(
|
||||
"INSERT INTO meguri__demo_pins(kind,id) VALUES('user',$1) ON CONFLICT DO NOTHING",
|
||||
&[&user.id.to_string()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let alice = users[0].id;
|
||||
let today = Utc::now().date_naive();
|
||||
let monday = today - Duration::days(i64::from(today.weekday().num_days_from_monday()));
|
||||
for (index, workspace_name, timezone, start, end) in [
|
||||
(
|
||||
1,
|
||||
"Acme Engineering",
|
||||
"Asia/Nicosia",
|
||||
"11:00:00",
|
||||
"19:00:00",
|
||||
),
|
||||
(
|
||||
2,
|
||||
"Customer Support",
|
||||
"Europe/London",
|
||||
"09:00:00",
|
||||
"17:00:00",
|
||||
),
|
||||
(
|
||||
3,
|
||||
"Regional Platform",
|
||||
"Asia/Nicosia",
|
||||
"09:00:00",
|
||||
"18:00:00",
|
||||
),
|
||||
(
|
||||
4,
|
||||
"Commerce Operations",
|
||||
"Europe/London",
|
||||
"09:00:00",
|
||||
"17:00:00",
|
||||
),
|
||||
] {
|
||||
let mut existing = None;
|
||||
for row in WorkspaceRow::objects().all(&db.db).await? {
|
||||
if row.demo_deleted_at.is_some() {
|
||||
continue;
|
||||
}
|
||||
let candidate = store::record(row)?;
|
||||
if candidate.document.name == workspace_name
|
||||
&& db.owner(candidate.id, alice).await.is_ok()
|
||||
{
|
||||
existing = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if existing.is_some() {
|
||||
continue;
|
||||
}
|
||||
let mut workspace = service::create_workspace(db, alice, workspace_name.into()).await?;
|
||||
service::set_working_interval(
|
||||
db,
|
||||
alice,
|
||||
workspace.id,
|
||||
workspace.revision,
|
||||
WorkingWindow {
|
||||
timezone: timezone.into(),
|
||||
weekdays: vec![1, 2, 3, 4, 5],
|
||||
start: start.parse().unwrap(),
|
||||
end: end.parse().unwrap(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
workspace = db.workspace(workspace.id).await?;
|
||||
let member_indices = if index == 1 {
|
||||
vec![1, 2, 3, 4]
|
||||
} else {
|
||||
vec![if index % 2 == 1 { 1 } else { 2 }]
|
||||
};
|
||||
for member_index in member_indices {
|
||||
workspace = service::set_member(
|
||||
db,
|
||||
alice,
|
||||
workspace.id,
|
||||
workspace.revision,
|
||||
users[member_index].username.clone(),
|
||||
Permission::member(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
// Fixed, documented 2026 examples; use the normal scoped CSV import path.
|
||||
let csv = include_str!("demo-holidays-2026.csv");
|
||||
if !crate::holidays::compare(csv, &workspace.document.holidays)?
|
||||
.additions
|
||||
.is_empty()
|
||||
{
|
||||
workspace =
|
||||
service::import_holidays(db, alice, workspace.id, workspace.revision, csv).await?;
|
||||
}
|
||||
let (variables, rules) = match index {
|
||||
1 => engineering_matrix(monday, std::array::from_fn(|index| users[index].id)),
|
||||
2 => support_queues(monday, alice, users[2].id),
|
||||
3 => regional_platform(monday, alice, users[1].id),
|
||||
_ => service_tiers(monday, alice, users[2].id),
|
||||
};
|
||||
let mut draft = Draft {
|
||||
revision: workspace.revision,
|
||||
variables,
|
||||
rules,
|
||||
route_position: 0,
|
||||
review_id: None,
|
||||
};
|
||||
let report = generation_service::preview(db, alice, workspace.id, draft.clone()).await?;
|
||||
draft.review_id = report["review_id"].as_str().map(str::to_owned);
|
||||
generation_service::apply(db, alice, workspace.id, draft).await?;
|
||||
db.db
|
||||
.raw_with(
|
||||
"INSERT INTO meguri__demo_pins(kind,id) VALUES('workspace',$1) ON CONFLICT DO NOTHING",
|
||||
&[&workspace.id.to_string()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Demo-only retention and a conservative database provenance boundary before migrations.
|
||||
use crate::{
|
||||
error::{Error, Result, ensure},
|
||||
store::Store,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use cot::db::{Database, DatabaseError};
|
||||
use serde::Serialize;
|
||||
use std::sync::OnceLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Retention {
|
||||
pub workspaces: i64,
|
||||
pub accounts: i64,
|
||||
pub requests: i64,
|
||||
pub events: i64,
|
||||
pub purge_grace: i64,
|
||||
}
|
||||
impl Default for Retention {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
workspaces: 86400,
|
||||
accounts: 172800,
|
||||
requests: 7200,
|
||||
events: 21600,
|
||||
purge_grace: 600,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Retention {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
fn read(name: &str, default: i64) -> Result<i64> {
|
||||
let value = std::env::var(name).map_or(Ok(default), |v| {
|
||||
v.parse().map_err(|_| {
|
||||
Error::Validation(format!("{name} must be an integer number of seconds"))
|
||||
})
|
||||
})?;
|
||||
ensure(
|
||||
(60..=2592000).contains(&value),
|
||||
format!("{name} must be 60–2592000 seconds"),
|
||||
)?;
|
||||
Ok(value)
|
||||
}
|
||||
let d = Self::default();
|
||||
Ok(Self {
|
||||
workspaces: read("MEGURI_DEMO_WORKSPACE_TTL_SECONDS", d.workspaces)?,
|
||||
accounts: read("MEGURI_DEMO_ACCOUNT_TTL_SECONDS", d.accounts)?,
|
||||
requests: read("MEGURI_DEMO_REQUEST_TTL_SECONDS", d.requests)?,
|
||||
events: read("MEGURI_DEMO_EVENT_TTL_SECONDS", d.events)?,
|
||||
purge_grace: read("MEGURI_DEMO_PURGE_GRACE_SECONDS", d.purge_grace)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Notice {
|
||||
pub isolated: bool,
|
||||
pub retention: Retention,
|
||||
}
|
||||
static NOTICE: OnceLock<Notice> = OnceLock::new();
|
||||
pub fn notice() -> Option<&'static Notice> {
|
||||
NOTICE.get()
|
||||
}
|
||||
pub fn active() -> bool {
|
||||
NOTICE.get().is_some()
|
||||
}
|
||||
pub fn install(notice: Notice) {
|
||||
let _ = NOTICE.set(notice);
|
||||
}
|
||||
|
||||
fn identifier(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
}
|
||||
fn code(error: &DatabaseError) -> Option<String> {
|
||||
if let DatabaseError::DatabaseEngineError(e) = error {
|
||||
e.as_database_error()
|
||||
.and_then(|e| e.code().map(|c| c.into_owned()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn application_url(url: &str, demo: bool) -> Result<String> {
|
||||
let mut parsed =
|
||||
reqwest::Url::parse(url).map_err(|_| Error::Validation("Invalid PostgreSQL URL".into()))?;
|
||||
ensure(parsed.scheme() == "postgresql", "Use a postgresql:// URL")?;
|
||||
let pairs: Vec<_> = parsed
|
||||
.query_pairs()
|
||||
.filter(|(k, _)| k != "application_name")
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect();
|
||||
parsed.set_query(None);
|
||||
parsed.query_pairs_mut().extend_pairs(pairs).append_pair(
|
||||
"application_name",
|
||||
&format!(
|
||||
"meguri-{}-{}",
|
||||
if demo { "demo" } else { "normal" },
|
||||
Uuid::new_v4().simple()
|
||||
),
|
||||
);
|
||||
Ok(parsed.to_string())
|
||||
}
|
||||
const CREATE_GUARD: &str = "CREATE TABLE IF NOT EXISTS public.meguri_runtime_guard(id boolean PRIMARY KEY CHECK(id),signature text NOT NULL,last_mode text NOT NULL CHECK(last_mode IN('normal','demo')),started_at timestamptz NOT NULL)";
|
||||
const PROOF: &str = "DO $$ BEGIN
|
||||
IF to_regclass('public.meguri_runtime_guard') IS NULL THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
|
||||
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;
|
||||
END $$;";
|
||||
/// A failed proof never authorizes a write. No migration or marker update happens here.
|
||||
pub async fn is_demo_database(db: &Database) -> bool {
|
||||
db.raw(PROOF).await.is_ok()
|
||||
}
|
||||
|
||||
/// Claim only a database with no user relations. A pre-existing application DB is never claimed.
|
||||
pub async fn claim_empty(db: &Database) -> Result<()> {
|
||||
db.raw(&format!("DO $$ BEGIN
|
||||
PERFORM pg_advisory_xact_lock(726482039);
|
||||
IF EXISTS(SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname NOT LIKE 'pg_%' AND n.nspname<>'information_schema' AND c.relkind IN('r','p','v','m','f','S')) THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
|
||||
{CREATE_GUARD};
|
||||
INSERT INTO public.meguri_runtime_guard VALUES(true,'meguri.runtime.v1','demo',clock_timestamp());
|
||||
END $$;")).await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn mark_normal(db: &Database) -> Result<()> {
|
||||
db.raw(&format!("DO $$ BEGIN PERFORM pg_advisory_xact_lock(726482039); {CREATE_GUARD};
|
||||
INSERT INTO public.meguri_runtime_guard VALUES(true,'meguri.runtime.v1','normal',clock_timestamp()) ON CONFLICT(id) DO UPDATE SET last_mode='normal',started_at=clock_timestamp(); END $$;")).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uses the configured DB only with positive demo provenance. Otherwise creates/reuses a
|
||||
/// separately named DB on the same server. Protected candidates and migration tables survive.
|
||||
pub async fn select_database(configured: &str, alternate: Option<&str>) -> Result<(String, bool)> {
|
||||
let configured = application_url(configured, true)?;
|
||||
let source = Database::new(&configured).await?;
|
||||
if is_demo_database(&source).await {
|
||||
return Ok((configured, false));
|
||||
}
|
||||
let base = reqwest::Url::parse(alternate.unwrap_or(&configured))
|
||||
.map_err(|_| Error::Validation("Invalid MEGURI_DEMO_DATABASE_URL".into()))?;
|
||||
let fingerprint = crate::generation::stable_id(&serde_json::json!([
|
||||
base.host_str(),
|
||||
base.port(),
|
||||
base.path()
|
||||
]));
|
||||
for attempt in 0..8 {
|
||||
let mut target = base.clone();
|
||||
let name = if alternate.is_some() && attempt == 0 {
|
||||
base.path().trim_start_matches('/').to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"meguri_demo_{}_{}",
|
||||
&fingerprint.simple().to_string()[..12],
|
||||
attempt
|
||||
)
|
||||
};
|
||||
ensure(!name.is_empty(), "A demo database name is required")?;
|
||||
target.set_path(&format!("/{name}"));
|
||||
let target = application_url(target.as_str(), true)?;
|
||||
let database = match Database::new(&target).await {
|
||||
Ok(db) => db,
|
||||
Err(e) if code(&e).as_deref() == Some("3D000") => {
|
||||
// Database creation is the only cluster-level write; no DROP DATABASE/schema/CASCADE.
|
||||
let mut admin = base.clone();
|
||||
admin.set_path("/postgres");
|
||||
let admin = Database::new(application_url(admin.as_str(), true)?).await?;
|
||||
if let Err(e) = admin
|
||||
.raw(&format!("CREATE DATABASE {}", identifier(&name)))
|
||||
.await
|
||||
&& code(&e).as_deref() != Some("42P04")
|
||||
{
|
||||
return Err(Error::Validation("Cannot create an isolated demo database. Ask the administrator to provision an empty database and set MEGURI_DEMO_DATABASE_URL. The configured database was not changed.".into()));
|
||||
}
|
||||
Database::new(&target).await?
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if is_demo_database(&database).await {
|
||||
return Ok((target, true));
|
||||
}
|
||||
if claim_empty(&database).await.is_ok() {
|
||||
return Ok((target, true));
|
||||
}
|
||||
}
|
||||
Err(Error::Validation("No safe demo database is available. Protected databases were not modified; configure a separate empty MEGURI_DEMO_DATABASE_URL.".into()))
|
||||
}
|
||||
|
||||
/// Truncate only the enumerated application state, after rechecking provenance under lock.
|
||||
/// No CASCADE: an unexpected external dependency aborts the entire reset.
|
||||
pub async fn reset(db: &Database) -> Result<()> {
|
||||
db.raw("DO $$ DECLARE tables text; BEGIN
|
||||
PERFORM pg_advisory_xact_lock(726482039);
|
||||
IF to_regclass('public.meguri_runtime_guard') IS NULL THEN RAISE EXCEPTION 'MEGURI_DEMO_PROTECTED'; END IF;
|
||||
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;
|
||||
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?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cleanup(db: &Store, now: DateTime<Utc>, ttl: &Retention) -> Result<()> {
|
||||
// The server checks provenance on every sweep too. A changed mode stops cleanup.
|
||||
ensure(
|
||||
is_demo_database(&db.db).await,
|
||||
"Cleanup refused: database is not marked as demo",
|
||||
)?;
|
||||
db.db
|
||||
.raw_with(
|
||||
CLEANUP,
|
||||
&[&now, &ttl.workspaces, &ttl.accounts, &ttl.requests],
|
||||
)
|
||||
.await?;
|
||||
db.db
|
||||
.raw_with(PURGE, &[&now, &ttl.purge_grace, &ttl.events])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn run(db: Store, ttl: Retention) {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(error) = async {
|
||||
cleanup(&db, Utc::now(), &ttl).await?;
|
||||
crate::demo::seed(&db).await
|
||||
}
|
||||
.await
|
||||
{
|
||||
tracing::error!(%error,"demo maintenance failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
const CLEANUP: &str = r#"
|
||||
WITH guard AS MATERIALIZED (SELECT id FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo' FOR UPDATE),
|
||||
expired AS (UPDATE meguri__workspaces SET demo_deleted_at=$1,revision=revision+1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND created_at<=$1-make_interval(secs=>$2::bigint::double precision) RETURNING id),
|
||||
expired_users AS (UPDATE meguri__users SET demo_deleted_at=$1 WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at IS NULL AND created_at<=$1-make_interval(secs=>$3::bigint::double precision) AND NOT EXISTS(SELECT 1 FROM meguri__demo_pins p WHERE p.kind='user' AND p.id=meguri__users.id) RETURNING id),
|
||||
sessions AS (DELETE FROM meguri__sessions WHERE EXISTS(SELECT 1 FROM guard) AND (user_id IN(SELECT id FROM expired_users) OR expires_at<=$1)),
|
||||
jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM expired)),
|
||||
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)
|
||||
INSERT INTO meguri__audit_events(workspace_id,action,details,created_at) SELECT id,'demo.requests_expired','{}',$1 FROM requests;
|
||||
"#;
|
||||
const PURGE: &str = r#"
|
||||
WITH guard AS MATERIALIZED (SELECT id FROM public.meguri_runtime_guard WHERE id AND signature='meguri.runtime.v1' AND last_mode='demo' FOR UPDATE),
|
||||
workspaces AS MATERIALIZED (SELECT id FROM meguri__workspaces WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at<=$1-make_interval(secs=>$2::bigint::double precision)),
|
||||
members AS (DELETE FROM meguri__workspace_members WHERE workspace_id IN(SELECT id FROM workspaces)),
|
||||
history AS (DELETE FROM meguri__workspace_history h WHERE workspace_id IN(SELECT id FROM workspaces) OR (EXISTS(SELECT 1 FROM guard) AND effective_at<$1-make_interval(secs=>$3::bigint::double precision) AND EXISTS(SELECT 1 FROM meguri__workspace_history newer WHERE newer.workspace_id=h.workspace_id AND newer.revision>h.revision AND newer.effective_at<$1-make_interval(secs=>$3::bigint::double precision)))),
|
||||
audit AS (DELETE FROM meguri__audit_events WHERE workspace_id IN(SELECT id FROM workspaces) OR (EXISTS(SELECT 1 FROM guard) AND created_at<$1-make_interval(secs=>$3::bigint::double precision))),
|
||||
jobs AS (DELETE FROM meguri__sync_jobs WHERE workspace_id IN(SELECT id FROM workspaces)),
|
||||
objects AS (DELETE FROM meguri__provider_objects WHERE workspace_id IN(SELECT id FROM workspaces)),
|
||||
pins AS (DELETE FROM meguri__demo_pins WHERE kind='workspace' AND id IN(SELECT id FROM workspaces)),
|
||||
removed AS (DELETE FROM meguri__workspaces WHERE id IN(SELECT id FROM workspaces)),
|
||||
users AS MATERIALIZED (SELECT id FROM meguri__users u WHERE EXISTS(SELECT 1 FROM guard) AND demo_deleted_at<=$1-make_interval(secs=>$2::bigint::double precision) AND NOT EXISTS(SELECT 1 FROM meguri__workspace_members m WHERE m.user_id=u.id AND m.workspace_id NOT IN(SELECT id FROM workspaces))),
|
||||
sessions AS (DELETE FROM meguri__sessions WHERE user_id IN(SELECT id FROM users)),
|
||||
identities AS (DELETE FROM meguri__external_identities WHERE user_id IN(SELECT id FROM users))
|
||||
DELETE FROM meguri__users WHERE id IN(SELECT id FROM users);
|
||||
"#;
|
||||
+706
@@ -0,0 +1,706 @@
|
||||
use crate::error::{Result, ensure};
|
||||
use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Permission {
|
||||
View,
|
||||
EditSchedules,
|
||||
RequestOverrides,
|
||||
ForceOverrides,
|
||||
ManageMembers,
|
||||
ManageIntegration,
|
||||
ViewAudit,
|
||||
}
|
||||
|
||||
impl Permission {
|
||||
pub fn all() -> Vec<Self> {
|
||||
vec![
|
||||
Self::View,
|
||||
Self::EditSchedules,
|
||||
Self::RequestOverrides,
|
||||
Self::ForceOverrides,
|
||||
Self::ManageMembers,
|
||||
Self::ManageIntegration,
|
||||
Self::ViewAudit,
|
||||
]
|
||||
}
|
||||
pub fn member() -> Vec<Self> {
|
||||
vec![Self::View, Self::RequestOverrides]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub country: String,
|
||||
/// None follows the browser timezone; this never changes scheduling rules.
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Member {
|
||||
pub user: User,
|
||||
pub permissions: Vec<Permission>,
|
||||
}
|
||||
|
||||
/// Scheduling metadata, not an authentication principal. Registered users retain their ID.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Person {
|
||||
pub id: Uuid,
|
||||
pub display_name: String,
|
||||
pub email: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Holiday {
|
||||
pub country: String,
|
||||
pub date: NaiveDate,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub fn default_handover() -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(11, 0, 0).unwrap()
|
||||
}
|
||||
|
||||
pub fn valid_country(country: &str) -> bool {
|
||||
// ISO 3166-1 alpha-2. No network dependency for validating scheduling state.
|
||||
const CODES: &str = "AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW";
|
||||
CODES.split_whitespace().any(|code| code == country)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceDocument {
|
||||
pub working_interval: WorkingWindow,
|
||||
pub overwrite_drift: bool,
|
||||
pub name: String,
|
||||
pub timezone: String,
|
||||
pub horizon_days: u16,
|
||||
#[serde(default)]
|
||||
pub people: Vec<Person>,
|
||||
#[serde(default)]
|
||||
pub schedules: Vec<Schedule>,
|
||||
#[serde(default)]
|
||||
pub holidays: Vec<Holiday>,
|
||||
#[serde(default)]
|
||||
pub absences: Vec<Absence>,
|
||||
#[serde(default)]
|
||||
pub requests: Vec<ReplacementRequest>,
|
||||
#[serde(default)]
|
||||
pub escalations: Vec<Escalation>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<RoutingRule>,
|
||||
#[serde(default)]
|
||||
pub integration: Integration,
|
||||
#[serde(default)]
|
||||
pub generation: crate::generation::Generation,
|
||||
}
|
||||
|
||||
impl WorkspaceDocument {
|
||||
pub fn new(name: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
working_interval: WorkingWindow::default(),
|
||||
overwrite_drift: false,
|
||||
timezone: "Europe/Bucharest".into(),
|
||||
horizon_days: 90,
|
||||
people: vec![],
|
||||
schedules: vec![],
|
||||
holidays: vec![],
|
||||
absences: vec![],
|
||||
requests: vec![],
|
||||
escalations: vec![],
|
||||
routes: vec![],
|
||||
integration: Integration::Mock,
|
||||
generation: Default::default(),
|
||||
}
|
||||
}
|
||||
pub fn is_absent(&self, user: Uuid, day: NaiveDate) -> bool {
|
||||
self.absences
|
||||
.iter()
|
||||
.any(|a| a.user_id == user && a.start <= day && day <= a.end)
|
||||
}
|
||||
pub fn schedule(&self, id: Uuid) -> Option<&Schedule> {
|
||||
self.schedules.iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
pub fn person(&self, id: Uuid) -> Option<&Person> {
|
||||
self.people.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
pub fn person_ids(&self, members: &BTreeSet<Uuid>) -> BTreeSet<Uuid> {
|
||||
members
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(self.people.iter().map(|p| p.id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn country_holiday(&self, id: Uuid, day: NaiveDate) -> bool {
|
||||
let country = self
|
||||
.person(id)
|
||||
.and_then(|p| p.country.as_deref())
|
||||
.unwrap_or("CY");
|
||||
self.holidays
|
||||
.iter()
|
||||
.any(|h| h.date == day && h.country == country)
|
||||
}
|
||||
|
||||
pub fn validate(&self, members: &BTreeSet<Uuid>) -> Result<()> {
|
||||
ensure(
|
||||
self.people.len() <= 1000,
|
||||
"A team supports at most 1,000 people",
|
||||
)?;
|
||||
let mut person_ids = BTreeSet::new();
|
||||
let mut emails = BTreeSet::new();
|
||||
for person in &self.people {
|
||||
ensure(
|
||||
person_ids.insert(person.id),
|
||||
crate::i18n::text("err_person_ids_must_be_unique_2d45f9"),
|
||||
)?;
|
||||
validate_name(&person.display_name)?;
|
||||
ensure(
|
||||
person
|
||||
.user_id
|
||||
.is_none_or(|id| id == person.id && members.contains(&id)),
|
||||
crate::i18n::text("err_choose_a_user_who_has_access_to_this_workspace_41cf15"),
|
||||
)?;
|
||||
ensure(
|
||||
person.country.as_deref().is_none_or(valid_country),
|
||||
crate::i18n::text("err_choose_a_valid_country_code_e79c72"),
|
||||
)?;
|
||||
if let Some(email) = &person.email {
|
||||
ensure(
|
||||
email.len() <= 254 && email_address::EmailAddress::is_valid(email),
|
||||
crate::i18n::text("err_enter_a_valid_email_address_1577ea"),
|
||||
)?;
|
||||
ensure(
|
||||
email == &email.trim().to_lowercase(),
|
||||
crate::i18n::text("err_email_addresses_must_be_normalized_c8f704"),
|
||||
)?;
|
||||
ensure(
|
||||
emails.insert(email),
|
||||
crate::i18n::text("err_this_email_is_already_in_the_team_directory_2692f4"),
|
||||
)?;
|
||||
}
|
||||
ensure(
|
||||
person.user_id.is_some() || person.email.is_some(),
|
||||
"A contact needs an email address",
|
||||
)?;
|
||||
}
|
||||
let members = self.person_ids(members);
|
||||
validate_name(&self.name)?;
|
||||
ensure(
|
||||
self.timezone.parse::<chrono_tz::Tz>().is_ok(),
|
||||
crate::i18n::text("err_use_a_valid_iana_timezone_b90408"),
|
||||
)?;
|
||||
ensure(
|
||||
(7..=366).contains(&self.horizon_days),
|
||||
crate::i18n::text("err_publication_horizon_must_be_7_366_days_95023e"),
|
||||
)?;
|
||||
ensure(
|
||||
self.schedules.len() <= 200
|
||||
&& self.escalations.len() <= 200
|
||||
&& self.routes.len() <= 500,
|
||||
"A team supports at most 200 schedules, 200 policies, and 500 routing rules",
|
||||
)?;
|
||||
ensure(
|
||||
self.absences.len() <= 2000
|
||||
&& self.holidays.len() <= 2000
|
||||
&& self.requests.len() <= 5000,
|
||||
crate::i18n::text("err_team_history_is_too_large_archive_old_data_befor_fc0b97"),
|
||||
)?;
|
||||
let ids: BTreeSet<_> = self.schedules.iter().map(|s| s.id).collect();
|
||||
ensure(
|
||||
ids.len() == self.schedules.len(),
|
||||
crate::i18n::text("err_schedule_ids_must_be_unique_4f12c8"),
|
||||
)?;
|
||||
for s in &self.schedules {
|
||||
validate_name(&s.name)?;
|
||||
match &s.kind {
|
||||
ScheduleKind::Rotation {
|
||||
participants,
|
||||
anchor,
|
||||
shift_days,
|
||||
} => {
|
||||
ensure(
|
||||
!participants.is_empty() && participants.len() <= 100,
|
||||
"A rotation needs 1–100 participants",
|
||||
)?;
|
||||
ensure(
|
||||
participants.iter().all(|id| members.contains(id)),
|
||||
crate::i18n::text(
|
||||
"err_choose_a_workspace_user_or_email_contact_for_eve_879191",
|
||||
),
|
||||
)?;
|
||||
ensure(
|
||||
participants.iter().collect::<BTreeSet<_>>().len() == participants.len(),
|
||||
crate::i18n::text("err_a_rotation_cannot_repeat_a_participant_adc324"),
|
||||
)?;
|
||||
ensure(
|
||||
(1..=366).contains(shift_days),
|
||||
crate::i18n::text("err_shift_length_must_be_1_366_calendar_days_b772e3"),
|
||||
)?;
|
||||
ensure(
|
||||
(2000..=2100).contains(&chrono::Datelike::year(anchor)),
|
||||
crate::i18n::text("err_anchor_year_must_be_2000_2100_5009d7"),
|
||||
)?;
|
||||
}
|
||||
ScheduleKind::Priority {
|
||||
participants,
|
||||
fallback,
|
||||
} => {
|
||||
ensure(
|
||||
!participants.is_empty() && participants.len() <= 100,
|
||||
crate::i18n::text("err_priority_coverage_needs_1_100_people_b162ae"),
|
||||
)?;
|
||||
ensure(
|
||||
participants.iter().all(|id| members.contains(id)),
|
||||
crate::i18n::text(
|
||||
"err_choose_a_workspace_user_or_email_contact_for_eve_291413",
|
||||
),
|
||||
)?;
|
||||
ensure(
|
||||
participants.iter().collect::<BTreeSet<_>>().len() == participants.len(),
|
||||
crate::i18n::text("err_priority_coverage_cannot_repeat_a_person_c524c8"),
|
||||
)?;
|
||||
ensure(
|
||||
ids.contains(fallback),
|
||||
crate::i18n::text("err_fallback_schedule_does_not_exist_0ef8b9"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut next = s.id;
|
||||
loop {
|
||||
ensure(
|
||||
seen.insert(next),
|
||||
crate::i18n::text("err_fallback_schedules_cannot_contain_a_cycle_b96e58"),
|
||||
)?;
|
||||
match &self
|
||||
.schedule(next)
|
||||
.ok_or_else(|| {
|
||||
crate::error::Error::Validation(
|
||||
crate::i18n::text("err_missing_fallback_schedule_6234b6").into(),
|
||||
)
|
||||
})?
|
||||
.kind
|
||||
{
|
||||
ScheduleKind::Priority { fallback, .. } => next = *fallback,
|
||||
ScheduleKind::Rotation { .. } => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in &self.absences {
|
||||
ensure(
|
||||
members.contains(&a.user_id),
|
||||
crate::i18n::text("err_absent_employee_must_be_a_team_member_510f37"),
|
||||
)?;
|
||||
ensure(
|
||||
a.start <= a.end && (a.end - a.start).num_days() <= 730,
|
||||
crate::i18n::text("err_absence_must_be_a_valid_range_of_at_most_two_yea_db96bb"),
|
||||
)?;
|
||||
}
|
||||
let mut holidays = BTreeSet::new();
|
||||
for holiday in &self.holidays {
|
||||
validate_name(&holiday.name)?;
|
||||
ensure(
|
||||
holiday.country == "*" || valid_country(&holiday.country),
|
||||
crate::i18n::text("err_holiday_country_must_be_an_iso_country_code_09ebeb"),
|
||||
)?;
|
||||
ensure(
|
||||
(2000..=2100).contains(&chrono::Datelike::year(&holiday.date)),
|
||||
crate::i18n::text("err_holiday_year_must_be_2000_2100_e542b1"),
|
||||
)?;
|
||||
ensure(
|
||||
holidays.insert((&holiday.country, holiday.date)),
|
||||
"A country/date can have only one holiday",
|
||||
)?;
|
||||
}
|
||||
let policy_ids: BTreeSet<_> = self.escalations.iter().map(|e| e.id).collect();
|
||||
ensure(
|
||||
policy_ids.len() == self.escalations.len(),
|
||||
crate::i18n::text("err_escalation_ids_must_be_unique_8346e7"),
|
||||
)?;
|
||||
for e in &self.escalations {
|
||||
validate_name(&e.name)?;
|
||||
ensure(
|
||||
!e.steps.is_empty() && e.steps.len() <= 20,
|
||||
crate::i18n::text("err_an_escalation_needs_1_20_steps_644fbf"),
|
||||
)?;
|
||||
let mut previous = 0;
|
||||
for (index, step) in e.steps.iter().enumerate() {
|
||||
ensure(
|
||||
step.delay_minutes >= previous && step.delay_minutes <= 10080,
|
||||
crate::i18n::text(
|
||||
"err_escalation_delays_must_be_ordered_and_at_most_on_93513d",
|
||||
),
|
||||
)?;
|
||||
previous = step.delay_minutes;
|
||||
match step.recipient {
|
||||
Recipient::Schedule { id } => ensure(
|
||||
ids.contains(&id),
|
||||
crate::i18n::message(
|
||||
"err_escalation_p0_step_p1_references_a_missing_sched_a8026c",
|
||||
&[
|
||||
("p0", (e.name).to_string()),
|
||||
("p1", (index + 1).to_string()),
|
||||
],
|
||||
),
|
||||
)?,
|
||||
Recipient::User { id } => ensure(
|
||||
members.contains(&id),
|
||||
crate::i18n::text("err_escalation_employee_must_be_a_team_member_8b2c9f"),
|
||||
)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ensure(
|
||||
e.repeat.count <= 20 && e.repeat.wait_minutes <= 10080,
|
||||
crate::i18n::text("err_invalid_escalation_repeat_settings_508f67"),
|
||||
)?;
|
||||
}
|
||||
let mut orders = BTreeSet::new();
|
||||
let mut route_ids = BTreeSet::new();
|
||||
for r in &self.routes {
|
||||
validate_name(&r.name)?;
|
||||
ensure(
|
||||
route_ids.insert(r.id) && orders.insert(r.order),
|
||||
crate::i18n::text("err_routing_ids_and_priorities_must_be_unique_1753f6"),
|
||||
)?;
|
||||
ensure(
|
||||
r.escalation_id.is_none_or(|id| policy_ids.contains(&id)),
|
||||
crate::i18n::text("err_routing_escalation_does_not_exist_0c1581"),
|
||||
)?;
|
||||
ensure(
|
||||
r.conditions.len() <= 20,
|
||||
crate::i18n::text("err_too_many_routing_conditions_7c419b"),
|
||||
)?;
|
||||
for c in &r.conditions {
|
||||
ensure(
|
||||
[
|
||||
"tags",
|
||||
"priority",
|
||||
"message",
|
||||
"description",
|
||||
"source",
|
||||
"alias",
|
||||
]
|
||||
.contains(&c.field.as_str()),
|
||||
crate::i18n::text("err_unsupported_routing_field_0b98b6"),
|
||||
)?;
|
||||
ensure(
|
||||
[
|
||||
"matches",
|
||||
"contains",
|
||||
"equals",
|
||||
"greater-than",
|
||||
"less-than",
|
||||
"starts-with",
|
||||
"ends-with",
|
||||
]
|
||||
.contains(&c.operation.as_str()),
|
||||
crate::i18n::text("err_unsupported_routing_operation_c5c07c"),
|
||||
)?;
|
||||
ensure(
|
||||
!c.value.is_empty() && c.value.len() <= 500,
|
||||
crate::i18n::text("err_routing_value_must_contain_1_500_characters_0f3a17"),
|
||||
)?;
|
||||
}
|
||||
if let Some(window) = &r.window {
|
||||
window.validate()?;
|
||||
}
|
||||
}
|
||||
if let Integration::Jsm {
|
||||
cloud_id,
|
||||
team_id,
|
||||
users,
|
||||
..
|
||||
} = &self.integration
|
||||
{
|
||||
ensure(
|
||||
Uuid::parse_str(cloud_id).is_ok() && Uuid::parse_str(team_id).is_ok(),
|
||||
crate::i18n::text("err_jsm_cloud_and_team_ids_must_be_uuids_f75b5f"),
|
||||
)?;
|
||||
ensure(
|
||||
users.keys().all(|id| members.contains(id)),
|
||||
crate::i18n::text("err_jsm_mapping_contains_a_non_member"),
|
||||
)?;
|
||||
ensure(
|
||||
users
|
||||
.values()
|
||||
.all(|v| !v.trim().is_empty() && v.len() <= 200),
|
||||
crate::i18n::text("err_jsm_account_ids_must_contain_1_200_characters_9fb430"),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_name(name: &str) -> Result<()> {
|
||||
ensure(
|
||||
!name.trim().is_empty() && name.len() <= 120,
|
||||
crate::i18n::text("err_names_must_contain_1_120_characters_8071e9"),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Schedule {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[serde(default = "default_handover")]
|
||||
pub handover: NaiveTime,
|
||||
#[serde(flatten)]
|
||||
pub kind: ScheduleKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", from = "ScheduleKindWire")]
|
||||
pub enum ScheduleKind {
|
||||
/// First available person wins; the ordered list does not rotate.
|
||||
Priority {
|
||||
participants: Vec<Uuid>,
|
||||
fallback: Uuid,
|
||||
},
|
||||
Rotation {
|
||||
participants: Vec<Uuid>,
|
||||
anchor: NaiveDate,
|
||||
shift_days: u16,
|
||||
},
|
||||
}
|
||||
|
||||
// Legacy JSON is normalized on read, including immutable history snapshots.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ScheduleKindWire {
|
||||
Priority {
|
||||
participants: Vec<Uuid>,
|
||||
fallback: Uuid,
|
||||
},
|
||||
Coverage {
|
||||
primary: Uuid,
|
||||
secondary: Uuid,
|
||||
fallback: Uuid,
|
||||
},
|
||||
Rotation {
|
||||
participants: Vec<Uuid>,
|
||||
anchor: NaiveDate,
|
||||
shift_days: u16,
|
||||
},
|
||||
}
|
||||
impl From<ScheduleKindWire> for ScheduleKind {
|
||||
fn from(value: ScheduleKindWire) -> Self {
|
||||
match value {
|
||||
ScheduleKindWire::Priority {
|
||||
participants,
|
||||
fallback,
|
||||
} => Self::Priority {
|
||||
participants,
|
||||
fallback,
|
||||
},
|
||||
ScheduleKindWire::Coverage {
|
||||
primary,
|
||||
secondary,
|
||||
fallback,
|
||||
} => Self::Priority {
|
||||
participants: vec![primary, secondary],
|
||||
fallback,
|
||||
},
|
||||
ScheduleKindWire::Rotation {
|
||||
participants,
|
||||
anchor,
|
||||
shift_days,
|
||||
} => Self::Rotation {
|
||||
participants,
|
||||
anchor,
|
||||
shift_days,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Absence {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub start: NaiveDate,
|
||||
pub end: NaiveDate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReplacementLeg {
|
||||
pub schedule_id: Uuid,
|
||||
pub start: DateTime<Utc>,
|
||||
pub end: DateTime<Utc>,
|
||||
pub from_user: Uuid,
|
||||
pub to_user: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RequestStatus {
|
||||
Pending,
|
||||
Applied,
|
||||
Rejected,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReplacementRequest {
|
||||
pub id: Uuid,
|
||||
#[serde(default)]
|
||||
pub absence_id: Option<Uuid>,
|
||||
pub requested_by: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub reason: String,
|
||||
pub legs: Vec<ReplacementLeg>,
|
||||
/// Remaining active spans; original legs remain available for history.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effective_legs: Option<Vec<ReplacementLeg>>,
|
||||
pub required_approvers: BTreeSet<Uuid>,
|
||||
pub approvals: BTreeSet<Uuid>,
|
||||
pub status: RequestStatus,
|
||||
pub applied_at: Option<DateTime<Utc>>,
|
||||
pub forced_by: Option<Uuid>,
|
||||
pub force_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl ReplacementRequest {
|
||||
pub fn effective_legs(&self) -> &[ReplacementLeg] {
|
||||
self.effective_legs.as_deref().unwrap_or(&self.legs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(tag = "provider", rename_all = "snake_case")]
|
||||
pub enum Integration {
|
||||
#[default]
|
||||
Mock,
|
||||
Jsm {
|
||||
cloud_id: String,
|
||||
team_id: String,
|
||||
advanced_escalations: bool,
|
||||
users: BTreeMap<Uuid, String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Escalation {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub steps: Vec<EscalationStep>,
|
||||
#[serde(default)]
|
||||
pub repeat: EscalationRepeat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EscalationStep {
|
||||
pub delay_minutes: u16,
|
||||
pub condition: EscalationCondition,
|
||||
pub recipient: Recipient,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum EscalationCondition {
|
||||
IfNotAcked,
|
||||
IfNotClosed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Recipient {
|
||||
Schedule { id: Uuid },
|
||||
User { id: Uuid },
|
||||
TeamAll,
|
||||
TeamRandom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct EscalationRepeat {
|
||||
pub count: u8,
|
||||
pub wait_minutes: u16,
|
||||
pub reset_recipient_states: bool,
|
||||
pub close_alert_after_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoutingRule {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub order: u16,
|
||||
pub conditions: Vec<RouteCondition>,
|
||||
pub window: Option<WorkingWindow>,
|
||||
pub escalation_id: Option<Uuid>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouteCondition {
|
||||
pub field: String,
|
||||
pub operation: String,
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub negate: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkingWindow {
|
||||
pub timezone: String,
|
||||
pub weekdays: Vec<u8>,
|
||||
pub start: NaiveTime,
|
||||
pub end: NaiveTime,
|
||||
}
|
||||
|
||||
impl Default for WorkingWindow {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timezone: "Europe/Bucharest".into(),
|
||||
weekdays: vec![1, 2, 3, 4, 5],
|
||||
start: default_handover(),
|
||||
end: NaiveTime::from_hms_opt(19, 0, 0).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkingWindow {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
ensure(
|
||||
self.timezone.parse::<chrono_tz::Tz>().is_ok(),
|
||||
crate::i18n::text("err_use_a_valid_iana_timezone_for_working_hours_b4e2c6"),
|
||||
)?;
|
||||
ensure(
|
||||
!self.weekdays.is_empty()
|
||||
&& self.weekdays.iter().all(|d| (1..=7).contains(d))
|
||||
&& self.weekdays.iter().collect::<BTreeSet<_>>().len() == self.weekdays.len(),
|
||||
crate::i18n::text("err_choose_distinct_working_days_9a4ea4"),
|
||||
)?;
|
||||
ensure(
|
||||
self.start < self.end,
|
||||
crate::i18n::text("err_working_hours_must_end_after_they_start_on_the_s_a19afe"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Assignment {
|
||||
pub schedule_id: Uuid,
|
||||
pub start: DateTime<Utc>,
|
||||
pub end: DateTime<Utc>,
|
||||
pub user_id: Uuid,
|
||||
pub reason: String,
|
||||
pub unavailable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Timeline {
|
||||
pub from: DateTime<Utc>,
|
||||
pub to: DateTime<Utc>,
|
||||
pub assignments: Vec<Assignment>,
|
||||
}
|
||||
+836
@@ -0,0 +1,836 @@
|
||||
//! Pure local-calendar scheduling. There is deliberately no database or provider access here.
|
||||
use crate::{
|
||||
domain::*,
|
||||
error::{Error, Result, ensure},
|
||||
};
|
||||
use chrono::{DateTime, Days, Duration, LocalResult, NaiveDate, TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use std::collections::BTreeSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn handover(doc: &WorkspaceDocument, day: NaiveDate) -> Result<DateTime<Utc>> {
|
||||
local_time(doc, day, default_handover())
|
||||
}
|
||||
|
||||
pub fn schedule_handover(
|
||||
doc: &WorkspaceDocument,
|
||||
id: Uuid,
|
||||
day: NaiveDate,
|
||||
) -> Result<DateTime<Utc>> {
|
||||
let schedule = doc
|
||||
.schedule(id)
|
||||
.ok_or_else(|| Error::Validation(crate::i18n::text("choose_a_schedule_0636f3").into()))?;
|
||||
local_time(doc, day, schedule.handover)
|
||||
}
|
||||
|
||||
pub fn local_time(
|
||||
doc: &WorkspaceDocument,
|
||||
day: NaiveDate,
|
||||
time: chrono::NaiveTime,
|
||||
) -> Result<DateTime<Utc>> {
|
||||
let zone: Tz = doc
|
||||
.timezone
|
||||
.parse()
|
||||
.map_err(|_| Error::Validation(crate::i18n::text("err_unknown_timezone_9d07d7").into()))?;
|
||||
match zone.from_local_datetime(&day.and_time(time)) {
|
||||
LocalResult::Single(time) => Ok(time.with_timezone(&Utc)),
|
||||
_ => Err(Error::Validation(crate::i18n::message(
|
||||
"cover_local_time_invalid",
|
||||
&[
|
||||
("date", day.format("%d-%m-%Y").to_string()),
|
||||
("time", time.format("%H:%M").to_string()),
|
||||
("zone", doc.timezone.clone()),
|
||||
],
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn duty_day(doc: &WorkspaceDocument, at: DateTime<Utc>) -> Result<NaiveDate> {
|
||||
day_at(doc, at, default_handover())
|
||||
}
|
||||
|
||||
pub fn schedule_duty_day(
|
||||
doc: &WorkspaceDocument,
|
||||
id: Uuid,
|
||||
at: DateTime<Utc>,
|
||||
) -> Result<NaiveDate> {
|
||||
let schedule = doc
|
||||
.schedule(id)
|
||||
.ok_or_else(|| Error::Validation(crate::i18n::text("choose_a_schedule_0636f3").into()))?;
|
||||
day_at(doc, at, schedule.handover)
|
||||
}
|
||||
|
||||
fn day_at(
|
||||
doc: &WorkspaceDocument,
|
||||
at: DateTime<Utc>,
|
||||
time: chrono::NaiveTime,
|
||||
) -> Result<NaiveDate> {
|
||||
let zone: Tz = doc
|
||||
.timezone
|
||||
.parse()
|
||||
.map_err(|_| Error::Validation(crate::i18n::text("err_unknown_timezone_9d07d7").into()))?;
|
||||
let local = at.with_timezone(&zone);
|
||||
let date = local.date_naive();
|
||||
if at < local_time(doc, date, time)? {
|
||||
date.pred_opt().ok_or_else(|| {
|
||||
Error::Validation(crate::i18n::text("err_date_is_out_of_range_4ecf15").into())
|
||||
})
|
||||
} else {
|
||||
Ok(date)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn publication_window(
|
||||
doc: &WorkspaceDocument,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
|
||||
let day = duty_day(doc, now)?;
|
||||
let mut from = handover(doc, day)?;
|
||||
let mut to = handover(doc, day + Duration::days(i64::from(doc.horizon_days)))?;
|
||||
for schedule in &doc.schedules {
|
||||
let day = schedule_duty_day(doc, schedule.id, now)?;
|
||||
from = from.min(schedule_handover(doc, schedule.id, day)?);
|
||||
to = to.max(schedule_handover(
|
||||
doc,
|
||||
schedule.id,
|
||||
day + Duration::days(i64::from(doc.horizon_days)),
|
||||
)?);
|
||||
}
|
||||
Ok((from, to))
|
||||
}
|
||||
|
||||
pub fn timeline(
|
||||
doc: &WorkspaceDocument,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
) -> Result<Timeline> {
|
||||
ensure(
|
||||
from < to && (to - from) <= Duration::days(367),
|
||||
crate::i18n::text("err_calendar_range_must_be_positive_and_at_most_367_23a2e6"),
|
||||
)?;
|
||||
let mut bounds = BTreeSet::from([from, to]);
|
||||
// Dependencies may hand over at different times. Split at every schedule's boundary.
|
||||
for schedule in &doc.schedules {
|
||||
let mut day = schedule_duty_day(doc, schedule.id, from)?;
|
||||
let last_day = schedule_duty_day(doc, schedule.id, to)?;
|
||||
while day <= last_day {
|
||||
let point = schedule_handover(doc, schedule.id, day)?;
|
||||
if point > from && point < to {
|
||||
bounds.insert(point);
|
||||
}
|
||||
day = day.checked_add_days(Days::new(1)).ok_or_else(|| {
|
||||
Error::Validation(crate::i18n::text("err_date_is_out_of_range_4ecf15").into())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
for request in doc
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|r| r.status == RequestStatus::Applied)
|
||||
{
|
||||
for leg in request.effective_legs() {
|
||||
for point in [leg.start, leg.end] {
|
||||
if point > from && point < to {
|
||||
bounds.insert(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let bounds: Vec<_> = bounds.into_iter().collect();
|
||||
let mut assignments = Vec::<Assignment>::new();
|
||||
for schedule in &doc.schedules {
|
||||
for pair in bounds.windows(2) {
|
||||
let day = schedule_duty_day(doc, schedule.id, pair[0])?;
|
||||
let (user_id, reason) = resolve(doc, schedule.id, pair[0], &mut BTreeSet::new())?;
|
||||
let unavailable = doc.is_absent(user_id, day);
|
||||
if let Some(last) = assignments.last_mut()
|
||||
&& last.schedule_id == schedule.id
|
||||
&& last.end == pair[0]
|
||||
&& last.user_id == user_id
|
||||
&& last.reason == reason
|
||||
&& last.unavailable == unavailable
|
||||
{
|
||||
last.end = pair[1];
|
||||
continue;
|
||||
}
|
||||
assignments.push(Assignment {
|
||||
schedule_id: schedule.id,
|
||||
start: pair[0],
|
||||
end: pair[1],
|
||||
user_id,
|
||||
reason,
|
||||
unavailable,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Timeline {
|
||||
from,
|
||||
to,
|
||||
assignments,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
doc: &WorkspaceDocument,
|
||||
id: Uuid,
|
||||
at: DateTime<Utc>,
|
||||
seen: &mut BTreeSet<Uuid>,
|
||||
) -> Result<(Uuid, String)> {
|
||||
ensure(
|
||||
seen.insert(id),
|
||||
crate::i18n::text("err_fallback_schedules_contain_a_cycle_e98539"),
|
||||
)?;
|
||||
let schedule = doc.schedule(id).ok_or_else(|| {
|
||||
Error::Validation(crate::i18n::text("err_missing_schedule_4dc6ee").into())
|
||||
})?;
|
||||
let day = schedule_duty_day(doc, id, at)?;
|
||||
let overrides: Vec<_> = doc
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|r| r.status == RequestStatus::Applied)
|
||||
.flat_map(|r| r.effective_legs().iter())
|
||||
.filter(|l| l.schedule_id == id && l.start <= at && at < l.end)
|
||||
.collect();
|
||||
ensure(
|
||||
overrides.len() <= 1,
|
||||
crate::i18n::text("err_approved_overrides_overlap_ce280c"),
|
||||
)?;
|
||||
if let Some(leg) = overrides.first() {
|
||||
return Ok((
|
||||
leg.to_user,
|
||||
crate::i18n::text("err_approved_replacement_f0feba").into(),
|
||||
));
|
||||
}
|
||||
match &schedule.kind {
|
||||
ScheduleKind::Priority {
|
||||
participants,
|
||||
fallback,
|
||||
} => {
|
||||
let global_holiday = doc
|
||||
.holidays
|
||||
.iter()
|
||||
.any(|h| h.date == day && h.country == "*");
|
||||
if !global_holiday
|
||||
&& let Some((index, user)) = participants.iter().enumerate().find(|(_, user)| {
|
||||
!doc.is_absent(**user, day) && !doc.country_holiday(**user, day)
|
||||
})
|
||||
{
|
||||
return Ok((
|
||||
*user,
|
||||
if index == 0 {
|
||||
"Primary".into()
|
||||
} else {
|
||||
crate::i18n::message(
|
||||
"err_earlier_owners_off_responsible_p0_bb11c0",
|
||||
&[("p0", (index + 1).to_string())],
|
||||
)
|
||||
},
|
||||
));
|
||||
}
|
||||
let (user, _) = resolve(doc, *fallback, at, seen)?;
|
||||
Ok((
|
||||
user,
|
||||
crate::i18n::text("err_owners_off_general_on_call_9cfec2").into(),
|
||||
))
|
||||
}
|
||||
ScheduleKind::Rotation {
|
||||
participants,
|
||||
anchor,
|
||||
shift_days,
|
||||
} => {
|
||||
ensure(
|
||||
!participants.is_empty() && *shift_days > 0,
|
||||
crate::i18n::text("err_invalid_rotation_8f771c"),
|
||||
)?;
|
||||
let shift = (day - *anchor)
|
||||
.num_days()
|
||||
.div_euclid(i64::from(*shift_days));
|
||||
let index = shift.rem_euclid(participants.len() as i64) as usize;
|
||||
Ok((participants[index], "Rotation".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace only overlapping active spans, preserving immutable original request legs.
|
||||
/// Call only after ownership validation and approval, before inserting the new override.
|
||||
pub fn supersede_overrides(doc: &mut WorkspaceDocument, legs: &[ReplacementLeg]) {
|
||||
for request in doc
|
||||
.requests
|
||||
.iter_mut()
|
||||
.filter(|r| r.status == RequestStatus::Applied)
|
||||
{
|
||||
let mut spans = request.effective_legs().to_vec();
|
||||
let mut changed = false;
|
||||
for leg in legs {
|
||||
let mut remaining = Vec::new();
|
||||
for span in spans {
|
||||
if span.schedule_id != leg.schedule_id
|
||||
|| span.end <= leg.start
|
||||
|| leg.end <= span.start
|
||||
{
|
||||
remaining.push(span);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
if span.start < leg.start {
|
||||
remaining.push(ReplacementLeg {
|
||||
end: leg.start,
|
||||
..span.clone()
|
||||
});
|
||||
}
|
||||
if leg.end < span.end {
|
||||
remaining.push(ReplacementLeg {
|
||||
start: leg.end,
|
||||
..span
|
||||
});
|
||||
}
|
||||
}
|
||||
spans = remaining;
|
||||
}
|
||||
if changed {
|
||||
request.effective_legs = Some(spans);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_legs(
|
||||
doc: &WorkspaceDocument,
|
||||
legs: &[ReplacementLeg],
|
||||
members: &BTreeSet<Uuid>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<()> {
|
||||
let members = doc.person_ids(members);
|
||||
ensure(
|
||||
!legs.is_empty() && legs.len() <= 2,
|
||||
"A replacement needs one interval; a swap needs two",
|
||||
)?;
|
||||
for (index, leg) in legs.iter().enumerate() {
|
||||
ensure(
|
||||
doc.schedule(leg.schedule_id).is_some(),
|
||||
crate::i18n::text("err_schedule_does_not_exist_637210"),
|
||||
)?;
|
||||
ensure(
|
||||
leg.start >= now && leg.start < leg.end && leg.end - leg.start <= Duration::days(90),
|
||||
crate::i18n::text("err_replacement_must_be_future_facing_and_at_most_90_c483a9"),
|
||||
)?;
|
||||
ensure(
|
||||
leg.end <= now + Duration::days(366),
|
||||
crate::i18n::text("err_replacement_must_be_within_the_next_year_2cab72"),
|
||||
)?;
|
||||
ensure(
|
||||
leg.from_user != leg.to_user
|
||||
&& members.contains(&leg.to_user)
|
||||
&& members.contains(&leg.from_user),
|
||||
crate::i18n::text("err_choose_two_different_team_members_38153c"),
|
||||
)?;
|
||||
for other in &legs[..index] {
|
||||
ensure(
|
||||
leg.schedule_id != other.schedule_id
|
||||
|| leg.end <= other.start
|
||||
|| other.end <= leg.start,
|
||||
crate::i18n::text("err_swap_intervals_overlap_740eb3"),
|
||||
)?;
|
||||
}
|
||||
let plan = timeline(doc, leg.start, leg.end)?;
|
||||
let segments: Vec<_> = plan
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == leg.schedule_id)
|
||||
.collect();
|
||||
ensure(
|
||||
!segments.is_empty() && segments.iter().all(|a| a.user_id == leg.from_user),
|
||||
crate::i18n::text("err_the_original_employee_no_longer_owns_the_entire_c36051"),
|
||||
)?;
|
||||
let mut day = schedule_duty_day(doc, leg.schedule_id, leg.start)?;
|
||||
let end_day = schedule_duty_day(doc, leg.schedule_id, leg.end - Duration::nanoseconds(1))?;
|
||||
while day <= end_day {
|
||||
ensure(
|
||||
!doc.is_absent(leg.to_user, day),
|
||||
crate::i18n::text("err_the_replacement_employee_is_absent_during_this_i_3b5cc2"),
|
||||
)?;
|
||||
day = day.succ_opt().ok_or_else(|| {
|
||||
Error::Validation(crate::i18n::text("err_date_is_out_of_range_4ecf15").into())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if legs.len() == 2 {
|
||||
ensure(
|
||||
legs[0].from_user == legs[1].to_user && legs[0].to_user == legs[1].from_user,
|
||||
crate::i18n::text("err_a_swap_must_exchange_shifts_between_the_same_two_dd579e"),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveTime;
|
||||
#[test]
|
||||
fn holidays_skip_only_people_in_the_affected_country_and_keep_rotation_running() {
|
||||
let (mut doc, people, rotation, coverage) = fixture();
|
||||
doc.people = people
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, id)| Person {
|
||||
id: *id,
|
||||
user_id: Some(*id),
|
||||
display_name: format!("Person {i}"),
|
||||
email: None,
|
||||
country: Some(if i == 0 { "AM" } else { "CY" }.into()),
|
||||
})
|
||||
.collect();
|
||||
let day = "2026-03-30".parse().unwrap();
|
||||
doc.holidays.push(Holiday {
|
||||
country: "AM".into(),
|
||||
date: day,
|
||||
name: "Armenia only".into(),
|
||||
});
|
||||
let from = handover(&doc, day).unwrap();
|
||||
let owner = |doc: &WorkspaceDocument, id| {
|
||||
timeline(doc, from, from + Duration::hours(1))
|
||||
.unwrap()
|
||||
.assignments
|
||||
.into_iter()
|
||||
.find(|a| a.schedule_id == id)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(owner(&doc, coverage).user_id, people[1]);
|
||||
assert_eq!(owner(&doc, rotation).user_id, people[2]);
|
||||
doc.holidays.push(Holiday {
|
||||
country: "CY".into(),
|
||||
date: day,
|
||||
name: "Cyprus only".into(),
|
||||
});
|
||||
assert_eq!(owner(&doc, coverage).user_id, people[2]);
|
||||
assert!(
|
||||
!owner(&doc, rotation).unavailable,
|
||||
"General on-call continues on public holidays"
|
||||
);
|
||||
doc.holidays.retain(|h| h.country == "CY");
|
||||
assert_eq!(
|
||||
owner(&doc, coverage).user_id,
|
||||
people[0],
|
||||
"A Cyprus holiday does not exclude the Armenian primary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_uses_its_own_handover_instead_of_parent_duty_day() {
|
||||
let (mut doc, people, rotation, coverage) = fixture();
|
||||
doc.timezone = "UTC".into();
|
||||
doc.schedules[0].handover = "09:00:00".parse().unwrap();
|
||||
for id in &people[..2] {
|
||||
doc.absences.push(Absence {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: *id,
|
||||
start: "2026-03-22".parse().unwrap(),
|
||||
end: "2026-03-24".parse().unwrap(),
|
||||
});
|
||||
}
|
||||
let plan = timeline(
|
||||
&doc,
|
||||
"2026-03-23T08:00:00Z".parse().unwrap(),
|
||||
"2026-03-23T12:00:00Z".parse().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for id in [rotation, coverage] {
|
||||
let shifts: Vec<_> = plan
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == id)
|
||||
.collect();
|
||||
assert_eq!(shifts.len(), 2);
|
||||
assert_eq!(shifts[0].user_id, people[0]);
|
||||
assert_eq!(shifts[1].user_id, people[1]);
|
||||
assert_eq!(
|
||||
shifts[0].end,
|
||||
"2026-03-23T09:00:00Z".parse::<DateTime<Utc>>().unwrap()
|
||||
);
|
||||
assert_eq!(shifts[0].end, shifts[1].start);
|
||||
}
|
||||
}
|
||||
fn fixture() -> (WorkspaceDocument, [Uuid; 3], Uuid, Uuid) {
|
||||
let people = [Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(3)];
|
||||
let rotation = Uuid::from_u128(10);
|
||||
let coverage = Uuid::from_u128(11);
|
||||
let mut doc = WorkspaceDocument::new("Platform".into());
|
||||
doc.schedules = vec![
|
||||
Schedule {
|
||||
handover: default_handover(),
|
||||
id: rotation,
|
||||
name: "General".into(),
|
||||
kind: ScheduleKind::Rotation {
|
||||
participants: people.to_vec(),
|
||||
anchor: "2026-03-16".parse().unwrap(),
|
||||
shift_days: 7,
|
||||
},
|
||||
},
|
||||
Schedule {
|
||||
handover: default_handover(),
|
||||
id: coverage,
|
||||
name: "Payments".into(),
|
||||
kind: ScheduleKind::Priority {
|
||||
participants: vec![people[0], people[1]],
|
||||
fallback: rotation,
|
||||
},
|
||||
},
|
||||
];
|
||||
(doc, people, rotation, coverage)
|
||||
}
|
||||
#[test]
|
||||
fn weekly_rotation_keeps_local_handover_across_dst_and_wraps_backwards() {
|
||||
let (doc, people, rotation, _) = fixture();
|
||||
let start = handover(&doc, "2026-03-23".parse().unwrap()).unwrap();
|
||||
let end = handover(&doc, "2026-03-30".parse().unwrap()).unwrap();
|
||||
assert_eq!((end - start).num_hours(), 167);
|
||||
let plan = timeline(&doc, start, end).unwrap();
|
||||
let shifts: Vec<_> = plan
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == rotation)
|
||||
.collect();
|
||||
assert_eq!(shifts.len(), 1);
|
||||
assert_eq!(shifts[0].user_id, people[1]);
|
||||
let previous = handover(&doc, "2026-03-09".parse().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
timeline(&doc, previous, previous + Duration::hours(1))
|
||||
.unwrap()
|
||||
.assignments[0]
|
||||
.user_id,
|
||||
people[2]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn coverage_uses_secondary_then_effective_fallback_and_holiday_precedence() {
|
||||
let (mut doc, people, rotation, coverage) = fixture();
|
||||
let day = "2026-03-30".parse().unwrap();
|
||||
let start = handover(&doc, day).unwrap();
|
||||
let end = start + Duration::hours(24);
|
||||
doc.absences.push(Absence {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: people[0],
|
||||
start: day,
|
||||
end: day,
|
||||
});
|
||||
let get = |doc: &WorkspaceDocument| {
|
||||
timeline(doc, start, end)
|
||||
.unwrap()
|
||||
.assignments
|
||||
.into_iter()
|
||||
.find(|a| a.schedule_id == coverage)
|
||||
.unwrap()
|
||||
.user_id
|
||||
};
|
||||
assert_eq!(get(&doc), people[1]);
|
||||
doc.absences.push(Absence {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: people[1],
|
||||
start: day,
|
||||
end: day,
|
||||
});
|
||||
assert_eq!(get(&doc), people[2]);
|
||||
doc.absences.clear();
|
||||
doc.holidays.push(Holiday {
|
||||
country: "*".into(),
|
||||
date: day,
|
||||
name: "Holiday".into(),
|
||||
});
|
||||
assert_eq!(get(&doc), people[2]);
|
||||
doc.requests.push(ReplacementRequest {
|
||||
effective_legs: None,
|
||||
id: Uuid::new_v4(),
|
||||
absence_id: None,
|
||||
requested_by: people[2],
|
||||
created_at: start - Duration::days(1),
|
||||
reason: "Swap".into(),
|
||||
legs: vec![ReplacementLeg {
|
||||
schedule_id: rotation,
|
||||
start,
|
||||
end,
|
||||
from_user: people[2],
|
||||
to_user: people[0],
|
||||
}],
|
||||
required_approvers: BTreeSet::new(),
|
||||
approvals: BTreeSet::new(),
|
||||
status: RequestStatus::Applied,
|
||||
applied_at: Some(start),
|
||||
forced_by: None,
|
||||
force_reason: None,
|
||||
});
|
||||
assert_eq!(get(&doc), people[0]);
|
||||
}
|
||||
#[test]
|
||||
fn ambiguous_handover_and_cycles_are_errors() {
|
||||
let (mut doc, people, rotation, coverage) = fixture();
|
||||
doc.schedules[0].handover = NaiveTime::from_hms_opt(3, 30, 0).unwrap();
|
||||
assert!(schedule_handover(&doc, rotation, "2026-03-29".parse().unwrap()).is_err());
|
||||
assert!(schedule_handover(&doc, rotation, "2026-10-25".parse().unwrap()).is_err());
|
||||
doc.schedules[0].kind = ScheduleKind::Priority {
|
||||
participants: vec![people[0], people[1]],
|
||||
fallback: coverage,
|
||||
};
|
||||
assert!(doc.validate(&people.into_iter().collect()).is_err());
|
||||
assert!(
|
||||
timeline(
|
||||
&doc,
|
||||
"2026-04-01T09:00:00Z".parse().unwrap(),
|
||||
"2026-04-02T09:00:00Z".parse().unwrap()
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
doc.holidays.push(Holiday {
|
||||
country: "*".into(),
|
||||
date: "2026-04-01".parse().unwrap(),
|
||||
name: "Holiday".into(),
|
||||
});
|
||||
assert!(
|
||||
timeline(
|
||||
&doc,
|
||||
"2026-04-01T09:00:00Z".parse().unwrap(),
|
||||
"2026-04-02T09:00:00Z".parse().unwrap()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(doc.schedules[0].id, rotation);
|
||||
}
|
||||
#[test]
|
||||
fn request_validation_rejects_changed_owner_and_absent_replacement() {
|
||||
let (mut doc, people, rotation, _) = fixture();
|
||||
let start = handover(&doc, "2026-03-30".parse().unwrap()).unwrap();
|
||||
let leg = ReplacementLeg {
|
||||
schedule_id: rotation,
|
||||
start,
|
||||
end: start + Duration::hours(24),
|
||||
from_user: people[0],
|
||||
to_user: people[1],
|
||||
};
|
||||
let members = people.into_iter().collect();
|
||||
assert!(
|
||||
validate_legs(
|
||||
&doc,
|
||||
std::slice::from_ref(&leg),
|
||||
&members,
|
||||
start - Duration::days(1)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let leg = ReplacementLeg {
|
||||
from_user: people[2],
|
||||
..leg
|
||||
};
|
||||
assert!(
|
||||
validate_legs(
|
||||
&doc,
|
||||
std::slice::from_ref(&leg),
|
||||
&members,
|
||||
start - Duration::days(1)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
doc.absences.push(Absence {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: people[1],
|
||||
start: "2026-03-30".parse().unwrap(),
|
||||
end: "2026-03-30".parse().unwrap(),
|
||||
});
|
||||
assert!(validate_legs(&doc, &[leg], &members, start - Duration::days(1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_rotations_cover_every_instant_once_across_zones_and_shift_lengths() {
|
||||
for zone in [
|
||||
"UTC",
|
||||
"Europe/Bucharest",
|
||||
"America/New_York",
|
||||
"Australia/Sydney",
|
||||
"Asia/Kathmandu",
|
||||
] {
|
||||
for length in [1, 2, 7, 14, 31] {
|
||||
let (mut doc, people, rotation, _) = fixture();
|
||||
doc.timezone = zone.into();
|
||||
doc.schedules.truncate(1);
|
||||
let anchor = "2026-01-01".parse().unwrap();
|
||||
doc.schedules[0].kind = ScheduleKind::Rotation {
|
||||
participants: people.to_vec(),
|
||||
anchor,
|
||||
shift_days: length,
|
||||
};
|
||||
let from = handover(&doc, anchor).unwrap();
|
||||
let to = handover(&doc, anchor + Duration::days(365)).unwrap();
|
||||
let plan = timeline(&doc, from, to).unwrap();
|
||||
assert_eq!(plan.assignments.first().unwrap().start, from);
|
||||
assert_eq!(plan.assignments.last().unwrap().end, to);
|
||||
for pair in plan.assignments.windows(2) {
|
||||
assert_eq!(
|
||||
pair[0].end, pair[1].start,
|
||||
"No gaps or overlaps: {zone}, {length}"
|
||||
);
|
||||
}
|
||||
for offset in 0..365 {
|
||||
let at = handover(&doc, anchor + Duration::days(offset)).unwrap();
|
||||
let owners: Vec<_> = plan
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.start <= at && at < a.end)
|
||||
.collect();
|
||||
assert_eq!(owners.len(), 1);
|
||||
assert_eq!(owners[0].schedule_id, rotation);
|
||||
assert_eq!(
|
||||
owners[0].user_id,
|
||||
people[(offset / i64::from(length)) as usize % 3]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autumn_shift_is_169_hours_and_half_open_handover_changes_owner_once() {
|
||||
let (mut doc, people, rotation, _) = fixture();
|
||||
let anchor = "2026-10-19".parse().unwrap();
|
||||
doc.schedules[0].kind = ScheduleKind::Rotation {
|
||||
participants: people.to_vec(),
|
||||
anchor,
|
||||
shift_days: 7,
|
||||
};
|
||||
let from = handover(&doc, anchor).unwrap();
|
||||
let boundary = handover(&doc, anchor + Duration::days(7)).unwrap();
|
||||
assert_eq!((boundary - from).num_hours(), 169);
|
||||
let plan = timeline(
|
||||
&doc,
|
||||
boundary - Duration::nanoseconds(1),
|
||||
boundary + Duration::nanoseconds(1),
|
||||
)
|
||||
.unwrap();
|
||||
let shifts: Vec<_> = plan
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == rotation)
|
||||
.collect();
|
||||
assert_eq!(shifts.len(), 2);
|
||||
assert_eq!(shifts[0].user_id, people[0]);
|
||||
assert_eq!(shifts[0].end, boundary);
|
||||
assert_eq!(shifts[1].user_id, people[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_replacement_does_not_move_base_rotation_and_overlaps_are_rejected() {
|
||||
let (mut doc, people, rotation, _) = fixture();
|
||||
let day = "2026-03-16".parse().unwrap();
|
||||
let start = handover(&doc, day).unwrap();
|
||||
let end = handover(&doc, day + Duration::days(8)).unwrap();
|
||||
let leg = ReplacementLeg {
|
||||
schedule_id: rotation,
|
||||
start: start + Duration::hours(3),
|
||||
end: start + Duration::hours(6),
|
||||
from_user: people[0],
|
||||
to_user: people[2],
|
||||
};
|
||||
let mut request = ReplacementRequest {
|
||||
effective_legs: None,
|
||||
id: Uuid::new_v4(),
|
||||
absence_id: None,
|
||||
requested_by: people[0],
|
||||
created_at: start - Duration::days(1),
|
||||
reason: "Appointment".into(),
|
||||
legs: vec![leg.clone()],
|
||||
required_approvers: BTreeSet::from([people[2]]),
|
||||
approvals: BTreeSet::new(),
|
||||
status: RequestStatus::Pending,
|
||||
applied_at: None,
|
||||
forced_by: None,
|
||||
force_reason: None,
|
||||
};
|
||||
doc.requests.push(request.clone());
|
||||
assert_eq!(
|
||||
timeline(&doc, start, end)
|
||||
.unwrap()
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == rotation)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
request.status = RequestStatus::Applied;
|
||||
doc.requests[0] = request.clone();
|
||||
let plan = timeline(&doc, start, end).unwrap();
|
||||
let shifts: Vec<_> = plan
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == rotation)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shifts.iter().map(|a| a.user_id).collect::<Vec<_>>(),
|
||||
vec![people[0], people[2], people[0], people[1]]
|
||||
);
|
||||
let next = ReplacementLeg {
|
||||
start: leg.end,
|
||||
end: leg.end + Duration::hours(1),
|
||||
..leg.clone()
|
||||
};
|
||||
assert!(
|
||||
validate_legs(&doc, &[next], &people.into_iter().collect(), start).is_ok(),
|
||||
"Adjacent replacements are legal"
|
||||
);
|
||||
assert!(validate_legs(&doc, &[leg], &people.into_iter().collect(), start).is_err());
|
||||
doc.requests.push(request);
|
||||
assert!(
|
||||
timeline(&doc, start, end).is_err(),
|
||||
"Corrupt overlapping state must never silently choose an owner"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn superseding_cover_preserves_swap_partner_history_and_legacy_requests() {
|
||||
let (mut doc, people, rotation, coverage) = fixture();
|
||||
let start = handover(&doc, "2026-03-16".parse().unwrap()).unwrap();
|
||||
let original = ReplacementLeg {
|
||||
schedule_id: rotation,
|
||||
start,
|
||||
end: start + Duration::hours(12),
|
||||
from_user: people[0],
|
||||
to_user: people[1],
|
||||
};
|
||||
let partner = ReplacementLeg {
|
||||
schedule_id: coverage,
|
||||
from_user: people[1],
|
||||
to_user: people[0],
|
||||
..original.clone()
|
||||
};
|
||||
let request = crate::absence::synthetic_request(
|
||||
Uuid::new_v4(),
|
||||
vec![original.clone(), partner.clone()],
|
||||
start,
|
||||
);
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
assert!(json.get("effective_legs").is_none());
|
||||
doc.requests.push(serde_json::from_value(json).unwrap());
|
||||
let correction = ReplacementLeg {
|
||||
start: start + Duration::hours(3),
|
||||
end: start + Duration::hours(6),
|
||||
from_user: people[1],
|
||||
to_user: people[2],
|
||||
..original.clone()
|
||||
};
|
||||
supersede_overrides(&mut doc, &[correction]);
|
||||
let spans = doc.requests[0].effective_legs();
|
||||
assert_eq!(spans.len(), 3);
|
||||
assert_eq!(
|
||||
(spans[0].start, spans[0].end),
|
||||
(start, start + Duration::hours(3))
|
||||
);
|
||||
assert_eq!(
|
||||
(spans[1].start, spans[1].end),
|
||||
(start + Duration::hours(6), original.end)
|
||||
);
|
||||
assert_eq!(spans[2].schedule_id, partner.schedule_id);
|
||||
assert_eq!(doc.requests[0].legs.len(), 2);
|
||||
assert_eq!(doc.requests[0].legs[0].end, original.end);
|
||||
supersede_overrides(&mut doc, &[original, partner]);
|
||||
assert!(doc.requests[0].effective_legs().is_empty());
|
||||
let restored: ReplacementRequest =
|
||||
serde_json::from_value(serde_json::to_value(&doc.requests[0]).unwrap()).unwrap();
|
||||
assert!(restored.effective_legs().is_empty());
|
||||
assert_eq!(restored.legs.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use cot::{
|
||||
StatusCode,
|
||||
json::Json,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
Validation(String),
|
||||
#[error("{}", crate::i18n::text("err_sign_in_to_continue_9a7843"))]
|
||||
Unauthorized,
|
||||
#[error(
|
||||
"{}",
|
||||
crate::i18n::text("err_you_do_not_have_permission_for_this_action_6306e9")
|
||||
)]
|
||||
Forbidden,
|
||||
#[error("{}", crate::i18n::text("err_resource_not_found_2d1d4c"))]
|
||||
NotFound,
|
||||
#[error("{0}")]
|
||||
Conflict(String),
|
||||
#[error("{0}")]
|
||||
Provider(String),
|
||||
#[error(transparent)]
|
||||
Database(#[from] cot::db::DatabaseError),
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("{0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> cot::Result<Response> {
|
||||
let (status, message) = match &self {
|
||||
Self::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::Provider(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
|
||||
_ => {
|
||||
tracing::error!(error = %self, "request failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
crate::i18n::text("err_an_internal_error_occurred_96c51c").into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
Json(json!({"error": message}))
|
||||
.with_status(status)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure(condition: bool, message: impl Into<String>) -> Result<()> {
|
||||
if condition {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Validation(message.into()))
|
||||
}
|
||||
}
|
||||
+1299
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,163 @@
|
||||
//! Read-only expansion and atomic activation for one workspace.
|
||||
use crate::{
|
||||
domain::*,
|
||||
engine,
|
||||
error::{Result, ensure},
|
||||
generation::{self, Issue, Rule, Variable},
|
||||
providers,
|
||||
store::{Store, WorkspaceRecord},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Draft {
|
||||
pub revision: i64,
|
||||
pub variables: Vec<Variable>,
|
||||
pub rules: Vec<Rule>,
|
||||
#[serde(default)]
|
||||
pub route_position: usize,
|
||||
#[serde(default)]
|
||||
pub review_id: Option<String>,
|
||||
}
|
||||
struct Prepared {
|
||||
report: Value,
|
||||
record: WorkspaceRecord,
|
||||
issues: Vec<Issue>,
|
||||
review: String,
|
||||
}
|
||||
async fn prepare(db: &Store, actor: Uuid, workspace: Uuid, draft: &Draft) -> Result<Prepared> {
|
||||
let mut record = db
|
||||
.prepare(workspace, draft.revision, actor, Permission::EditSchedules)
|
||||
.await?;
|
||||
let directory = db.directory(workspace).await?;
|
||||
let members = directory.iter().map(|u| u.id).collect();
|
||||
let now = Utc::now();
|
||||
let mut expanded = generation::expand(
|
||||
&record.document,
|
||||
workspace,
|
||||
&draft.variables,
|
||||
&draft.rules,
|
||||
draft.route_position,
|
||||
&record.document.working_interval,
|
||||
&members,
|
||||
now,
|
||||
);
|
||||
let used: std::collections::BTreeSet<_> = expanded
|
||||
.document
|
||||
.schedules
|
||||
.iter()
|
||||
.flat_map(|s| match &s.kind {
|
||||
ScheduleKind::Rotation { participants, .. }
|
||||
| ScheduleKind::Priority { participants, .. } => participants.clone(),
|
||||
})
|
||||
.chain(expanded.document.escalations.iter().flat_map(|e| {
|
||||
e.steps.iter().filter_map(|s| match s.recipient {
|
||||
Recipient::User { id } => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
}))
|
||||
.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()),
|
||||
});
|
||||
}
|
||||
}
|
||||
if expanded.issues.is_empty() {
|
||||
if let Err(e) = expanded.document.validate(&members) {
|
||||
expanded.issues.push(Issue {
|
||||
path: "People".into(),
|
||||
message: e.to_string(),
|
||||
..Issue::default()
|
||||
});
|
||||
}
|
||||
if let Err(e) = providers::validate_capabilities(&expanded.document, now) {
|
||||
expanded.issues.push(Issue {
|
||||
path: "Provider".into(),
|
||||
message: e.to_string(),
|
||||
..Issue::default()
|
||||
});
|
||||
}
|
||||
// Both shared and manually configured calendars must remain valid under DST.
|
||||
if let Err(e) = engine::timeline(
|
||||
&expanded.document,
|
||||
now,
|
||||
now + Duration::days(i64::from(expanded.document.horizon_days)),
|
||||
) {
|
||||
expanded.issues.push(Issue {
|
||||
path: "Calendar".into(),
|
||||
message: e.to_string(),
|
||||
..Issue::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let issues = expanded.issues;
|
||||
let cancelled = record
|
||||
.document
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.status == RequestStatus::Pending
|
||||
&& expanded
|
||||
.document
|
||||
.requests
|
||||
.iter()
|
||||
.any(|n| n.id == r.id && n.status == RequestStatus::Cancelled)
|
||||
})
|
||||
.count();
|
||||
let result=issues.is_empty().then(||{let d=&expanded.document;json!({"timezone":d.timezone,"people":d.people,"schedules":d.schedules,"escalations":d.escalations,"routes":d.routes,"managed":d.generation.managed})});
|
||||
let review = generation::stable_id(&json!([
|
||||
workspace,
|
||||
draft.revision,
|
||||
draft.variables,
|
||||
draft.rules,
|
||||
draft.route_position
|
||||
]))
|
||||
.to_string();
|
||||
let report = json!({"review_id":review,"changes":expanded.changes,"cancelled_requests":cancelled,"result":result,"trace":expanded.trace,"issues":issues});
|
||||
record.document = expanded.document;
|
||||
Ok(Prepared {
|
||||
report,
|
||||
record,
|
||||
issues,
|
||||
review,
|
||||
})
|
||||
}
|
||||
pub async fn preview(db: &Store, actor: Uuid, workspace: Uuid, draft: Draft) -> Result<Value> {
|
||||
Ok(prepare(db, actor, workspace, &draft).await?.report)
|
||||
}
|
||||
pub async fn apply(db: &Store, actor: Uuid, workspace: Uuid, draft: Draft) -> Result<Value> {
|
||||
let mut prepared = prepare(db, actor, workspace, &draft).await?;
|
||||
ensure(
|
||||
prepared.issues.is_empty(),
|
||||
prepared
|
||||
.issues
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|i| format!("{}: {}", i.path, i.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; "),
|
||||
)?;
|
||||
ensure(
|
||||
draft.review_id.as_deref() == Some(prepared.review.as_str()),
|
||||
crate::i18n::text("err_the_preview_is_stale_check_the_updated_impact_be_85cacd"),
|
||||
)?;
|
||||
db.commit(
|
||||
&mut prepared.record,
|
||||
actor,
|
||||
Permission::EditSchedules,
|
||||
"generation.applied",
|
||||
prepared.report,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(json!({"ok":true}))
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
//! Pure RFC 4180 CSV parsing and comparison. Preview never changes state.
|
||||
use crate::{
|
||||
domain::{Holiday, valid_country},
|
||||
error::{Result, ensure},
|
||||
};
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ImportPreview {
|
||||
pub additions: Vec<Holiday>,
|
||||
pub existing: usize,
|
||||
pub duplicates: usize,
|
||||
pub conflicts: Vec<String>,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn compare(input: &str, saved: &[Holiday]) -> Result<ImportPreview> {
|
||||
ensure(
|
||||
input.len() <= 262_144,
|
||||
crate::i18n::text("err_csv_is_limited_to_256_kib_e30936"),
|
||||
)?;
|
||||
let mut preview = ImportPreview {
|
||||
additions: vec![],
|
||||
existing: 0,
|
||||
duplicates: 0,
|
||||
conflicts: vec![],
|
||||
errors: vec![],
|
||||
};
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.trim(csv::Trim::All)
|
||||
.from_reader(input.trim_start_matches('\u{feff}').as_bytes());
|
||||
let headers = match reader.headers() {
|
||||
Ok(h) => h.iter().map(str::to_ascii_lowercase).collect::<Vec<_>>(),
|
||||
Err(_) => {
|
||||
preview
|
||||
.errors
|
||||
.push(crate::i18n::text("err_could_not_read_csv_headers_2bbd8d").into());
|
||||
return Ok(preview);
|
||||
}
|
||||
};
|
||||
let positions = ["country", "date", "name"].map(|key| headers.iter().position(|h| h == key));
|
||||
let [Some(country), Some(date), Some(name)] = positions else {
|
||||
preview
|
||||
.errors
|
||||
.push(crate::i18n::text("err_use_the_csv_headers_country_date_name_7d9639").into());
|
||||
return Ok(preview);
|
||||
};
|
||||
if headers.len() != 3 {
|
||||
preview.errors.push(
|
||||
crate::i18n::text("err_csv_must_have_exactly_three_columns_country_date_723e07").into(),
|
||||
);
|
||||
return Ok(preview);
|
||||
}
|
||||
let existing: BTreeMap<_, _> = saved
|
||||
.iter()
|
||||
.map(|h| ((h.country.clone(), h.date), h.name.clone()))
|
||||
.collect();
|
||||
let mut seen = BTreeMap::new();
|
||||
for (index, row) in reader.records().enumerate() {
|
||||
if index >= 2000 {
|
||||
preview
|
||||
.errors
|
||||
.push(crate::i18n::text("err_import_is_limited_to_2_000_rows_6e1c86").into());
|
||||
break;
|
||||
}
|
||||
let line = index + 2;
|
||||
let row = match row {
|
||||
Ok(row) => row,
|
||||
Err(_) => {
|
||||
preview.errors.push(crate::i18n::message(
|
||||
"err_row_line_invalid_csv_or_column_count_be7745",
|
||||
&[("line", (line).to_string())],
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let country = row[country].to_ascii_uppercase();
|
||||
let parsed = ["%Y-%m-%d", "%d-%m-%Y"].into_iter().find_map(|format| {
|
||||
NaiveDate::parse_from_str(&row[date], format)
|
||||
.ok()
|
||||
.filter(|d| {
|
||||
(2000..=2100).contains(&d.year()) && d.format(format).to_string() == row[date]
|
||||
})
|
||||
});
|
||||
if !valid_country(&country)
|
||||
|| parsed.is_none()
|
||||
|| row[name].is_empty()
|
||||
|| row[name].len() > 120
|
||||
{
|
||||
preview.errors.push(crate::i18n::message(
|
||||
"err_row_line_use_an_iso_country_code_dd_mm_yyyy_or_y_bf011e",
|
||||
&[("line", (line).to_string())],
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let holiday = Holiday {
|
||||
country,
|
||||
date: parsed.unwrap(),
|
||||
name: row[name].into(),
|
||||
};
|
||||
let key = (holiday.country.clone(), holiday.date);
|
||||
if let Some(previous) = seen.insert(key.clone(), holiday.name.clone()) {
|
||||
if previous == holiday.name {
|
||||
preview.duplicates += 1;
|
||||
} else {
|
||||
preview.errors.push(crate::i18n::message(
|
||||
"err_row_line_two_different_names_for_p0_on_p1_4c127b",
|
||||
&[
|
||||
("line", (line).to_string()),
|
||||
("p0", (holiday.country).to_string()),
|
||||
("p1", (holiday.date.format("%d-%m-%Y")).to_string()),
|
||||
],
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = existing.get(&key) {
|
||||
if name == &holiday.name {
|
||||
preview.existing += 1;
|
||||
} else {
|
||||
preview.conflicts.push(crate::i18n::message(
|
||||
"err__p0_p1_existing_p2_is_kept_p3_will_not_replace_i_053cc0",
|
||||
&[
|
||||
("p0", (holiday.country).to_string()),
|
||||
("p1", (holiday.date.format("%d-%m-%Y")).to_string()),
|
||||
("p2", (name).to_string()),
|
||||
("p3", (holiday.name).to_string()),
|
||||
],
|
||||
));
|
||||
}
|
||||
} else {
|
||||
preview.additions.push(holiday);
|
||||
}
|
||||
}
|
||||
if saved.len() + preview.additions.len() > 2000 {
|
||||
preview
|
||||
.errors
|
||||
.push(crate::i18n::text("err_the_team_would_exceed_2_000_holidays_496996").into());
|
||||
}
|
||||
preview
|
||||
.additions
|
||||
.sort_by(|a, b| (&a.country, a.date).cmp(&(&b.country, b.date)));
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn csv_handles_bom_quotes_countries_duplicates_and_preserves_existing_names() {
|
||||
let saved = vec![Holiday {
|
||||
country: "CY".into(),
|
||||
date: "2026-01-01".parse().unwrap(),
|
||||
name: "New Year".into(),
|
||||
}];
|
||||
let p=compare("\u{feff}date,name,country\r\n2026-01-01,New Year,CY\r\n2026-01-01,\"New Year, holiday\",am\r\n2026-01-01,\"New Year, holiday\",AM\r\n2026-12-25,Christmas,CY",&saved).unwrap();
|
||||
assert!(p.errors.is_empty());
|
||||
assert_eq!(p.existing, 1);
|
||||
assert_eq!(p.duplicates, 1);
|
||||
assert_eq!(p.additions.len(), 2);
|
||||
let p = compare("country,date,name\nCY,2026-01-01,Changed", &saved).unwrap();
|
||||
assert!(p.additions.is_empty());
|
||||
assert_eq!(p.conflicts.len(), 1);
|
||||
assert_eq!(saved[0].name, "New Year");
|
||||
}
|
||||
#[test]
|
||||
fn invalid_rows_and_conflicting_input_duplicates_block_import() {
|
||||
let p=compare("country,date,name\nXX,2026-01-01,Invalid country\nCY,2026-02-30,Invalid date\nAM,2026-01-01,One\nAM,2026-01-01,Two\nCY,2026-01-01",&[]).unwrap();
|
||||
assert_eq!(p.errors.len(), 4);
|
||||
assert!(
|
||||
!compare("date,label\n2026-01-01,Holiday", &[])
|
||||
.unwrap()
|
||||
.errors
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
//! One in-memory catalog shared with the browser. Domain messages retain stable English
|
||||
//! diagnostics for API compatibility; presentation uses the same catalog and parameters.
|
||||
use serde::Deserialize;
|
||||
use std::{collections::BTreeMap, sync::LazyLock};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Message {
|
||||
pub en: String,
|
||||
pub ru: Option<String>,
|
||||
pub fr: Option<String>,
|
||||
}
|
||||
pub static MESSAGES: LazyLock<BTreeMap<String, Message>> = LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("../static/locales/messages.json"))
|
||||
.expect("the bundled localization catalog must be valid")
|
||||
});
|
||||
pub fn text(key: &str) -> &'static str {
|
||||
&MESSAGES.get(key).expect("localization key must exist").en
|
||||
}
|
||||
pub fn message(key: &str, parameters: &[(&str, String)]) -> String {
|
||||
let pattern = text(key);
|
||||
let mut output = String::new();
|
||||
let mut remaining = pattern;
|
||||
while let Some(start) = remaining.find('{') {
|
||||
output.push_str(&remaining[..start]);
|
||||
let tail = &remaining[start + 1..];
|
||||
let Some(end) = tail.find('}') else {
|
||||
output.push_str(&remaining[start..]);
|
||||
return output;
|
||||
};
|
||||
let name = &tail[..end];
|
||||
if let Some((_, value)) = parameters.iter().find(|(key, _)| *key == name) {
|
||||
output.push_str(value);
|
||||
} else {
|
||||
output.push_str(&remaining[start..start + end + 2]);
|
||||
}
|
||||
remaining = &tail[end + 1..];
|
||||
}
|
||||
output.push_str(remaining);
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catalog_is_complete_and_parameters_are_not_reinterpreted() {
|
||||
for (key, value) in MESSAGES.iter() {
|
||||
assert!(!value.en.is_empty(), "{key}: English missing");
|
||||
assert!(
|
||||
value.ru.as_ref().is_some_and(|s| !s.is_empty()),
|
||||
"{key}: Russian missing"
|
||||
);
|
||||
assert!(
|
||||
value.fr.as_ref().is_some_and(|s| !s.is_empty()),
|
||||
"{key}: French missing"
|
||||
);
|
||||
}
|
||||
let output = message(
|
||||
"publication_absent_detail",
|
||||
&[
|
||||
("person", "{schedule}<script>".into()),
|
||||
("schedule", "Payments".into()),
|
||||
("start", "a".into()),
|
||||
("end", "b".into()),
|
||||
],
|
||||
);
|
||||
assert!(output.contains("{schedule}<script> is absent"));
|
||||
assert!(output.contains("“Payments”, a → b"));
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
pub mod absence;
|
||||
pub mod audit;
|
||||
pub mod configuration;
|
||||
pub mod demo;
|
||||
pub mod demo_runtime;
|
||||
pub mod domain;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod generation;
|
||||
pub mod generation_service;
|
||||
pub mod holidays;
|
||||
pub mod i18n;
|
||||
pub mod migrations;
|
||||
pub mod models;
|
||||
pub mod providers;
|
||||
pub mod service;
|
||||
pub mod store;
|
||||
pub mod web;
|
||||
pub mod worker;
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
use cot::{
|
||||
Bootstrapper,
|
||||
config::{DatabaseConfig, ProjectConfig},
|
||||
};
|
||||
use meguri::{
|
||||
demo_runtime, service,
|
||||
store::Store,
|
||||
web::{MeguriProject, enabled},
|
||||
worker,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "meguri=info,cot=warn".into()),
|
||||
)
|
||||
.init();
|
||||
let mut database_url = std::env::var("DATABASE_URL")
|
||||
.map_err(|_| "Set DATABASE_URL to a PostgreSQL connection URL; see .env.example")?;
|
||||
if !database_url.starts_with("postgresql://") {
|
||||
return Err("DATABASE_URL must use the postgresql:// scheme".into());
|
||||
}
|
||||
let role = std::env::var("MEGURI_ROLE").unwrap_or_else(|_| "all".into());
|
||||
let bind = std::env::var("MEGURI_BIND").unwrap_or_else(|_| "127.0.0.1:3000".into());
|
||||
let public_url =
|
||||
std::env::var("MEGURI_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:3000".into());
|
||||
let public = reqwest::Url::parse(&public_url)?;
|
||||
let loopback = matches!(public.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"));
|
||||
if public.scheme() != "https" && !(public.scheme() == "http" && loopback) {
|
||||
return Err("MEGURI_PUBLIC_URL must use HTTPS outside localhost".into());
|
||||
}
|
||||
if enabled("MEGURI_SEED_DEMO") && (!loopback || !bind.starts_with("127.0.0.1:")) {
|
||||
return Err("Demo accounts require a loopback bind and URL".into());
|
||||
}
|
||||
if enabled("MEGURI_SEED_DEMO") {
|
||||
if role != "all" && role != "web" {
|
||||
return Err("Demo mode runs as a single web/all process; migrate and worker roles cannot reset a database".into());
|
||||
}
|
||||
let retention = demo_runtime::Retention::from_env()?;
|
||||
let alternate = std::env::var("MEGURI_DEMO_DATABASE_URL").ok();
|
||||
let (selected, isolated) =
|
||||
demo_runtime::select_database(&database_url, alternate.as_deref()).await?;
|
||||
database_url = selected;
|
||||
let connection = cot::db::Database::new(&database_url).await?;
|
||||
demo_runtime::reset(&connection).await?;
|
||||
demo_runtime::install(demo_runtime::Notice {
|
||||
isolated,
|
||||
retention,
|
||||
});
|
||||
if isolated {
|
||||
tracing::warn!(
|
||||
"Demo mode refused the configured database. Running in an isolated demo database; the administrator must review DATABASE_URL and MEGURI_SEED_DEMO."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
database_url = demo_runtime::application_url(&database_url, false)?;
|
||||
demo_runtime::mark_normal(&cot::db::Database::new(&database_url).await?).await?;
|
||||
}
|
||||
let db = if enabled("MEGURI_SKIP_MIGRATIONS") && !demo_runtime::active() {
|
||||
Store {
|
||||
db: cot::db::Database::new(database_url.clone()).await?,
|
||||
}
|
||||
} else {
|
||||
Store::connect(&database_url).await?
|
||||
};
|
||||
if role == "migrate" {
|
||||
tracing::info!("Cot migrations completed");
|
||||
return Ok(());
|
||||
}
|
||||
if enabled("MEGURI_SEED_DEMO") {
|
||||
service::seed_demo(&db).await?;
|
||||
}
|
||||
let demo_maintenance = demo_runtime::notice()
|
||||
.map(|notice| tokio::spawn(demo_runtime::run(db.clone(), notice.retention.clone())));
|
||||
if role == "worker" {
|
||||
worker::run(db).await;
|
||||
return Ok(());
|
||||
}
|
||||
if role != "web" && role != "all" {
|
||||
return Err("MEGURI_ROLE must be all, web, worker, or migrate".into());
|
||||
}
|
||||
let worker = if role == "all" {
|
||||
Some(tokio::spawn(worker::run(db)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let config = ProjectConfig::builder()
|
||||
.debug(false)
|
||||
.database(DatabaseConfig::builder().url(database_url).build())
|
||||
.build();
|
||||
let project = Bootstrapper::new(MeguriProject)
|
||||
.with_config(config)
|
||||
.boot()
|
||||
.await?;
|
||||
tracing::info!(%bind,"Meguri is ready");
|
||||
cot::project::run(project, &bind).await?;
|
||||
if let Some(worker) = worker {
|
||||
worker.abort();
|
||||
}
|
||||
if let Some(maintenance) = demo_maintenance {
|
||||
maintenance.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
pub mod m_0001_initial;
|
||||
pub mod m_0002_constraints;
|
||||
pub mod m_0003_calendars;
|
||||
pub mod m_0004_user_preferences;
|
||||
pub mod m_0005_variables;
|
||||
pub mod m_0006_demo_lifecycle;
|
||||
pub mod m_0007_language;
|
||||
pub mod m_0008_workspaces;
|
||||
pub const MIGRATIONS: &[&cot::db::migrations::SyncDynMigration] = &[
|
||||
&m_0001_initial::Migration,
|
||||
&m_0002_constraints::Migration,
|
||||
&m_0003_calendars::Migration,
|
||||
&m_0004_user_preferences::Migration,
|
||||
&m_0005_variables::Migration,
|
||||
&m_0006_demo_lifecycle::Migration,
|
||||
&m_0007_language::Migration,
|
||||
&m_0008_workspaces::Migration,
|
||||
];
|
||||
@@ -0,0 +1,425 @@
|
||||
//! Initial Cot ORM schema. Historical model snapshots are intentionally frozen.
|
||||
use chrono::{DateTime, Utc};
|
||||
use cot::db::migrations::{Field, MigrationDependency, Operation};
|
||||
use cot::db::{Auto, DatabaseField, Identifier, model};
|
||||
#[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_0001_initial";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] = &[];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("meguri__users"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE)
|
||||
.primary_key(),
|
||||
Field::new(Identifier::new("username"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE)
|
||||
.unique(),
|
||||
Field::new(
|
||||
Identifier::new("display_name"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("password_hash"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<DateTime<Utc> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("meguri__external_identities"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE)
|
||||
.primary_key(),
|
||||
Field::new(Identifier::new("issuer"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("subject"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("user_id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("meguri__sessions"))
|
||||
.fields(&[
|
||||
Field::new(
|
||||
Identifier::new("token_hash"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE)
|
||||
.primary_key(),
|
||||
Field::new(Identifier::new("user_id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("csrf"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("expires_at"),
|
||||
<DateTime<Utc> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("meguri__tenants"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE)
|
||||
.primary_key(),
|
||||
Field::new(Identifier::new("name"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("overwrite_drift"),
|
||||
<bool as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<bool as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<DateTime<Utc> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("meguri__tenant_members"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE)
|
||||
.primary_key(),
|
||||
Field::new(
|
||||
Identifier::new("tenant_id"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("user_id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("is_owner"), <bool as DatabaseField>::TYPE)
|
||||
.set_null(<bool as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("meguri__teams"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE)
|
||||
.primary_key(),
|
||||
Field::new(
|
||||
Identifier::new("tenant_id"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("document"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("revision"), <i64 as DatabaseField>::TYPE)
|
||||
.set_null(<i64 as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<DateTime<Utc> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("meguri__team_members"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE)
|
||||
.primary_key(),
|
||||
Field::new(
|
||||
Identifier::new("tenant_id"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("team_id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("user_id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("permissions"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("meguri__team_history"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE)
|
||||
.primary_key(),
|
||||
Field::new(
|
||||
Identifier::new("tenant_id"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("team_id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("revision"), <i64 as DatabaseField>::TYPE)
|
||||
.set_null(<i64 as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("document"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("effective_at"),
|
||||
<DateTime<Utc> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("actor_id"),
|
||||
<Option<String> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("meguri__audit_events"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <Auto<i64> as DatabaseField>::TYPE)
|
||||
.set_null(<Auto<i64> as DatabaseField>::NULLABLE)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(
|
||||
Identifier::new("tenant_id"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("team_id"),
|
||||
<Option<String> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("actor_id"),
|
||||
<Option<String> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("action"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("details"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("created_at"),
|
||||
<DateTime<Utc> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("meguri__sync_jobs"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("team_id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE)
|
||||
.primary_key(),
|
||||
Field::new(
|
||||
Identifier::new("tenant_id"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("requested_revision"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<i64 as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("published_revision"),
|
||||
<Option<i64> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<i64> as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("state"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("attempts"), <i32 as DatabaseField>::TYPE)
|
||||
.set_null(<i32 as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("next_attempt"),
|
||||
<DateTime<Utc> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("last_error"),
|
||||
<Option<String> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("last_success"),
|
||||
<Option<DateTime<Utc>> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<DateTime<Utc>> as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("window_end"),
|
||||
<Option<DateTime<Utc>> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<DateTime<Utc>> as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("lease_token"),
|
||||
<Option<String> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<String> as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("lease_until"),
|
||||
<Option<DateTime<Utc>> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<Option<DateTime<Utc>> as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("meguri__provider_objects"))
|
||||
.fields(&[
|
||||
Field::new(Identifier::new("id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE)
|
||||
.primary_key(),
|
||||
Field::new(
|
||||
Identifier::new("tenant_id"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("team_id"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("object_key"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("remote_id"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(Identifier::new("content"), <String as DatabaseField>::TYPE)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("updated_at"),
|
||||
<DateTime<Utc> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<DateTime<Utc> as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(model_type = "migration", table_name = "users")]
|
||||
struct _UserRow {
|
||||
#[model(primary_key)]
|
||||
id: String,
|
||||
#[model(unique)]
|
||||
username: String,
|
||||
display_name: String,
|
||||
password_hash: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(model_type = "migration", table_name = "external_identities")]
|
||||
struct _ExternalIdentityRow {
|
||||
#[model(primary_key)]
|
||||
id: String,
|
||||
issuer: String,
|
||||
subject: String,
|
||||
user_id: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(model_type = "migration", table_name = "sessions")]
|
||||
struct _SessionRow {
|
||||
#[model(primary_key)]
|
||||
token_hash: String,
|
||||
user_id: String,
|
||||
csrf: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(model_type = "migration", table_name = "tenants")]
|
||||
struct _TenantRow {
|
||||
#[model(primary_key)]
|
||||
id: String,
|
||||
name: String,
|
||||
overwrite_drift: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(model_type = "migration", table_name = "tenant_members")]
|
||||
struct _TenantMemberRow {
|
||||
#[model(primary_key)]
|
||||
id: String,
|
||||
tenant_id: String,
|
||||
user_id: String,
|
||||
is_owner: bool,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(model_type = "migration", table_name = "teams")]
|
||||
struct _TeamRow {
|
||||
#[model(primary_key)]
|
||||
id: String,
|
||||
tenant_id: String,
|
||||
document: String,
|
||||
revision: i64,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(model_type = "migration", table_name = "team_members")]
|
||||
struct _TeamMemberRow {
|
||||
#[model(primary_key)]
|
||||
id: String,
|
||||
tenant_id: String,
|
||||
team_id: String,
|
||||
user_id: String,
|
||||
permissions: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(model_type = "migration", table_name = "team_history")]
|
||||
struct _HistoryRow {
|
||||
#[model(primary_key)]
|
||||
id: String,
|
||||
tenant_id: String,
|
||||
team_id: String,
|
||||
revision: i64,
|
||||
document: String,
|
||||
effective_at: DateTime<Utc>,
|
||||
actor_id: Option<String>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(model_type = "migration", table_name = "audit_events")]
|
||||
struct _AuditRow {
|
||||
#[model(primary_key)]
|
||||
id: Auto<i64>,
|
||||
tenant_id: String,
|
||||
team_id: Option<String>,
|
||||
actor_id: Option<String>,
|
||||
action: String,
|
||||
details: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(model_type = "migration", table_name = "sync_jobs")]
|
||||
struct _SyncJobRow {
|
||||
#[model(primary_key)]
|
||||
team_id: String,
|
||||
tenant_id: String,
|
||||
requested_revision: i64,
|
||||
published_revision: Option<i64>,
|
||||
state: String,
|
||||
attempts: i32,
|
||||
next_attempt: DateTime<Utc>,
|
||||
last_error: Option<String>,
|
||||
last_success: Option<DateTime<Utc>>,
|
||||
window_end: Option<DateTime<Utc>>,
|
||||
lease_token: Option<String>,
|
||||
lease_until: Option<DateTime<Utc>>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
#[model(model_type = "migration", table_name = "provider_objects")]
|
||||
struct _ProviderObjectRow {
|
||||
#[model(primary_key)]
|
||||
id: String,
|
||||
tenant_id: String,
|
||||
team_id: String,
|
||||
object_key: String,
|
||||
remote_id: String,
|
||||
content: String,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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_0002_constraints";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] =
|
||||
&[MigrationDependency::migration("meguri", "m_0001_initial")];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(constraints).build()];
|
||||
}
|
||||
|
||||
#[migration_op]
|
||||
async fn constraints(context: MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
let statements = [
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS tenant_member_identity ON meguri__tenant_members(tenant_id,user_id)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS team_identity ON meguri__teams(tenant_id,id)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS team_member_identity ON meguri__team_members(tenant_id,team_id,user_id)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS history_revision ON meguri__team_history(team_id,revision)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS provider_object_identity ON meguri__provider_objects(team_id,object_key)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS external_identity ON meguri__external_identities(issuer,subject)",
|
||||
"CREATE INDEX IF NOT EXISTS sync_due ON meguri__sync_jobs(next_attempt)",
|
||||
"CREATE INDEX IF NOT EXISTS audit_team ON meguri__audit_events(tenant_id,team_id,id DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS history_time ON meguri__team_history(team_id,effective_at)",
|
||||
"ALTER TABLE meguri__team_members ADD CONSTRAINT team_member_team FOREIGN KEY(tenant_id,team_id) REFERENCES meguri__teams(tenant_id,id)",
|
||||
"ALTER TABLE meguri__team_members ADD CONSTRAINT team_member_tenant FOREIGN KEY(tenant_id,user_id) REFERENCES meguri__tenant_members(tenant_id,user_id)",
|
||||
"ALTER TABLE meguri__tenant_members ADD CONSTRAINT tenant_member_tenant FOREIGN KEY(tenant_id) REFERENCES meguri__tenants(id)",
|
||||
"ALTER TABLE meguri__tenant_members ADD CONSTRAINT tenant_member_user FOREIGN KEY(user_id) REFERENCES meguri__users(id)",
|
||||
"ALTER TABLE meguri__sessions ADD CONSTRAINT session_user_fk FOREIGN KEY(user_id) REFERENCES meguri__users(id)",
|
||||
"ALTER TABLE meguri__external_identities ADD CONSTRAINT external_identity_user_fk FOREIGN KEY(user_id) REFERENCES meguri__users(id)",
|
||||
"ALTER TABLE meguri__teams ADD CONSTRAINT team_tenant FOREIGN KEY(tenant_id) REFERENCES meguri__tenants(id)",
|
||||
"ALTER TABLE meguri__team_history ADD CONSTRAINT history_team FOREIGN KEY(tenant_id,team_id) REFERENCES meguri__teams(tenant_id,id)",
|
||||
"ALTER TABLE meguri__audit_events ADD CONSTRAINT audit_team FOREIGN KEY(tenant_id,team_id) REFERENCES meguri__teams(tenant_id,id)",
|
||||
"ALTER TABLE meguri__sync_jobs ADD CONSTRAINT sync_team FOREIGN KEY(tenant_id,team_id) REFERENCES meguri__teams(tenant_id,id)",
|
||||
"ALTER TABLE meguri__provider_objects ADD CONSTRAINT object_team FOREIGN KEY(tenant_id,team_id) REFERENCES meguri__teams(tenant_id,id)",
|
||||
"ALTER TABLE meguri__teams ADD CONSTRAINT valid_document CHECK (jsonb_typeof(document::jsonb)='object')",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS jsm_destination ON meguri__teams((document::jsonb->'integration'->>'cloud_id'),(document::jsonb->'integration'->>'team_id')) WHERE document::jsonb->'integration'->>'provider'='jsm'",
|
||||
];
|
||||
// One PostgreSQL statement: a failed constraint rolls back the entire operation.
|
||||
context
|
||||
.db
|
||||
.raw(&format!("DO $$ BEGIN {}; END $$;", statements.join(";\n")))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Preserve old handovers and global holidays in both current state and historical snapshots.
|
||||
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_0003_calendars";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
|
||||
"meguri",
|
||||
"m_0002_constraints",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
|
||||
}
|
||||
|
||||
#[migration_op]
|
||||
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
context.db.raw(r#"
|
||||
DO $$ DECLARE row record; doc jsonb; BEGIN
|
||||
ALTER TABLE meguri__tenants ADD COLUMN working_interval text NOT NULL DEFAULT '{"timezone":"Europe/Bucharest","weekdays":[1,2,3,4,5],"start":"11:00:00","end":"19:00:00"}';
|
||||
ALTER TABLE meguri__tenants ADD COLUMN settings_revision bigint NOT NULL DEFAULT 1;
|
||||
FOR row IN SELECT id,document,false AS historical FROM meguri__teams UNION ALL SELECT id,document,true FROM meguri__team_history LOOP
|
||||
doc := row.document::jsonb;
|
||||
doc := jsonb_set(doc, '{schedules}', COALESCE((SELECT jsonb_agg(s || jsonb_build_object('handover',COALESCE(s->'handover',doc->'handover','"11:00:00"'::jsonb))) FROM jsonb_array_elements(doc->'schedules') s),'[]'::jsonb));
|
||||
doc := doc - 'handover';
|
||||
IF jsonb_typeof(doc->'holidays')='object' THEN
|
||||
doc := jsonb_set(doc,'{holidays}',COALESCE((SELECT jsonb_agg(jsonb_build_object('date',key,'name',value,'country','*')) FROM jsonb_each(doc->'holidays')),'[]'::jsonb));
|
||||
END IF;
|
||||
doc := jsonb_set(doc,'{routes}',COALESCE((SELECT jsonb_agg(CASE WHEN r->'window' IS NOT NULL AND r->'window'<>'null'::jsonb THEN jsonb_set(r,'{window}',r->'window'||jsonb_build_object('timezone',doc->'timezone')) ELSE r END) FROM jsonb_array_elements(doc->'routes') r),'[]'::jsonb));
|
||||
IF row.historical THEN UPDATE meguri__team_history SET document=doc::text WHERE id=row.id;
|
||||
ELSE UPDATE meguri__teams SET document=doc::text WHERE id=row.id; END IF;
|
||||
END LOOP;
|
||||
-- Existing route snapshots retain their historical times. The earliest configured
|
||||
-- interval initializes the workspace setting used for new or edited rules.
|
||||
UPDATE meguri__tenants t SET working_interval=COALESCE((SELECT r->>'window' FROM meguri__teams team CROSS JOIN LATERAL jsonb_array_elements(team.document::jsonb->'routes') r WHERE team.tenant_id=t.id AND r->'window'<>'null'::jsonb ORDER BY team.created_at,team.id LIMIT 1),t.working_interval);
|
||||
END $$;
|
||||
"#).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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_0004_user_preferences";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] =
|
||||
&[MigrationDependency::migration("meguri", "m_0003_calendars")];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
|
||||
}
|
||||
#[migration_op]
|
||||
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
context.db.raw("DO $$ BEGIN ALTER TABLE meguri__users ADD COLUMN country text NOT NULL DEFAULT 'CY'; ALTER TABLE meguri__users ADD COLUMN timezone text; END $$;").await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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_0005_variables";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
|
||||
"meguri",
|
||||
"m_0004_user_preferences",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
|
||||
}
|
||||
#[migration_op]
|
||||
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
context.db.raw("DO $$ BEGIN ALTER TABLE meguri__tenants ADD COLUMN variables text NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(variables::jsonb)='array'); ALTER TABLE meguri__tenants ADD COLUMN variables_revision bigint NOT NULL DEFAULT 1; END $$;").await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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_0006_demo_lifecycle";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] =
|
||||
&[MigrationDependency::migration("meguri", "m_0005_variables")];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
|
||||
}
|
||||
#[migration_op]
|
||||
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
context
|
||||
.db
|
||||
.raw(
|
||||
"DO $$ BEGIN
|
||||
ALTER TABLE meguri__users ADD COLUMN demo_deleted_at timestamptz;
|
||||
ALTER TABLE meguri__tenants ADD COLUMN demo_deleted_at timestamptz;
|
||||
ALTER TABLE meguri__teams ADD COLUMN demo_deleted_at timestamptz;
|
||||
CREATE TABLE meguri__demo_pins(kind text NOT NULL,id text NOT NULL,PRIMARY KEY(kind,id));
|
||||
CREATE INDEX demo_users_age ON meguri__users(created_at);
|
||||
CREATE INDEX demo_tenants_age ON meguri__tenants(created_at);
|
||||
CREATE INDEX demo_teams_age ON meguri__teams(created_at);
|
||||
END $$;",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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_0007_language";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration(
|
||||
"meguri",
|
||||
"m_0006_demo_lifecycle",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
|
||||
}
|
||||
#[migration_op]
|
||||
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
context.db.raw("ALTER TABLE meguri__users ADD COLUMN language text CHECK (language IN ('en','ru','fr'))").await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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_0008_workspaces";
|
||||
const DEPENDENCIES: &'static [MigrationDependency] =
|
||||
&[MigrationDependency::migration("meguri", "m_0007_language")];
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(upgrade).build()];
|
||||
}
|
||||
#[migration_op]
|
||||
async fn upgrade(context: MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
context.db.raw(r#"DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM meguri__tenants) OR EXISTS(SELECT 1 FROM meguri__teams) THEN
|
||||
RAISE EXCEPTION 'Workspace schema requires a fresh database. Existing tenant/team data was not changed.';
|
||||
END IF;
|
||||
DROP TABLE meguri__provider_objects,meguri__sync_jobs,meguri__audit_events,meguri__team_history,meguri__team_members,meguri__tenant_members,meguri__teams,meguri__tenants;
|
||||
CREATE TABLE meguri__workspaces(id text PRIMARY KEY,document text NOT NULL CHECK(jsonb_typeof(document::jsonb)='object'),revision bigint NOT NULL,created_at timestamptz NOT NULL,demo_deleted_at timestamptz);
|
||||
CREATE TABLE meguri__workspace_members(id text PRIMARY KEY,workspace_id text NOT NULL REFERENCES meguri__workspaces(id),user_id text NOT NULL REFERENCES meguri__users(id),permissions text NOT NULL CHECK(jsonb_typeof(permissions::jsonb)='array'),is_owner boolean NOT NULL,UNIQUE(workspace_id,user_id));
|
||||
CREATE TABLE meguri__workspace_history(id text PRIMARY KEY,workspace_id text NOT NULL REFERENCES meguri__workspaces(id),revision bigint NOT NULL,document text NOT NULL,effective_at timestamptz NOT NULL,actor_id text,UNIQUE(workspace_id,revision));
|
||||
CREATE TABLE meguri__audit_events(id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,workspace_id text NOT NULL REFERENCES meguri__workspaces(id),actor_id text,action text NOT NULL,details text NOT NULL,created_at timestamptz NOT NULL);
|
||||
CREATE TABLE meguri__sync_jobs(workspace_id text PRIMARY KEY REFERENCES meguri__workspaces(id),requested_revision bigint NOT NULL,published_revision bigint,state text NOT NULL,attempts integer NOT NULL,next_attempt timestamptz NOT NULL,last_error text,last_success timestamptz,window_end timestamptz,lease_token text,lease_until timestamptz);
|
||||
CREATE TABLE meguri__provider_objects(id text PRIMARY KEY,workspace_id text NOT NULL REFERENCES meguri__workspaces(id),object_key text NOT NULL,remote_id text NOT NULL,content text NOT NULL,updated_at timestamptz NOT NULL,UNIQUE(workspace_id,object_key));
|
||||
CREATE INDEX workspace_sync_due ON meguri__sync_jobs(next_attempt);
|
||||
CREATE INDEX workspace_audit ON meguri__audit_events(workspace_id,id DESC);
|
||||
CREATE INDEX workspace_history_time ON meguri__workspace_history(workspace_id,effective_at);
|
||||
CREATE UNIQUE INDEX workspace_jsm_destination ON meguri__workspaces((document::jsonb->'integration'->>'cloud_id'),(document::jsonb->'integration'->>'team_id')) WHERE document::jsonb->'integration'->>'provider'='jsm';
|
||||
END $$;"#).await?;
|
||||
Ok(())
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
//! Cot ORM models. UUIDs and typed documents use portable string storage in Cot 0.7.
|
||||
use chrono::{DateTime, Utc};
|
||||
use cot::db::{Auto, model};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "users")]
|
||||
pub struct UserRow {
|
||||
pub language: Option<String>,
|
||||
pub demo_deleted_at: Option<DateTime<Utc>>,
|
||||
#[model(primary_key)]
|
||||
pub id: String,
|
||||
#[model(unique)]
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub country: String,
|
||||
pub timezone: Option<String>,
|
||||
pub password_hash: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "external_identities")]
|
||||
pub struct ExternalIdentityRow {
|
||||
#[model(primary_key)]
|
||||
pub id: String,
|
||||
pub issuer: String,
|
||||
pub subject: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "sessions")]
|
||||
pub struct SessionRow {
|
||||
#[model(primary_key)]
|
||||
pub token_hash: String,
|
||||
pub user_id: String,
|
||||
pub csrf: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "workspaces")]
|
||||
pub struct WorkspaceRow {
|
||||
pub demo_deleted_at: Option<DateTime<Utc>>,
|
||||
#[model(primary_key)]
|
||||
pub id: String,
|
||||
pub document: String,
|
||||
pub revision: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "workspace_members")]
|
||||
pub struct WorkspaceMemberRow {
|
||||
#[model(primary_key)]
|
||||
pub id: String,
|
||||
pub workspace_id: String,
|
||||
pub user_id: String,
|
||||
pub permissions: String,
|
||||
pub is_owner: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "workspace_history")]
|
||||
pub struct HistoryRow {
|
||||
#[model(primary_key)]
|
||||
pub id: String,
|
||||
pub workspace_id: String,
|
||||
pub revision: i64,
|
||||
pub document: String,
|
||||
pub effective_at: DateTime<Utc>,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "audit_events")]
|
||||
pub struct AuditRow {
|
||||
#[model(primary_key)]
|
||||
pub id: Auto<i64>,
|
||||
pub workspace_id: String,
|
||||
pub actor_id: Option<String>,
|
||||
pub action: String,
|
||||
pub details: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "sync_jobs")]
|
||||
pub struct SyncJobRow {
|
||||
#[model(primary_key)]
|
||||
pub workspace_id: String,
|
||||
pub requested_revision: i64,
|
||||
pub published_revision: Option<i64>,
|
||||
pub state: String,
|
||||
pub attempts: i32,
|
||||
pub next_attempt: DateTime<Utc>,
|
||||
pub last_error: Option<String>,
|
||||
pub last_success: Option<DateTime<Utc>>,
|
||||
pub window_end: Option<DateTime<Utc>>,
|
||||
pub lease_token: Option<String>,
|
||||
pub lease_until: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[model(table_name = "provider_objects")]
|
||||
pub struct ProviderObjectRow {
|
||||
#[model(primary_key)]
|
||||
pub id: String,
|
||||
pub workspace_id: String,
|
||||
pub object_key: String,
|
||||
pub remote_id: String,
|
||||
pub content: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
use crate::{
|
||||
domain::*,
|
||||
error::{Error, Result, ensure},
|
||||
models::ProviderObjectRow,
|
||||
providers::Publication,
|
||||
store::{Store, WorkspaceRecord},
|
||||
};
|
||||
use chrono::{DateTime, Timelike, Utc};
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Jsm {
|
||||
client: Client,
|
||||
base: String,
|
||||
email: String,
|
||||
token: String,
|
||||
team: String,
|
||||
users: BTreeMap<Uuid, String>,
|
||||
directory_base: Option<String>,
|
||||
}
|
||||
|
||||
impl Jsm {
|
||||
pub fn from_environment(record: &WorkspaceRecord) -> Result<Self> {
|
||||
let Integration::Jsm {
|
||||
cloud_id,
|
||||
team_id,
|
||||
users,
|
||||
..
|
||||
} = &record.document.integration
|
||||
else {
|
||||
return Err(Error::Validation(
|
||||
crate::i18n::text("err_jsm_is_not_configured_4e6594").into(),
|
||||
));
|
||||
};
|
||||
let prefix = format!(
|
||||
"MEGURI_JSM_{}",
|
||||
record.id.simple().to_string().to_uppercase()
|
||||
);
|
||||
let email = std::env::var(format!("{prefix}_EMAIL")).map_err(|_| {
|
||||
Error::Provider(crate::i18n::message(
|
||||
"err_missing_prefix_email_deployment_secret_6e5753",
|
||||
&[("prefix", (prefix).to_string())],
|
||||
))
|
||||
})?;
|
||||
let token = std::env::var(format!("{prefix}_TOKEN")).map_err(|_| {
|
||||
Error::Provider(crate::i18n::message(
|
||||
"err_missing_prefix_token_deployment_secret_1f0352",
|
||||
&[("prefix", (prefix).to_string())],
|
||||
))
|
||||
})?;
|
||||
Ok(Self::new(
|
||||
format!("https://api.atlassian.com/jsm/ops/api/{cloud_id}/v1"),
|
||||
email,
|
||||
token,
|
||||
team_id.clone(),
|
||||
users.clone(),
|
||||
)?
|
||||
.with_directory_base(format!(
|
||||
"https://api.atlassian.com/ex/jira/{cloud_id}/rest/api/3"
|
||||
)))
|
||||
}
|
||||
pub fn new(
|
||||
base: String,
|
||||
email: String,
|
||||
token: String,
|
||||
team: String,
|
||||
users: BTreeMap<Uuid, String>,
|
||||
) -> Result<Self> {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|_| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_could_not_initialize_jsm_client_09bad0").into(),
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
client,
|
||||
base,
|
||||
email,
|
||||
token,
|
||||
team,
|
||||
users,
|
||||
directory_base: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Explicit injection for the fake HTTP server; production always uses Atlassian's host.
|
||||
pub fn with_directory_base(mut self, base: String) -> Self {
|
||||
self.directory_base = Some(base);
|
||||
self
|
||||
}
|
||||
|
||||
async fn resolve_email(&self, email: &str) -> Result<String> {
|
||||
let base = self.directory_base.as_ref().ok_or_else(|| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_configure_the_atlassian_directory_or_provide_acc_266b32")
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let mut matches = BTreeSet::new();
|
||||
for offset in (0..1000).step_by(100) {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{base}/user/search"))
|
||||
.basic_auth(&self.email, Some(&self.token))
|
||||
.query(&[
|
||||
("query", email.to_owned()),
|
||||
("startAt", offset.to_string()),
|
||||
("maxResults", "100".into()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_atlassian_user_lookup_could_not_connect_6f312b")
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::Provider(crate::i18n::message(
|
||||
"err_atlassian_user_lookup_returned_http_p0_grant_bro_a0f887",
|
||||
&[("p0", (response.status().as_u16()).to_string())],
|
||||
)));
|
||||
}
|
||||
let users: Vec<Value> = response.json().await.map_err(|_| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_invalid_atlassian_directory_response_1c1889").into(),
|
||||
)
|
||||
})?;
|
||||
for user in &users {
|
||||
if user["active"].as_bool() == Some(true)
|
||||
&& user["emailAddress"]
|
||||
.as_str()
|
||||
.is_some_and(|e| e.trim().eq_ignore_ascii_case(email))
|
||||
&& let Some(id) = user["accountId"].as_str().filter(|id| !id.is_empty())
|
||||
{
|
||||
matches.insert(id.to_owned());
|
||||
}
|
||||
}
|
||||
if users.len() < 100 {
|
||||
break;
|
||||
}
|
||||
if offset == 900 {
|
||||
return Err(Error::Provider(
|
||||
crate::i18n::text(
|
||||
"err_atlassian_search_limit_reached_provide_an_explic_d1fe8a",
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if matches.len() != 1 {
|
||||
return Err(Error::Provider(crate::i18n::message(
|
||||
"err_could_not_find_one_active_atlassian_account_with_39e9a9",
|
||||
&[("email", (email).to_string())],
|
||||
)));
|
||||
}
|
||||
Ok(matches.into_iter().next().unwrap())
|
||||
}
|
||||
|
||||
async fn resolve_people(&self, record: &WorkspaceRecord, plan: &Publication) -> Result<Self> {
|
||||
let mut resolved = self.clone();
|
||||
let mut ids: BTreeSet<_> = plan
|
||||
.schedules
|
||||
.iter()
|
||||
.flat_map(|s| s.periods.iter().map(|p| p.user_id))
|
||||
.collect();
|
||||
ids.extend(
|
||||
plan.escalations
|
||||
.iter()
|
||||
.flat_map(|e| e.steps.iter())
|
||||
.filter_map(|s| {
|
||||
if let Recipient::User { id } = s.recipient {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
);
|
||||
for id in ids {
|
||||
if resolved.users.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
let email = record
|
||||
.document
|
||||
.person(id)
|
||||
.and_then(|p| p.email.as_deref())
|
||||
.ok_or_else(|| {
|
||||
Error::Provider(crate::i18n::message(
|
||||
"err_person_id_needs_an_email_or_an_atlassian_account_360521",
|
||||
&[("id", (id).to_string())],
|
||||
))
|
||||
})?;
|
||||
resolved.users.insert(id, self.resolve_email(email).await?);
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
async fn request(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&Value>,
|
||||
) -> Result<Option<Value>> {
|
||||
let mut req = self
|
||||
.client
|
||||
.request(method.clone(), format!("{}{path}", self.base))
|
||||
.basic_auth(&self.email, Some(&self.token))
|
||||
.header("Accept", "application/json");
|
||||
if let Some(body) = body {
|
||||
req = req.json(body);
|
||||
}
|
||||
let response = req.send().await.map_err(|_| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_jsm_request_timed_out_or_could_not_connect_5ce428").into(),
|
||||
)
|
||||
})?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::Provider(crate::i18n::message(
|
||||
"err_jsm_returned_http_p0_for_p1_check_credentials_pl_b31caf",
|
||||
&[
|
||||
("p0", (response.status().as_u16()).to_string()),
|
||||
("p1", (method).to_string()),
|
||||
],
|
||||
)));
|
||||
}
|
||||
if response.status() == StatusCode::NO_CONTENT {
|
||||
return Ok(Some(Value::Null));
|
||||
}
|
||||
Ok(Some(response.json().await.map_err(|_| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_jsm_returned_an_invalid_json_response_af8daf").into(),
|
||||
)
|
||||
})?))
|
||||
}
|
||||
async fn list(&self, path: &str) -> Result<Vec<Value>> {
|
||||
let mut all = vec![];
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
let sep = if path.contains('?') { '&' } else { '?' };
|
||||
let page = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("{path}{sep}offset={offset}&size=100"),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_jsm_collection_was_not_found_60d46c").into(),
|
||||
)
|
||||
})?;
|
||||
let values = page
|
||||
.get("values")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
Error::Provider(crate::i18n::text("err_jsm_collection_has_no_values").into())
|
||||
})?;
|
||||
let count = values.len();
|
||||
all.extend(values.iter().cloned());
|
||||
if page
|
||||
.pointer("/links/next")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(str::is_empty)
|
||||
&& count < 100
|
||||
{
|
||||
break;
|
||||
}
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
offset += count;
|
||||
ensure(
|
||||
offset <= 20000,
|
||||
crate::i18n::text("err_jsm_collection_exceeds_the_supported_pagination_a1136e"),
|
||||
)?;
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
fn mapped(&self, id: Uuid) -> Result<String> {
|
||||
self.users.get(&id).cloned().ok_or_else(|| {
|
||||
Error::Validation(crate::i18n::message(
|
||||
"err_employee_id_has_no_jsm_account_mapping_7b6db1",
|
||||
&[("id", (id).to_string())],
|
||||
))
|
||||
})
|
||||
}
|
||||
fn collection(&self, kind: &str) -> String {
|
||||
match kind {
|
||||
"schedule" => "/schedules".into(),
|
||||
"escalation" => format!("/teams/{}/escalations", self.team),
|
||||
_ => format!("/teams/{}/routing-rules", self.team),
|
||||
}
|
||||
}
|
||||
async fn read(&self, kind: &str, id: &str) -> Result<Option<Value>> {
|
||||
let Some(mut object) = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("{}/{id}", self.collection(kind)),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if kind == "schedule" {
|
||||
object["rotations"] =
|
||||
Value::Array(self.list(&format!("/schedules/{id}/rotations")).await?);
|
||||
}
|
||||
Ok(Some(object))
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn synchronize(
|
||||
&self,
|
||||
db: &Store,
|
||||
record: &WorkspaceRecord,
|
||||
kind: &str,
|
||||
key: &str,
|
||||
desired: &Value,
|
||||
previous: Option<&ProviderObjectRow>,
|
||||
overwrite: bool,
|
||||
) -> Result<String> {
|
||||
let name = desired["name"].as_str().unwrap_or_default();
|
||||
let existing_id = if let Some(previous) = previous {
|
||||
Some(previous.remote_id.clone())
|
||||
} else {
|
||||
let found: Vec<_> = self
|
||||
.list(&self.collection(kind))
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|v| v["name"].as_str() == Some(name))
|
||||
.collect();
|
||||
ensure(
|
||||
found.len() <= 1,
|
||||
crate::i18n::text("err_multiple_jsm_resources_have_the_same_meguri_owne_889887"),
|
||||
)?;
|
||||
found
|
||||
.first()
|
||||
.and_then(|v| v["id"].as_str())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let current = if let Some(id) = &existing_id {
|
||||
self.read(kind, id).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let expected = previous
|
||||
.map(|p| serde_json::from_str::<Value>(&p.content))
|
||||
.transpose()?;
|
||||
if previous.is_some() && current.is_none() && !overwrite {
|
||||
return Err(Error::Conflict(crate::i18n::message(
|
||||
"err_jsm_kind_was_deleted_externally_publication_paus_88543d",
|
||||
&[("kind", (kind).to_string())],
|
||||
)));
|
||||
}
|
||||
let 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 {
|
||||
return Err(Error::Conflict(crate::i18n::message(
|
||||
"err_jsm_kind_changed_outside_meguri_publication_paus_ac8220",
|
||||
&[("kind", (kind).to_string())],
|
||||
)));
|
||||
}
|
||||
}
|
||||
if kind == "schedule"
|
||||
&& 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,
|
||||
)
|
||||
.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(),
|
||||
)
|
||||
})?;
|
||||
id
|
||||
} else {
|
||||
let response = self
|
||||
.request(Method::POST, &self.collection(kind), Some(desired))
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_jsm_create_endpoint_was_not_found_1be86a").into(),
|
||||
)
|
||||
})?;
|
||||
response["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_jsm_created_resource_without_an_id").into(),
|
||||
)
|
||||
})?
|
||||
.to_string()
|
||||
};
|
||||
ensure(
|
||||
safe_id(&id),
|
||||
crate::i18n::text("err_unexpected_jsm_resource_identifier_dedd70"),
|
||||
)?;
|
||||
let actual = self.read(kind, &id).await?.ok_or_else(|| {
|
||||
Error::Provider(
|
||||
crate::i18n::text("err_jsm_resource_is_missing_after_publication_3448ad").into(),
|
||||
)
|
||||
})?;
|
||||
if !equivalent(&actual, desired) {
|
||||
return Err(Error::Provider(crate::i18n::message(
|
||||
"err_jsm_kind_read_back_does_not_match_the_requested_63440e",
|
||||
&[("kind", (kind).to_string())],
|
||||
)));
|
||||
}
|
||||
db.checkpoint(record.id, key, &id, desired).await?;
|
||||
Ok(id)
|
||||
}
|
||||
pub async fn publish(
|
||||
&self,
|
||||
db: &Store,
|
||||
record: &WorkspaceRecord,
|
||||
plan: &Publication,
|
||||
overwrite: bool,
|
||||
) -> Result<()> {
|
||||
// Preflight every recipient before creating or modifying any external object.
|
||||
self.resolve_people(record, plan)
|
||||
.await?
|
||||
.publish_resolved(db, record, plan, overwrite)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn publish_resolved(
|
||||
&self,
|
||||
db: &Store,
|
||||
record: &WorkspaceRecord,
|
||||
plan: &Publication,
|
||||
overwrite: bool,
|
||||
) -> Result<()> {
|
||||
let objects = db.objects(record.id).await?;
|
||||
let previous: BTreeMap<_, _> = objects.iter().map(|p| (p.object_key.as_str(), p)).collect();
|
||||
let mut used = BTreeSet::new();
|
||||
let mut schedules = BTreeMap::new();
|
||||
let mut policies = BTreeMap::new();
|
||||
for s in &plan.schedules {
|
||||
let key = format!("schedule:{}", s.id);
|
||||
used.insert(key.clone());
|
||||
let rotations=s.periods.iter().map(|p|Ok(json!({"name":format!("shift-{}",p.start.timestamp()),"startDate":p.start,"endDate":p.end,"type":"daily","length":1,"participants":[{"type":"user","id":self.mapped(p.user_id)?}]}))).collect::<Result<Vec<_>>>()?;
|
||||
let body = json!({"name":owned_name(record.id,s.id),"description":crate::i18n::message("err_managed_by_meguri_p0_e8ef37", &[("p0", (s.name).to_string())]),"teamId":self.team,"timezone":s.timezone,"enabled":true,"rotations":rotations});
|
||||
let remote = self
|
||||
.synchronize(
|
||||
db,
|
||||
record,
|
||||
"schedule",
|
||||
&key,
|
||||
&body,
|
||||
previous.get(key.as_str()).copied(),
|
||||
overwrite,
|
||||
)
|
||||
.await?;
|
||||
schedules.insert(s.id, remote);
|
||||
}
|
||||
for e in &plan.escalations {
|
||||
let key = format!("escalation:{}", e.id);
|
||||
used.insert(key.clone());
|
||||
let mut rules = vec![];
|
||||
for step in &e.steps {
|
||||
let (kind, id, notify) = match step.recipient {
|
||||
Recipient::Schedule { id } => (
|
||||
"schedule",
|
||||
schedules.get(&id).cloned().ok_or_else(|| {
|
||||
Error::Validation(
|
||||
crate::i18n::text(
|
||||
"err_escalation_references_an_empty_schedule_2a0f3c",
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
})?,
|
||||
"default",
|
||||
),
|
||||
Recipient::User { id } => ("user", self.mapped(id)?, "default"),
|
||||
Recipient::TeamAll => ("team", self.team.clone(), "all"),
|
||||
Recipient::TeamRandom => ("team", self.team.clone(), "random"),
|
||||
};
|
||||
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 remote = self
|
||||
.synchronize(
|
||||
db,
|
||||
record,
|
||||
"escalation",
|
||||
&key,
|
||||
&body,
|
||||
previous.get(key.as_str()).copied(),
|
||||
overwrite,
|
||||
)
|
||||
.await?;
|
||||
policies.insert(e.id, remote);
|
||||
}
|
||||
let mut routes = plan.routes.clone();
|
||||
routes.sort_by_key(|r| r.order);
|
||||
for (order, route) in routes.iter().enumerate() {
|
||||
let key = format!("route:{}", route.id);
|
||||
used.insert(key.clone());
|
||||
let notify = match route.escalation_id {
|
||||
Some(id) => {
|
||||
json!({"type":"escalation","id":policies.get(&id).ok_or_else(||Error::Validation(crate::i18n::text("err_missing_escalation_mapping_3fd412").into()))?})
|
||||
}
|
||||
None => json!({"type":"none"}),
|
||||
};
|
||||
let conditions:Vec<_>=route.conditions.iter().map(|c|json!({"field":c.field,"operation":c.operation,"expectedValue":c.value,"not":c.negate})).collect();
|
||||
let criteria = if conditions.is_empty() {
|
||||
json!({"type":"match-all"})
|
||||
} 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});
|
||||
if let Some(w) = &route.window {
|
||||
body["timezone"] = json!(w.timezone);
|
||||
let days = [
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
];
|
||||
let restrictions:Vec<_>=w.weekdays.iter().map(|d|json!({"startDay":days[usize::from(*d)-1],"endDay":days[usize::from(*d)-1],"startHour":w.start.hour(),"startMin":w.start.minute(),"endHour":w.end.hour(),"endMin":w.end.minute()})).collect();
|
||||
body["timeRestriction"] =
|
||||
json!({"type":"weekday-and-time-of-day","restrictions":restrictions});
|
||||
} else {
|
||||
body["timeRestriction"] = Value::Null;
|
||||
}
|
||||
self.synchronize(
|
||||
db,
|
||||
record,
|
||||
"route",
|
||||
&key,
|
||||
&body,
|
||||
previous.get(key.as_str()).copied(),
|
||||
overwrite,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
// Delete only checkpointed, owned resources, in reverse dependency order.
|
||||
for kind in ["route", "escalation", "schedule"] {
|
||||
for old in objects.iter().filter(|o| {
|
||||
o.object_key.starts_with(&format!("{kind}:")) && !used.contains(&o.object_key)
|
||||
}) {
|
||||
if let Some(current) = self.read(kind, &old.remote_id).await? {
|
||||
let expected = serde_json::from_str(&old.content)?;
|
||||
if !equivalent(¤t, &expected) && !overwrite {
|
||||
return Err(Error::Conflict(
|
||||
crate::i18n::text(
|
||||
"err_an_obsolete_jsm_resource_changed_externally_dele_a26bda",
|
||||
)
|
||||
.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(),
|
||||
));
|
||||
}
|
||||
self.request(
|
||||
Method::DELETE,
|
||||
&format!("{}/{}", self.collection(kind), old.remote_id),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
db.forget(record.id, &old.object_key).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_name(team: Uuid, id: Uuid) -> String {
|
||||
format!("meguri-{}-{}", team.simple(), id.simple())
|
||||
}
|
||||
fn safe_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 200
|
||||
&& id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "-_".contains(c))
|
||||
}
|
||||
|
||||
/// Ignore server-generated metadata, while comparing every managed field and array entry.
|
||||
pub fn equivalent(actual: &Value, expected: &Value) -> bool {
|
||||
match expected {
|
||||
Value::Object(expected) => {
|
||||
// A restriction added to a finite rotation changes coverage, not metadata.
|
||||
if expected.contains_key("participants")
|
||||
&& expected.contains_key("startDate")
|
||||
&& !expected.contains_key("timeRestriction")
|
||||
&& actual.get("timeRestriction").is_some_and(|v| !v.is_null())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
expected
|
||||
.iter()
|
||||
.all(|(k, v)| 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))
|
||||
}),
|
||||
Value::String(expected) => {
|
||||
if let (Ok(a), Ok(b)) = (
|
||||
actual.as_str().unwrap_or_default().parse::<DateTime<Utc>>(),
|
||||
expected.parse::<DateTime<Utc>>(),
|
||||
) {
|
||||
a == b
|
||||
} else {
|
||||
actual.as_str() == Some(expected)
|
||||
}
|
||||
}
|
||||
_ => actual == expected,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[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"}]}]});
|
||||
let mut remote = json!({"id":"remote","rotations":[{"id":"rotation","startDate":"2026-09-23T08:00:00.000Z","participants":[{"id":"alice","type":"user"}]}]});
|
||||
assert!(equivalent(&remote, &expected));
|
||||
remote["rotations"][0]["timeRestriction"] =
|
||||
json!({"type":"time-of-day","restriction":{"startHour":9,"endHour":17}});
|
||||
assert!(!equivalent(&remote, &expected));
|
||||
remote["rotations"][0]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("timeRestriction");
|
||||
remote["rotations"][0]["participants"][0]["id"] = json!("bob");
|
||||
assert!(!equivalent(&remote, &expected));
|
||||
remote["rotations"].as_array_mut().unwrap().clear();
|
||||
assert!(!equivalent(&remote, &expected));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Provider-independent publication contract. Providers never calculate rotations.
|
||||
pub mod jsm;
|
||||
use crate::{
|
||||
domain::*,
|
||||
error::{Result, ensure},
|
||||
store::{Store, WorkspaceRecord},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublishedSchedule {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub timezone: String,
|
||||
pub periods: Vec<Assignment>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Publication {
|
||||
pub workspace_id: Uuid,
|
||||
pub revision: i64,
|
||||
pub from: DateTime<Utc>,
|
||||
pub to: DateTime<Utc>,
|
||||
pub schedules: Vec<PublishedSchedule>,
|
||||
pub escalations: Vec<Escalation>,
|
||||
pub routes: Vec<RoutingRule>,
|
||||
}
|
||||
|
||||
pub fn validate_capabilities(doc: &WorkspaceDocument, now: DateTime<Utc>) -> Result<()> {
|
||||
if let Integration::Jsm {
|
||||
advanced_escalations,
|
||||
users,
|
||||
..
|
||||
} = &doc.integration
|
||||
{
|
||||
for policy in &doc.escalations {
|
||||
ensure(
|
||||
*advanced_escalations
|
||||
|| (policy.repeat.count == 0
|
||||
&& !policy
|
||||
.steps
|
||||
.iter()
|
||||
.any(|s| matches!(s.recipient, Recipient::TeamRandom))),
|
||||
crate::i18n::text("err_random_team_recipients_and_repeats_require_jsm_p_69c0b1"),
|
||||
)?;
|
||||
}
|
||||
let mut needed = std::collections::BTreeSet::new();
|
||||
for s in &doc.schedules {
|
||||
match &s.kind {
|
||||
ScheduleKind::Rotation { participants, .. }
|
||||
| ScheduleKind::Priority { participants, .. } => needed.extend(participants),
|
||||
}
|
||||
}
|
||||
for r in doc
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|r| r.status == RequestStatus::Applied)
|
||||
{
|
||||
for l in r.effective_legs() {
|
||||
if l.end > now {
|
||||
needed.insert(&l.to_user);
|
||||
}
|
||||
}
|
||||
}
|
||||
for e in &doc.escalations {
|
||||
for s in &e.steps {
|
||||
if let Recipient::User { id } = &s.recipient {
|
||||
needed.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ensure(
|
||||
needed.iter().all(|id| {
|
||||
users.contains_key(id) || doc.person(**id).is_some_and(|p| p.email.is_some())
|
||||
}),
|
||||
crate::i18n::text("err_add_an_email_or_atlassian_account_id_for_every_s_7d378f"),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compile(
|
||||
record: &WorkspaceRecord,
|
||||
timeline: Timeline,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<Publication> {
|
||||
validate_capabilities(&record.document, now)?;
|
||||
if let Some(gap) = timeline
|
||||
.assignments
|
||||
.iter()
|
||||
.find(|a| a.end > now && a.unavailable)
|
||||
{
|
||||
let person = record
|
||||
.document
|
||||
.person(gap.user_id)
|
||||
.map(|p| p.display_name.clone())
|
||||
.unwrap_or_else(|| gap.user_id.to_string());
|
||||
let schedule = record
|
||||
.document
|
||||
.schedules
|
||||
.iter()
|
||||
.find(|s| s.id == gap.schedule_id)
|
||||
.map(|s| s.name.clone())
|
||||
.unwrap_or_else(|| gap.schedule_id.to_string());
|
||||
return Err(crate::error::Error::Validation(crate::i18n::message(
|
||||
"publication_absent_detail",
|
||||
&[
|
||||
("person", person),
|
||||
("schedule", schedule),
|
||||
(
|
||||
"start",
|
||||
gap.start.max(now).format("%d-%m-%Y %H:%M UTC").to_string(),
|
||||
),
|
||||
("end", gap.end.format("%d-%m-%Y %H:%M UTC").to_string()),
|
||||
],
|
||||
)));
|
||||
}
|
||||
let mut schedules = vec![];
|
||||
for schedule in &record.document.schedules {
|
||||
let mut periods: Vec<Assignment> = vec![];
|
||||
let mut intervals: Vec<_> = timeline
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == schedule.id)
|
||||
.cloned()
|
||||
.collect();
|
||||
intervals.sort_by_key(|a| a.start);
|
||||
for assignment in intervals {
|
||||
if let Some(last) = periods.last_mut()
|
||||
&& last.end == assignment.start
|
||||
&& last.user_id == assignment.user_id
|
||||
{
|
||||
last.end = assignment.end;
|
||||
continue;
|
||||
}
|
||||
periods.push(assignment);
|
||||
}
|
||||
if matches!(record.document.integration, Integration::Jsm { .. }) {
|
||||
ensure(
|
||||
periods.len() <= 500,
|
||||
crate::i18n::text(
|
||||
"err_jsm_supports_at_most_500_rotations_per_schedule_shorten_the_publication_horizon",
|
||||
),
|
||||
)?;
|
||||
}
|
||||
if !periods.is_empty() {
|
||||
schedules.push(PublishedSchedule {
|
||||
id: schedule.id,
|
||||
name: schedule.name.clone(),
|
||||
timezone: record.document.timezone.clone(),
|
||||
periods,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Publication {
|
||||
workspace_id: record.id,
|
||||
revision: record.revision,
|
||||
from: timeline.from,
|
||||
to: timeline.to,
|
||||
schedules,
|
||||
escalations: record.document.escalations.clone(),
|
||||
routes: record.document.routes.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Statically linked provider plugins share the canonical plan and checkpoint store.
|
||||
/// No vendor-specific scheduling belongs in an implementation of this contract.
|
||||
pub trait Provider: Send + Sync {
|
||||
fn publish(
|
||||
&self,
|
||||
db: &Store,
|
||||
record: &WorkspaceRecord,
|
||||
plan: &Publication,
|
||||
overwrite: bool,
|
||||
) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
}
|
||||
|
||||
pub struct Mock;
|
||||
impl Provider for Mock {
|
||||
async fn publish(
|
||||
&self,
|
||||
db: &Store,
|
||||
record: &WorkspaceRecord,
|
||||
plan: &Publication,
|
||||
_overwrite: bool,
|
||||
) -> Result<()> {
|
||||
db.checkpoint(record.id, "mock", "mock", &serde_json::to_value(plan)?)
|
||||
.await
|
||||
}
|
||||
}
|
||||
impl Provider for jsm::Jsm {
|
||||
async fn publish(
|
||||
&self,
|
||||
db: &Store,
|
||||
record: &WorkspaceRecord,
|
||||
plan: &Publication,
|
||||
overwrite: bool,
|
||||
) -> Result<()> {
|
||||
jsm::Jsm::publish(self, db, record, plan, overwrite).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn publish(
|
||||
db: &Store,
|
||||
record: &WorkspaceRecord,
|
||||
plan: &Publication,
|
||||
overwrite: bool,
|
||||
) -> Result<()> {
|
||||
ensure(
|
||||
!crate::demo_runtime::active() || matches!(record.document.integration, Integration::Mock),
|
||||
crate::i18n::text("err_external_publication_is_disabled_in_demo_mode_712662"),
|
||||
)?;
|
||||
match &record.document.integration {
|
||||
Integration::Mock => Provider::publish(&Mock, db, record, plan, overwrite).await,
|
||||
Integration::Jsm { .. } => {
|
||||
Provider::publish(
|
||||
&jsm::Jsm::from_environment(record)?,
|
||||
db,
|
||||
record,
|
||||
plan,
|
||||
overwrite,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn unsupported_escalation_is_not_silently_dropped() {
|
||||
let mut doc = WorkspaceDocument::new("Platform".into());
|
||||
doc.integration = Integration::Jsm {
|
||||
cloud_id: Uuid::new_v4().to_string(),
|
||||
team_id: Uuid::new_v4().to_string(),
|
||||
advanced_escalations: false,
|
||||
users: Default::default(),
|
||||
};
|
||||
doc.escalations.push(Escalation {
|
||||
id: Uuid::new_v4(),
|
||||
name: "Urgent".into(),
|
||||
steps: vec![EscalationStep {
|
||||
delay_minutes: 30,
|
||||
condition: EscalationCondition::IfNotAcked,
|
||||
recipient: Recipient::TeamRandom,
|
||||
}],
|
||||
repeat: Default::default(),
|
||||
});
|
||||
assert!(validate_capabilities(&doc, Utc::now()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_backup_and_rotation_block_future_publication() {
|
||||
let user = Uuid::new_v4();
|
||||
let id = Uuid::new_v4();
|
||||
let day = "2026-09-23".parse().unwrap();
|
||||
let mut document = WorkspaceDocument::new("Platform".into());
|
||||
document.schedules.push(Schedule {
|
||||
handover: default_handover(),
|
||||
id,
|
||||
name: "Backup".into(),
|
||||
kind: ScheduleKind::Rotation {
|
||||
participants: vec![user],
|
||||
anchor: day,
|
||||
shift_days: 7,
|
||||
},
|
||||
});
|
||||
document.absences.push(Absence {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user,
|
||||
start: day,
|
||||
end: day,
|
||||
});
|
||||
let mut record = WorkspaceRecord {
|
||||
id: Uuid::new_v4(),
|
||||
|
||||
revision: 1,
|
||||
document,
|
||||
};
|
||||
let start = crate::engine::handover(&record.document, day).unwrap();
|
||||
let end = start + chrono::Duration::hours(24);
|
||||
let timeline = crate::engine::timeline(&record.document, start, end).unwrap();
|
||||
assert!(timeline.assignments[0].unavailable);
|
||||
let error = compile(&record, timeline.clone(), start)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("Backup")
|
||||
&& error.contains(&user.to_string())
|
||||
&& error.contains("23-09-2026")
|
||||
);
|
||||
record.document.schedules[0].kind = ScheduleKind::Rotation {
|
||||
participants: vec![user],
|
||||
anchor: day,
|
||||
shift_days: 7,
|
||||
};
|
||||
assert!(compile(&record, timeline.clone(), start).is_err());
|
||||
assert!(
|
||||
compile(&record, timeline, end).is_ok(),
|
||||
"An absence that has ended cannot block a future publication"
|
||||
);
|
||||
}
|
||||
}
|
||||
+975
@@ -0,0 +1,975 @@
|
||||
use crate::{
|
||||
domain::*,
|
||||
engine,
|
||||
error::{Error, Result, ensure},
|
||||
models::*,
|
||||
store::{self, Store, WorkspaceRecord},
|
||||
};
|
||||
use argon2::{
|
||||
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||
password_hash::{SaltString, rand_core::OsRng},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use cot::db::{Model, query};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn token_hash(token: &str) -> String {
|
||||
format!("{:x}", Sha256::digest(token.as_bytes()))
|
||||
}
|
||||
|
||||
pub async fn hash_password(password: String) -> Result<String> {
|
||||
ensure(
|
||||
(12..=256).contains(&password.len()),
|
||||
crate::i18n::text("err_password_must_contain_12_256_characters_3169d0"),
|
||||
)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|_| {
|
||||
Error::Internal(crate::i18n::text("err_could_not_hash_password_212449").into())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Internal(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
db: &Store,
|
||||
username: String,
|
||||
display_name: String,
|
||||
password: String,
|
||||
) -> Result<User> {
|
||||
let username = username.trim().to_ascii_lowercase();
|
||||
ensure(
|
||||
(3..=64).contains(&username.len())
|
||||
&& username
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)),
|
||||
crate::i18n::text("err_username_must_use_3_64_letters_numbers_dots_unde_2c6b6f"),
|
||||
)?;
|
||||
validate_name(&display_name)?;
|
||||
let hash = hash_password(password).await?;
|
||||
let user = User {
|
||||
language: None,
|
||||
id: Uuid::new_v4(),
|
||||
username,
|
||||
display_name,
|
||||
country: "CY".into(),
|
||||
timezone: None,
|
||||
};
|
||||
let result = UserRow {
|
||||
language: None,
|
||||
demo_deleted_at: None,
|
||||
id: user.id.to_string(),
|
||||
username: user.username.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
country: user.country.clone(),
|
||||
timezone: None,
|
||||
password_hash: hash,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
.insert(&db.db)
|
||||
.await;
|
||||
if matches!(result, Err(cot::db::DatabaseError::UniqueViolation)) {
|
||||
return Err(Error::Conflict(
|
||||
crate::i18n::text("err_this_username_is_already_registered_5ea7ea").into(),
|
||||
));
|
||||
}
|
||||
result?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
db: &Store,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<(User, String, String)> {
|
||||
ensure(
|
||||
password.len() <= 256 && username.len() <= 64,
|
||||
crate::i18n::text("err_invalid_credentials_e41784"),
|
||||
)?;
|
||||
let username = username.trim().to_ascii_lowercase();
|
||||
let row = query!(UserRow,$username==username).get(&db.db).await?;
|
||||
let row = row.filter(|r| r.demo_deleted_at.is_none());
|
||||
// Perform the expensive KDF for an unknown username too.
|
||||
let fallback = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXJhbmRvbXNhbHQ$DKjSa6guFpKhutOgiqYmtDXrWFFJEWEbCJ3JAlCSF3c";
|
||||
let hash: String = row
|
||||
.as_ref()
|
||||
.map(|r| r.password_hash.clone())
|
||||
.unwrap_or(fallback.into());
|
||||
let valid = tokio::task::spawn_blocking(move || {
|
||||
PasswordHash::new(&hash).is_ok_and(|h| {
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &h)
|
||||
.is_ok()
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Internal(e.to_string()))?;
|
||||
if !valid || row.is_none() {
|
||||
return Err(Error::Unauthorized);
|
||||
}
|
||||
let row = row.unwrap();
|
||||
let user = store::user(row)?;
|
||||
let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
|
||||
let csrf = Uuid::new_v4().to_string();
|
||||
SessionRow {
|
||||
token_hash: token_hash(&token),
|
||||
user_id: user.id.to_string(),
|
||||
csrf: csrf.clone(),
|
||||
expires_at: Utc::now() + Duration::hours(12),
|
||||
}
|
||||
.insert(&db.db)
|
||||
.await?;
|
||||
Ok((user, token, csrf))
|
||||
}
|
||||
|
||||
pub async fn create_workspace(db: &Store, actor: Uuid, name: String) -> Result<WorkspaceRecord> {
|
||||
validate_name(&name)?;
|
||||
let id = Uuid::new_v4();
|
||||
let doc = WorkspaceDocument::new(name);
|
||||
db.db
|
||||
.raw_with(
|
||||
CREATE_WORKSPACE,
|
||||
&[
|
||||
&id.to_string(),
|
||||
&serde_json::to_string(&doc)?,
|
||||
&actor.to_string(),
|
||||
&serde_json::to_string(&Permission::all())?,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
db.workspace(id).await
|
||||
}
|
||||
|
||||
pub async fn update_document(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
revision: i64,
|
||||
mut document: WorkspaceDocument,
|
||||
) -> Result<WorkspaceRecord> {
|
||||
let mut record = db
|
||||
.prepare(workspace, revision, actor, Permission::EditSchedules)
|
||||
.await?;
|
||||
document.requests = record.document.requests.clone();
|
||||
for request in &mut document.requests {
|
||||
if request.status == RequestStatus::Pending
|
||||
&& request
|
||||
.absence_id
|
||||
.is_some_and(|id| !document.absences.iter().any(|a| a.id == id))
|
||||
{
|
||||
request.status = RequestStatus::Cancelled;
|
||||
}
|
||||
}
|
||||
document.integration = record.document.integration.clone();
|
||||
document.working_interval = record.document.working_interval.clone();
|
||||
document.overwrite_drift = record.document.overwrite_drift;
|
||||
crate::generation::validate_manual_edit(&record.document, &mut document)?;
|
||||
let members = db.member_ids(workspace).await?;
|
||||
for person in &mut document.people {
|
||||
person.email = person
|
||||
.email
|
||||
.take()
|
||||
.map(|e| e.trim().to_lowercase())
|
||||
.filter(|e| !e.is_empty());
|
||||
person.country = person
|
||||
.country
|
||||
.take()
|
||||
.map(|c| c.trim().to_ascii_uppercase())
|
||||
.filter(|c| !c.is_empty());
|
||||
if person.country.is_none() {
|
||||
person.country = Some("CY".into());
|
||||
}
|
||||
if let Some(old) = record.document.person(person.id) {
|
||||
ensure(
|
||||
old.user_id == person.user_id,
|
||||
crate::i18n::text("err_a_contact_cannot_be_linked_to_a_login_through_an_68dd72"),
|
||||
)?;
|
||||
} else if person.user_id.is_none() {
|
||||
let id = person.id.to_string();
|
||||
ensure(
|
||||
!query!(UserRow,$id==id).exists(&db.db).await?,
|
||||
crate::i18n::text("err_select_the_workspace_user_from_the_directory_725423"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let interval = db.working_interval(workspace).await?;
|
||||
document.timezone = interval.timezone.clone();
|
||||
for route in &mut document.routes {
|
||||
if route.window.is_some() {
|
||||
route.window = Some(interval.clone());
|
||||
}
|
||||
}
|
||||
document.validate(&members)?;
|
||||
let members = document.person_ids(&members);
|
||||
for request in document
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|r| r.status == RequestStatus::Applied)
|
||||
{
|
||||
for leg in request
|
||||
.effective_legs()
|
||||
.iter()
|
||||
.filter(|l| l.end > Utc::now())
|
||||
{
|
||||
ensure(
|
||||
document.schedule(leg.schedule_id).is_some(),
|
||||
crate::i18n::text("err_cannot_remove_a_schedule_with_an_active_approved_a83aa7"),
|
||||
)?;
|
||||
ensure(
|
||||
members.contains(&leg.to_user),
|
||||
crate::i18n::text("err_an_approved_replacement_references_a_missing_mem_fd0f2a"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
engine::timeline(
|
||||
&document,
|
||||
Utc::now(),
|
||||
Utc::now() + Duration::days(i64::from(document.horizon_days)),
|
||||
)?;
|
||||
let before = serde_json::to_value(&record.document)?;
|
||||
record.document = document;
|
||||
let after = serde_json::to_value(&record.document)?;
|
||||
db.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
Permission::EditSchedules,
|
||||
"configuration.updated",
|
||||
json!({"before":before,"after":after}),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RemoveSchedule {
|
||||
pub revision: i64,
|
||||
pub redirect_to: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn remove_schedule(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
schedule: Uuid,
|
||||
input: RemoveSchedule,
|
||||
) -> Result<WorkspaceRecord> {
|
||||
let mut record = db
|
||||
.prepare(workspace, input.revision, actor, Permission::EditSchedules)
|
||||
.await?;
|
||||
let now = Utc::now();
|
||||
let plan = crate::configuration::schedule_removal_plan(&record.document, schedule, now)?;
|
||||
let document =
|
||||
crate::configuration::remove_schedule(&record.document, schedule, input.redirect_to, now)?;
|
||||
document.validate(&db.member_ids(workspace).await?)?;
|
||||
engine::timeline(
|
||||
&document,
|
||||
now,
|
||||
now + Duration::days(i64::from(document.horizon_days)),
|
||||
)?;
|
||||
record.document = document;
|
||||
db.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
Permission::EditSchedules,
|
||||
"schedule.removed",
|
||||
json!({"impact":plan,"redirect_to":input.redirect_to}),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReorderRoutes {
|
||||
pub revision: i64,
|
||||
pub ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn reorder_routes(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
input: ReorderRoutes,
|
||||
) -> Result<WorkspaceRecord> {
|
||||
let mut record = db
|
||||
.prepare(workspace, input.revision, actor, Permission::EditSchedules)
|
||||
.await?;
|
||||
let mut previous = record.document.routes.clone();
|
||||
previous.sort_by_key(|r| r.order);
|
||||
let before: Vec<_> = previous.iter().map(|r| r.id).collect();
|
||||
crate::configuration::reorder_routes(&mut record.document, &input.ids)?;
|
||||
crate::generation::validate_route_block(&mut record.document)?;
|
||||
db.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
Permission::EditSchedules,
|
||||
"routing.reordered",
|
||||
json!({"before":before,"after":input.ids}),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn update_integration(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
revision: i64,
|
||||
integration: Integration,
|
||||
) -> Result<WorkspaceRecord> {
|
||||
ensure(
|
||||
!crate::demo_runtime::active() || matches!(integration, Integration::Mock),
|
||||
crate::i18n::text("err_demo_mode_uses_the_local_preview_provider_extern_e125f3"),
|
||||
)?;
|
||||
let mut record = db
|
||||
.prepare(workspace, revision, actor, Permission::ManageIntegration)
|
||||
.await?;
|
||||
let old = &record.document.integration;
|
||||
let same_destination = match (old, &integration) {
|
||||
(Integration::Mock, _) => true,
|
||||
(
|
||||
Integration::Jsm {
|
||||
cloud_id: a,
|
||||
team_id: b,
|
||||
..
|
||||
},
|
||||
Integration::Jsm {
|
||||
cloud_id: c,
|
||||
team_id: d,
|
||||
..
|
||||
},
|
||||
) => a == c && b == d,
|
||||
_ => false,
|
||||
};
|
||||
let published = db
|
||||
.objects(workspace)
|
||||
.await?
|
||||
.iter()
|
||||
.any(|o| o.object_key != "mock");
|
||||
ensure(
|
||||
!published || same_destination,
|
||||
crate::i18n::text("err_a_published_jsm_destination_cannot_be_changed_in_7bc9a5"),
|
||||
)?;
|
||||
record.document.integration = integration;
|
||||
record.document.validate(&db.member_ids(workspace).await?)?;
|
||||
crate::providers::validate_capabilities(&record.document, Utc::now())?;
|
||||
let details = serde_json::to_value(&record.document.integration)?;
|
||||
db.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
Permission::ManageIntegration,
|
||||
"integration.updated",
|
||||
details,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn set_member(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
revision: i64,
|
||||
username: String,
|
||||
permissions: Vec<Permission>,
|
||||
) -> Result<WorkspaceRecord> {
|
||||
ensure(
|
||||
permissions.contains(&Permission::View),
|
||||
crate::i18n::text("err_every_member_must_have_calendar_access_ca104e"),
|
||||
)?;
|
||||
let mut record = db
|
||||
.prepare(workspace, revision, actor, Permission::ManageMembers)
|
||||
.await?;
|
||||
let username = username.trim().to_ascii_lowercase();
|
||||
let user = query!(UserRow,$username==username)
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.ok_or(Error::Validation(
|
||||
crate::i18n::text("err_ask_this_person_to_create_an_account_first_897eb2").into(),
|
||||
))?;
|
||||
let user = store::uuid(&user.id)?;
|
||||
db.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
Permission::ManageMembers,
|
||||
"member.permissions_changed",
|
||||
json!({"user_id":user,"permissions":permissions}),
|
||||
Some((user, permissions)),
|
||||
)
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NewRequest {
|
||||
#[serde(default)]
|
||||
pub remaining: bool,
|
||||
pub revision: i64,
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
pub legs: Vec<ReplacementLeg>,
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NewAbsence {
|
||||
pub revision: i64,
|
||||
#[serde(flatten)]
|
||||
pub draft: crate::absence::Draft,
|
||||
#[serde(default)]
|
||||
pub people: Vec<Person>,
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
async fn prepare_absence(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
input: &NewAbsence,
|
||||
now: chrono::DateTime<Utc>,
|
||||
) -> Result<(WorkspaceRecord, Permission, crate::absence::Plan)> {
|
||||
let grants = db.require(workspace, actor, Permission::View).await?;
|
||||
let permission = if input.force {
|
||||
Permission::ForceOverrides
|
||||
} else if actor != input.draft.absence.user_id || grants.contains(&Permission::EditSchedules) {
|
||||
Permission::EditSchedules
|
||||
} else {
|
||||
Permission::RequestOverrides
|
||||
};
|
||||
let mut record = db
|
||||
.prepare(workspace, input.revision, actor, permission)
|
||||
.await?;
|
||||
ensure(
|
||||
input.people.len() <= 100,
|
||||
crate::i18n::text("err_add_at_most_100_contacts_at_once_e720fc"),
|
||||
)?;
|
||||
let participants = db.member_ids(workspace).await?;
|
||||
for new in &input.people {
|
||||
if let Some(old) = record.document.person(new.id) {
|
||||
ensure(
|
||||
serde_json::to_value(old)? == serde_json::to_value(new)?,
|
||||
crate::i18n::text("err_edit_an_existing_contact_in_team_settings_ad289b"),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let mut person = new.clone();
|
||||
person.email = person
|
||||
.email
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty());
|
||||
person.country = Some(
|
||||
person
|
||||
.country
|
||||
.unwrap_or_else(|| "CY".into())
|
||||
.trim()
|
||||
.to_ascii_uppercase(),
|
||||
);
|
||||
if person.user_id.is_none() {
|
||||
let key = person.id.to_string();
|
||||
ensure(
|
||||
!query!(UserRow,$id==key).exists(&db.db).await?,
|
||||
crate::i18n::text("err_select_an_existing_workspace_user_from_the_direc_a09a03"),
|
||||
)?;
|
||||
}
|
||||
record.document.people.push(person);
|
||||
}
|
||||
record.document.validate(&participants)?;
|
||||
let mut plan = crate::absence::plan(&record.document, &input.draft, &participants, now)?;
|
||||
if !input.force {
|
||||
let members = db.member_ids(workspace).await?;
|
||||
for id in plan
|
||||
.groups
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|l| l.to_user)
|
||||
.collect::<BTreeSet<_>>()
|
||||
{
|
||||
if !members.contains(&id) {
|
||||
plan.errors.push(
|
||||
crate::i18n::text(
|
||||
"err_a_selected_replacement_has_no_team_access_to_con_dd538a",
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((record, permission, plan))
|
||||
}
|
||||
|
||||
pub async fn preview_absence(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
input: NewAbsence,
|
||||
) -> Result<serde_json::Value> {
|
||||
let (record, _, plan) = prepare_absence(db, actor, workspace, &input, Utc::now()).await?;
|
||||
Ok(json!({"revision":record.revision,"plan":plan}))
|
||||
}
|
||||
|
||||
pub async fn create_absence(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
input: NewAbsence,
|
||||
) -> Result<WorkspaceRecord> {
|
||||
let now = Utc::now();
|
||||
let (mut record, permission, plan) = prepare_absence(db, actor, workspace, &input, now).await?;
|
||||
ensure(plan.errors.is_empty(), plan.errors.join("; "))?;
|
||||
ensure(
|
||||
input.reason.len() <= 1000,
|
||||
crate::i18n::text("err_comment_must_be_at_most_1000_characters_e5d5ef"),
|
||||
)?;
|
||||
ensure(
|
||||
record.document.requests.len() + plan.groups.len() <= 5000,
|
||||
crate::i18n::text("err_replacement_history_limit_reached_3b1168"),
|
||||
)?;
|
||||
record.document.absences.push(input.draft.absence.clone());
|
||||
for legs in &plan.groups {
|
||||
let mut request =
|
||||
crate::absence::synthetic_request(input.draft.absence.id, legs.clone(), now);
|
||||
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 {
|
||||
RequestStatus::Applied
|
||||
} else {
|
||||
RequestStatus::Pending
|
||||
};
|
||||
request.applied_at = input.force.then_some(now);
|
||||
request.forced_by = input.force.then_some(actor);
|
||||
request.force_reason = input.force.then_some(input.reason.clone());
|
||||
if input.force {
|
||||
engine::supersede_overrides(&mut record.document, &request.legs);
|
||||
}
|
||||
record.document.requests.push(request);
|
||||
}
|
||||
db.commit(&mut record,actor,permission,if input.force {"absence.forced"} else {"absence.created"},json!({"absence":input.draft.absence,"ranges":input.draft.ranges,"impact":plan,"force":input.force,"reason":input.reason}),None).await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn request_replacement(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
mut input: NewRequest,
|
||||
) -> Result<WorkspaceRecord> {
|
||||
ensure(
|
||||
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 mut record = db
|
||||
.prepare(workspace, input.revision, actor, permission)
|
||||
.await?;
|
||||
let members = db.member_ids(workspace).await?;
|
||||
let now = Utc::now();
|
||||
clip_requested_interval(&mut input, now);
|
||||
engine::validate_legs(&record.document, &input.legs, &members, now)?;
|
||||
ensure(
|
||||
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();
|
||||
let request = ReplacementRequest {
|
||||
effective_legs: None,
|
||||
id: Uuid::new_v4(),
|
||||
absence_id: None,
|
||||
requested_by: actor,
|
||||
created_at: now,
|
||||
reason: input.reason.clone(),
|
||||
legs: input.legs,
|
||||
required_approvers,
|
||||
approvals: BTreeSet::new(),
|
||||
status: if input.force {
|
||||
RequestStatus::Applied
|
||||
} else {
|
||||
RequestStatus::Pending
|
||||
},
|
||||
applied_at: input.force.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 {
|
||||
engine::supersede_overrides(&mut record.document, &request.legs);
|
||||
}
|
||||
record.document.requests.push(request);
|
||||
db.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
permission,
|
||||
if input.force {
|
||||
"replacement.forced"
|
||||
} else {
|
||||
"replacement.requested"
|
||||
},
|
||||
details,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn validate_request_participation(actor: Uuid, input: &NewRequest) -> Result<()> {
|
||||
ensure(
|
||||
input.force
|
||||
|| input
|
||||
.legs
|
||||
.iter()
|
||||
.all(|leg| leg.from_user == actor || leg.to_user == actor),
|
||||
crate::i18n::text("err_you_can_give_your_own_shift_or_offer_to_cover_a_5e6f57"),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_request_access(input: &NewRequest, members: &BTreeSet<Uuid>) -> Result<()> {
|
||||
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"),
|
||||
)
|
||||
}
|
||||
|
||||
fn clip_requested_interval(input: &mut NewRequest, now: chrono::DateTime<Utc>) {
|
||||
if input.remaining
|
||||
&& let Some(leg) = input.legs.first_mut()
|
||||
{
|
||||
leg.start = leg.start.max(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only validation of the same ownership, availability and permission checks used on save.
|
||||
pub async fn preview_replacement(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
mut input: NewRequest,
|
||||
) -> Result<serde_json::Value> {
|
||||
let permission = if input.force {
|
||||
Permission::ForceOverrides
|
||||
} else {
|
||||
Permission::RequestOverrides
|
||||
};
|
||||
let record = db
|
||||
.prepare(workspace, input.revision, actor, permission)
|
||||
.await?;
|
||||
validate_request_participation(actor, &input)?;
|
||||
let members = db.member_ids(workspace).await?;
|
||||
validate_request_access(&input, &members)?;
|
||||
ensure(
|
||||
input.reason.len() <= 1000,
|
||||
crate::i18n::text("err_comment_must_be_at_most_1000_characters_e5d5ef"),
|
||||
)?;
|
||||
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}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RequestDecision {
|
||||
pub revision: i64,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub async fn decide_request(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
id: Uuid,
|
||||
input: RequestDecision,
|
||||
) -> Result<WorkspaceRecord> {
|
||||
let permission = if input.action == "force" {
|
||||
Permission::ForceOverrides
|
||||
} else {
|
||||
Permission::View
|
||||
};
|
||||
let mut record = db
|
||||
.prepare(workspace, input.revision, actor, permission)
|
||||
.await?;
|
||||
let index = record
|
||||
.document
|
||||
.requests
|
||||
.iter()
|
||||
.position(|r| r.id == id)
|
||||
.ok_or(Error::NotFound)?;
|
||||
let mut request = record.document.requests[index].clone();
|
||||
if request.status != RequestStatus::Pending {
|
||||
return Err(Error::Conflict(
|
||||
crate::i18n::text("err_this_request_has_already_been_resolved_26b69e").into(),
|
||||
));
|
||||
}
|
||||
let mut publish = false;
|
||||
match input.action.as_str() {
|
||||
"approve" => {
|
||||
if !request.required_approvers.contains(&actor) {
|
||||
return Err(Error::Forbidden);
|
||||
}
|
||||
request.approvals.insert(actor);
|
||||
publish = request.required_approvers.is_subset(&request.approvals);
|
||||
}
|
||||
"force" => {
|
||||
ensure(
|
||||
input.reason.len() <= 1000,
|
||||
crate::i18n::text("err_comment_must_be_at_most_1000_characters_e5d5ef"),
|
||||
)?;
|
||||
request.forced_by = Some(actor);
|
||||
request.force_reason = Some(input.reason);
|
||||
publish = true;
|
||||
}
|
||||
"reject" => {
|
||||
if !request.required_approvers.contains(&actor) {
|
||||
return Err(Error::Forbidden);
|
||||
}
|
||||
request.status = RequestStatus::Rejected;
|
||||
}
|
||||
"cancel" => {
|
||||
if request.requested_by != actor {
|
||||
return Err(Error::Forbidden);
|
||||
}
|
||||
request.status = RequestStatus::Cancelled;
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::Validation(
|
||||
crate::i18n::text("err_unknown_request_action_0db000").into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if publish {
|
||||
let members = db.member_ids(workspace).await?;
|
||||
let now = Utc::now();
|
||||
if request.absence_id.is_some() {
|
||||
request.legs.retain(|l| l.end > now);
|
||||
for leg in &mut request.legs {
|
||||
leg.start = leg.start.max(now);
|
||||
}
|
||||
crate::absence::validate_request(&record.document, &request, &members, now)?;
|
||||
} else {
|
||||
for leg in &mut request.legs {
|
||||
leg.start = leg.start.max(now);
|
||||
}
|
||||
engine::validate_legs(&record.document, &request.legs, &members, now)?;
|
||||
}
|
||||
engine::supersede_overrides(&mut record.document, &request.legs);
|
||||
request.status = RequestStatus::Applied;
|
||||
request.applied_at = Some(Utc::now());
|
||||
}
|
||||
let details = serde_json::to_value(&request)?;
|
||||
record.document.requests[index] = request;
|
||||
db.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
permission,
|
||||
&format!("replacement.{}", input.action),
|
||||
details,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn set_drift_policy(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
revision: i64,
|
||||
overwrite: bool,
|
||||
) -> Result<()> {
|
||||
db.owner(workspace, actor).await?;
|
||||
let mut record = db
|
||||
.prepare(workspace, revision, actor, Permission::ManageIntegration)
|
||||
.await?;
|
||||
record.document.overwrite_drift = overwrite;
|
||||
db.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
Permission::ManageIntegration,
|
||||
"workspace.drift_policy_changed",
|
||||
json!({"overwrite_drift":overwrite}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn import_holidays(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
revision: i64,
|
||||
csv: &str,
|
||||
) -> Result<WorkspaceRecord> {
|
||||
let mut record = db
|
||||
.prepare(workspace, revision, actor, Permission::EditSchedules)
|
||||
.await?;
|
||||
let preview = crate::holidays::compare(csv, &record.document.holidays)?;
|
||||
ensure(
|
||||
preview.errors.is_empty(),
|
||||
crate::i18n::text("err_fix_the_csv_errors_before_importing_fe5a4e"),
|
||||
)?;
|
||||
ensure(
|
||||
!preview.additions.is_empty(),
|
||||
crate::i18n::text("err_there_are_no_new_holidays_to_import_f77467"),
|
||||
)?;
|
||||
let details = json!({"added":preview.additions,"existing":preview.existing,"conflicts":preview.conflicts});
|
||||
record.document.holidays.extend(preview.additions);
|
||||
record.document.validate(&db.member_ids(workspace).await?)?;
|
||||
db.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
Permission::EditSchedules,
|
||||
"holidays.imported",
|
||||
details,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub async fn set_working_interval(
|
||||
db: &Store,
|
||||
actor: Uuid,
|
||||
workspace: Uuid,
|
||||
revision: i64,
|
||||
interval: WorkingWindow,
|
||||
) -> Result<()> {
|
||||
db.owner(workspace, actor).await?;
|
||||
interval.validate()?;
|
||||
let mut record = db
|
||||
.prepare(workspace, revision, actor, Permission::EditSchedules)
|
||||
.await?;
|
||||
record.document.timezone = interval.timezone.clone();
|
||||
record.document.working_interval = interval.clone();
|
||||
for route in &mut record.document.routes {
|
||||
if route.window.is_some() {
|
||||
route.window = Some(interval.clone());
|
||||
}
|
||||
}
|
||||
engine::timeline(
|
||||
&record.document,
|
||||
Utc::now(),
|
||||
Utc::now() + Duration::days(i64::from(record.document.horizon_days)),
|
||||
)?;
|
||||
db.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
Permission::EditSchedules,
|
||||
"working_interval.updated",
|
||||
serde_json::to_value(interval)?,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_profile(db: &Store, user: &UserRow) -> Result<()> {
|
||||
// Country changes become new scheduling snapshots. Display timezone never enters
|
||||
// a workspace document and therefore cannot move a shift or enqueue publication.
|
||||
db.db
|
||||
.raw_with(
|
||||
SAVE_PROFILE,
|
||||
&[
|
||||
&user.id,
|
||||
&user.display_name,
|
||||
&user.country,
|
||||
&user.timezone,
|
||||
&user.password_hash,
|
||||
&user.language,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn seed_demo(db: &Store) -> Result<()> {
|
||||
crate::demo::seed(db).await
|
||||
}
|
||||
|
||||
pub async fn list_workspaces(db: &Store, user: Uuid) -> Result<Value> {
|
||||
let actor = user.to_string();
|
||||
let mut workspaces = vec![];
|
||||
for membership in query!(WorkspaceMemberRow,$user_id==actor)
|
||||
.all(&db.db)
|
||||
.await?
|
||||
{
|
||||
let id = membership.workspace_id;
|
||||
let row = query!(WorkspaceRow,$id==id)
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
if row.demo_deleted_at.is_some() {
|
||||
continue;
|
||||
}
|
||||
let record = store::record(row)?;
|
||||
if db.require(record.id, user, Permission::View).await.is_ok() {
|
||||
workspaces.push(
|
||||
json!({"id":record.id,"name":record.document.name,"is_owner":membership.is_owner}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(json!({"workspaces":workspaces}))
|
||||
}
|
||||
const CREATE_WORKSPACE: &str = r#"
|
||||
WITH workspace AS (
|
||||
INSERT INTO meguri__workspaces(id,document,revision,created_at) VALUES($1,$2,1,now()) RETURNING *
|
||||
), member AS (
|
||||
INSERT INTO meguri__workspace_members(id,workspace_id,user_id,permissions,is_owner) SELECT id||':'||$3,id,$3,$4,true FROM workspace
|
||||
), 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__audit_events(workspace_id,actor_id,action,details,created_at) SELECT id,$3,'workspace.created','{}',now() FROM workspace
|
||||
"#;
|
||||
|
||||
const SAVE_PROFILE: &str = r#"
|
||||
WITH previous AS MATERIALIZED (SELECT * FROM meguri__users WHERE id=$1 FOR NO KEY UPDATE), changed AS (
|
||||
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)
|
||||
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__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
|
||||
"#;
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
use crate::{
|
||||
domain::*,
|
||||
error::{Error, Result},
|
||||
models::*,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use cot::db::{Database, Model, query};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Store {
|
||||
pub db: Database,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceRecord {
|
||||
pub id: Uuid,
|
||||
pub revision: i64,
|
||||
pub document: WorkspaceDocument,
|
||||
}
|
||||
|
||||
pub fn uuid(value: &str) -> Result<Uuid> {
|
||||
Uuid::parse_str(value).map_err(|_| {
|
||||
Error::Internal(crate::i18n::text("err_invalid_stored_identifier_7c6a5b").into())
|
||||
})
|
||||
}
|
||||
pub fn user(row: UserRow) -> Result<User> {
|
||||
Ok(User {
|
||||
language: row.language,
|
||||
id: uuid(&row.id)?,
|
||||
username: row.username,
|
||||
display_name: row.display_name,
|
||||
country: row.country,
|
||||
timezone: row.timezone,
|
||||
})
|
||||
}
|
||||
pub fn record(row: WorkspaceRow) -> Result<WorkspaceRecord> {
|
||||
if row.demo_deleted_at.is_some() {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
Ok(WorkspaceRecord {
|
||||
id: uuid(&row.id)?,
|
||||
|
||||
revision: row.revision,
|
||||
document: serde_json::from_str(&row.document)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub async fn connect(url: &str) -> Result<Self> {
|
||||
let db = Database::new(url).await?;
|
||||
cot::db::migrations::MigrationEngine::new(crate::migrations::MIGRATIONS.iter().copied())?
|
||||
.run(&db)
|
||||
.await?;
|
||||
Ok(Self { db })
|
||||
}
|
||||
pub async fn workspace(&self, workspace: Uuid) -> Result<WorkspaceRecord> {
|
||||
let id = workspace.to_string();
|
||||
record(
|
||||
query!(WorkspaceRow,$id==id)
|
||||
.get(&self.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?,
|
||||
)
|
||||
}
|
||||
pub async fn permissions(&self, workspace: Uuid, actor: Uuid) -> Result<Vec<Permission>> {
|
||||
self.workspace(workspace).await?;
|
||||
let workspace = workspace.to_string();
|
||||
let actor = actor.to_string();
|
||||
let row = query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor)
|
||||
.get(&self.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
if row.is_owner {
|
||||
Ok(Permission::all())
|
||||
} else {
|
||||
Ok(serde_json::from_str(&row.permissions)?)
|
||||
}
|
||||
}
|
||||
pub async fn require(
|
||||
&self,
|
||||
workspace: Uuid,
|
||||
actor: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<Vec<Permission>> {
|
||||
let grants = self.permissions(workspace, actor).await?;
|
||||
if grants.contains(&permission) {
|
||||
Ok(grants)
|
||||
} else {
|
||||
Err(Error::Forbidden)
|
||||
}
|
||||
}
|
||||
pub async fn owner(&self, workspace: Uuid, actor: Uuid) -> Result<()> {
|
||||
self.workspace(workspace).await?;
|
||||
let workspace = workspace.to_string();
|
||||
let actor = actor.to_string();
|
||||
if query!(WorkspaceMemberRow,$workspace_id==workspace && $user_id==actor && $is_owner==true)
|
||||
.exists(&self.db)
|
||||
.await?
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Forbidden)
|
||||
}
|
||||
}
|
||||
pub async fn members(&self, workspace: Uuid) -> Result<Vec<Member>> {
|
||||
let workspace = workspace.to_string();
|
||||
let mut members = vec![];
|
||||
for m in query!(WorkspaceMemberRow,$workspace_id==workspace)
|
||||
.all(&self.db)
|
||||
.await?
|
||||
{
|
||||
let id = m.user_id;
|
||||
let row = query!(UserRow,$id==id)
|
||||
.get(&self.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
members.push(Member {
|
||||
user: user(row)?,
|
||||
permissions: if m.is_owner {
|
||||
Permission::all()
|
||||
} else {
|
||||
serde_json::from_str(&m.permissions)?
|
||||
},
|
||||
});
|
||||
}
|
||||
members.sort_by(|a, b| a.user.display_name.cmp(&b.user.display_name));
|
||||
Ok(members)
|
||||
}
|
||||
pub async fn member_ids(&self, workspace: Uuid) -> Result<BTreeSet<Uuid>> {
|
||||
Ok(self
|
||||
.members(workspace)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|m| m.user.id)
|
||||
.collect())
|
||||
}
|
||||
pub async fn directory(&self, workspace: Uuid) -> Result<Vec<User>> {
|
||||
Ok(self
|
||||
.members(workspace)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|m| m.user)
|
||||
.collect())
|
||||
}
|
||||
pub async fn working_interval(&self, workspace: Uuid) -> Result<WorkingWindow> {
|
||||
Ok(self.workspace(workspace).await?.document.working_interval)
|
||||
}
|
||||
pub async fn prepare(
|
||||
&self,
|
||||
workspace: Uuid,
|
||||
revision: i64,
|
||||
actor: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<WorkspaceRecord> {
|
||||
self.require(workspace, actor, permission).await?;
|
||||
let record = self.workspace(workspace).await?;
|
||||
if record.revision != revision {
|
||||
return Err(Error::Conflict(
|
||||
crate::i18n::text("err_this_team_changed_refresh_before_saving_7f93ad").into(),
|
||||
));
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
/// Cot 0.7 has no transaction API. A single parameterized CTE commits the CAS,
|
||||
/// membership (if any), history, audit, and durable job as one PostgreSQL statement.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn commit(
|
||||
&self,
|
||||
record: &mut WorkspaceRecord,
|
||||
actor: Uuid,
|
||||
permission: Permission,
|
||||
action: &str,
|
||||
details: Value,
|
||||
member: Option<(Uuid, Vec<Permission>)>,
|
||||
) -> Result<()> {
|
||||
let workspace = record.id.to_string();
|
||||
let actor = actor.to_string();
|
||||
let document = serde_json::to_string(&record.document)?;
|
||||
let details = details.to_string();
|
||||
let required = serde_json::to_string(&vec![permission])?;
|
||||
let member_id = member.as_ref().map(|(id, _)| id.to_string());
|
||||
let grants = serde_json::to_string(&member.map(|(_, g)| g).unwrap_or_default())?;
|
||||
let result = self
|
||||
.db
|
||||
.raw_with(
|
||||
COMMIT_WORKSPACE,
|
||||
&[
|
||||
&workspace,
|
||||
&record.revision,
|
||||
&document,
|
||||
&actor,
|
||||
&action,
|
||||
&details,
|
||||
&required,
|
||||
&member_id,
|
||||
&grants,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if result.rows_affected().0 == 0 {
|
||||
return Err(Error::Conflict(
|
||||
crate::i18n::text("err_this_team_or_your_permissions_changed_refresh_be_c1e4c2")
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
record.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn audit(&self, workspace: Uuid) -> Result<Vec<Value>> {
|
||||
let mut names = crate::audit::Names::new();
|
||||
for person in self.directory(workspace).await? {
|
||||
names.insert(person.id.to_string(), person.display_name);
|
||||
}
|
||||
let current = self.workspace(workspace).await?;
|
||||
crate::audit::remember(&mut names, ¤t.document);
|
||||
let history_team = workspace.to_string();
|
||||
let mut history = query!(HistoryRow, $workspace_id == history_team)
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
history.sort_by_key(|h| (h.effective_at, h.revision));
|
||||
let mut history = history.into_iter().peekable();
|
||||
let workspace = workspace.to_string();
|
||||
let mut events = query!(AuditRow,$workspace_id==workspace)
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
events.sort_by_key(|a| std::cmp::Reverse(a.id.unwrap()));
|
||||
events.truncate(100);
|
||||
events.sort_by_key(|a| (a.created_at, a.id.unwrap()));
|
||||
let mut output = vec![];
|
||||
for e in events {
|
||||
while history
|
||||
.peek()
|
||||
.is_some_and(|h| h.effective_at <= e.created_at)
|
||||
{
|
||||
let snapshot = history.next().unwrap();
|
||||
let document: WorkspaceDocument = serde_json::from_str(&snapshot.document)?;
|
||||
crate::audit::remember(&mut names, &document);
|
||||
}
|
||||
let actor_name = e.actor_id.as_ref().and_then(|id| names.get(id)).cloned();
|
||||
let actor = if let Some(id) = e.actor_id {
|
||||
query!(UserRow,$id==id)
|
||||
.get(&self.db)
|
||||
.await?
|
||||
.map(|u| u.username)
|
||||
.unwrap_or("Unknown".into())
|
||||
} else {
|
||||
"System".into()
|
||||
};
|
||||
let actor_name = actor_name
|
||||
.map(|name| format!("{name} (@{actor})"))
|
||||
.unwrap_or_else(|| actor.clone());
|
||||
let details: Value = serde_json::from_str(&e.details)?;
|
||||
let resolved_details = crate::audit::resolve(&details, &names);
|
||||
output.push(json!({"id":e.id.unwrap(),"action":e.action,"details":details,"resolved_details":resolved_details,"created_at":e.created_at,"actor":actor,"actor_name":actor_name}));
|
||||
}
|
||||
output.sort_by_key(|e| std::cmp::Reverse(e["id"].as_i64().unwrap_or_default()));
|
||||
Ok(output)
|
||||
}
|
||||
pub async fn sync_status(&self, workspace: Uuid) -> Result<Value> {
|
||||
let workspace = workspace.to_string();
|
||||
Ok(
|
||||
match query!(SyncJobRow,$workspace_id==workspace)
|
||||
.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})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
pub async fn historical_timeline(
|
||||
&self,
|
||||
workspace: &WorkspaceRecord,
|
||||
from: DateTime<Utc>,
|
||||
to: DateTime<Utc>,
|
||||
) -> Result<Timeline> {
|
||||
crate::error::ensure(
|
||||
from < to && (to - from) <= chrono::Duration::days(367),
|
||||
crate::i18n::text("err_invalid_calendar_range_c177b5"),
|
||||
)?;
|
||||
let id = workspace.id.to_string();
|
||||
let revision = workspace.revision;
|
||||
let mut history =
|
||||
query!(HistoryRow,$workspace_id==id && $effective_at<to && $revision<=revision)
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
history.sort_by_key(|r| (r.effective_at, r.revision));
|
||||
let mut result = Timeline {
|
||||
from,
|
||||
to,
|
||||
assignments: vec![],
|
||||
};
|
||||
for (index, row) in history.iter().enumerate() {
|
||||
let start = row.effective_at.max(from);
|
||||
let end = history
|
||||
.get(index + 1)
|
||||
.map(|r| r.effective_at)
|
||||
.unwrap_or(to)
|
||||
.min(to);
|
||||
if start < end {
|
||||
result.assignments.extend(
|
||||
crate::engine::timeline(&serde_json::from_str(&row.document)?, start, end)?
|
||||
.assignments,
|
||||
);
|
||||
}
|
||||
}
|
||||
// History records actions as well as rule changes. Adjacent identical
|
||||
// assignments remain one interval in the calendar.
|
||||
result.assignments.sort_by_key(|a| (a.schedule_id, a.start));
|
||||
let mut merged: Vec<Assignment> = vec![];
|
||||
for assignment in result.assignments {
|
||||
if let Some(last) = merged.last_mut()
|
||||
&& last.schedule_id == assignment.schedule_id
|
||||
&& last.end == assignment.start
|
||||
&& last.user_id == assignment.user_id
|
||||
&& last.reason == assignment.reason
|
||||
&& last.unavailable == assignment.unavailable
|
||||
{
|
||||
last.end = assignment.end;
|
||||
} else {
|
||||
merged.push(assignment);
|
||||
}
|
||||
}
|
||||
result.assignments = merged;
|
||||
Ok(result)
|
||||
}
|
||||
pub async fn objects(&self, workspace: Uuid) -> Result<Vec<ProviderObjectRow>> {
|
||||
let workspace = workspace.to_string();
|
||||
Ok(query!(ProviderObjectRow,$workspace_id==workspace)
|
||||
.all(&self.db)
|
||||
.await?)
|
||||
}
|
||||
pub async fn checkpoint(
|
||||
&self,
|
||||
workspace: Uuid,
|
||||
key: &str,
|
||||
remote_id: &str,
|
||||
content: &Value,
|
||||
) -> Result<()> {
|
||||
ProviderObjectRow {
|
||||
id: format!("{workspace}:{key}"),
|
||||
workspace_id: workspace.to_string(),
|
||||
object_key: key.into(),
|
||||
remote_id: remote_id.into(),
|
||||
content: content.to_string(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
.save(&self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn forget(&self, workspace: Uuid, key: &str) -> Result<()> {
|
||||
let id = format!("{workspace}:{key}");
|
||||
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)
|
||||
.await?;
|
||||
let mut record = self.workspace(workspace).await?;
|
||||
self.commit(
|
||||
&mut record,
|
||||
actor,
|
||||
Permission::ManageIntegration,
|
||||
"publication.requested",
|
||||
json!({}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
const COMMIT_WORKSPACE: &str = r#"
|
||||
WITH updated AS (
|
||||
UPDATE meguri__workspaces SET document=$3,revision=revision+1
|
||||
WHERE id=$1 AND revision=$2 AND demo_deleted_at IS NULL
|
||||
AND 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)
|
||||
SELECT $1||':'||$8,$1,$8,$9,false FROM updated WHERE $8::text IS NOT NULL
|
||||
ON CONFLICT(id) DO UPDATE SET permissions=EXCLUDED.permissions
|
||||
), 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(),$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
|
||||
)
|
||||
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
|
||||
"#;
|
||||
+841
@@ -0,0 +1,841 @@
|
||||
use crate::{
|
||||
domain::*,
|
||||
engine,
|
||||
error::{Error, Result, ensure},
|
||||
models::*,
|
||||
service,
|
||||
store::{self, Store},
|
||||
};
|
||||
use chrono::{Duration, NaiveDate, Utc};
|
||||
use cot::{
|
||||
App, AppBuilder, Method, Project, StatusCode,
|
||||
db::query,
|
||||
html::Html,
|
||||
json::Json,
|
||||
project::RegisterAppsContext,
|
||||
request::{Request, RequestExt},
|
||||
response::{IntoResponse, Response},
|
||||
router::{Route, Router},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct MeguriApp;
|
||||
impl App for MeguriApp {
|
||||
fn name(&self) -> &str {
|
||||
"meguri"
|
||||
}
|
||||
fn migrations(&self) -> Vec<Box<cot::db::migrations::SyncDynMigration>> {
|
||||
if enabled("MEGURI_SKIP_MIGRATIONS") {
|
||||
vec![]
|
||||
} else {
|
||||
cot::db::migrations::wrap_migrations(crate::migrations::MIGRATIONS)
|
||||
}
|
||||
}
|
||||
fn router(&self) -> Router {
|
||||
let mut routes = vec![
|
||||
Route::with_handler("/", index),
|
||||
Route::with_handler("/assets/app.css", css),
|
||||
Route::with_handler("/assets/app.js", 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),
|
||||
Route::with_handler("/assets/i18n.js", i18n_js),
|
||||
Route::with_handler("/assets/alpine.js", alpine),
|
||||
Route::with_handler("/health/live", live),
|
||||
Route::with_handler("/health/ready", ready),
|
||||
];
|
||||
for path in [
|
||||
"/api/session",
|
||||
"/api/register",
|
||||
"/api/login",
|
||||
"/api/logout",
|
||||
"/api/account",
|
||||
"/api/workspaces",
|
||||
"/api/workspaces/{workspace}",
|
||||
"/api/workspaces/{workspace}/working-interval",
|
||||
"/api/workspaces/{workspace}/calendar",
|
||||
"/api/workspaces/{workspace}/config",
|
||||
"/api/workspaces/{workspace}/schedules/{schedule}/removal",
|
||||
"/api/workspaces/{workspace}/schedules/{schedule}/remove",
|
||||
"/api/workspaces/{workspace}/routes/order",
|
||||
"/api/workspaces/{workspace}/absence-preview",
|
||||
"/api/workspaces/{workspace}/absences",
|
||||
"/api/workspaces/{workspace}/generation-preview",
|
||||
"/api/workspaces/{workspace}/generation",
|
||||
"/api/workspaces/{workspace}/members",
|
||||
"/api/workspaces/{workspace}/requests",
|
||||
"/api/workspaces/{workspace}/request-preview",
|
||||
"/api/workspaces/{workspace}/requests/{request}",
|
||||
"/api/workspaces/{workspace}/integration",
|
||||
"/api/workspaces/{workspace}/sync",
|
||||
"/api/workspaces/{workspace}/preview",
|
||||
"/api/workspaces/{workspace}/holiday-preview",
|
||||
"/api/workspaces/{workspace}/holiday-import",
|
||||
] {
|
||||
routes.push(Route::with_handler(path, api));
|
||||
}
|
||||
Router::with_urls(routes)
|
||||
}
|
||||
}
|
||||
pub struct MeguriProject;
|
||||
impl Project for MeguriProject {
|
||||
fn register_apps(&self, apps: &mut AppBuilder, _: &RegisterAppsContext) {
|
||||
apps.register_with_views(MeguriApp, "");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled(name: &str) -> bool {
|
||||
std::env::var(name).is_ok_and(|v| v == "1" || v == "true")
|
||||
}
|
||||
fn origin() -> String {
|
||||
std::env::var("MEGURI_PUBLIC_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:3000".into())
|
||||
.trim_end_matches('/')
|
||||
.to_owned()
|
||||
}
|
||||
fn response(value: Value) -> Result<Response> {
|
||||
Json(value)
|
||||
.into_response()
|
||||
.map_err(|e| Error::Internal(e.to_string()))
|
||||
}
|
||||
fn secured(response: Response) -> cot::Result<Response> {
|
||||
response
|
||||
.with_header("X-Content-Type-Options", "nosniff")
|
||||
.with_header("X-Frame-Options", "DENY")
|
||||
.with_header("Referrer-Policy", "same-origin")
|
||||
.with_header("Cache-Control", "no-store")
|
||||
.into_response()
|
||||
}
|
||||
async fn index() -> cot::Result<Response> {
|
||||
let html = include_str!("../static/index.html")
|
||||
.replace(
|
||||
"<!-- GENERATION_MAP -->",
|
||||
include_str!("../static/generation-map.html"),
|
||||
)
|
||||
.replace(
|
||||
"<!-- RESOURCE_INVENTORY -->",
|
||||
include_str!("../static/resource-inventory.html"),
|
||||
)
|
||||
.replace(
|
||||
"<!-- RESOURCE_DETAILS -->",
|
||||
include_str!("../static/resource-details.html"),
|
||||
)
|
||||
.replace(
|
||||
"<!-- GENERATION_PAGE -->",
|
||||
include_str!("../static/generation-page.html"),
|
||||
)
|
||||
.replace(
|
||||
"<!-- GENERATION_MODALS -->",
|
||||
include_str!("../static/generation-modals.html"),
|
||||
)
|
||||
.replace(
|
||||
"<!-- GENERATION_PREVIEW -->",
|
||||
include_str!("../static/generation-preview.html"),
|
||||
)
|
||||
.replace(
|
||||
"<!-- BINDING_POPOVER -->",
|
||||
include_str!("../static/binding-popover.html"),
|
||||
)
|
||||
.replace(
|
||||
"<!-- VALIDATION_STATUS -->",
|
||||
include_str!("../static/validation-status.html"),
|
||||
);
|
||||
secured(Html::new(html).with_header("Content-Security-Policy","default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'").into_response()?)
|
||||
}
|
||||
async fn generation_map_js() -> cot::Result<Response> {
|
||||
include_str!("../static/generation-map.js")
|
||||
.with_content_type("text/javascript; charset=utf-8")
|
||||
.into_response()
|
||||
}
|
||||
async fn cytoscape_js() -> cot::Result<Response> {
|
||||
include_str!("../static/vendor/cytoscape.js")
|
||||
.with_content_type("text/javascript; charset=utf-8")
|
||||
.into_response()
|
||||
}
|
||||
async fn generation_js() -> cot::Result<Response> {
|
||||
include_str!("../static/generation.js")
|
||||
.with_content_type("text/javascript; charset=utf-8")
|
||||
.into_response()
|
||||
}
|
||||
async fn i18n_js() -> cot::Result<Response> {
|
||||
format!(
|
||||
"const MEGURI_MESSAGES = Object.freeze({});\n{}",
|
||||
include_str!("../static/locales/messages.json"),
|
||||
include_str!("../static/i18n.js")
|
||||
)
|
||||
.with_content_type("text/javascript; charset=utf-8")
|
||||
.into_response()
|
||||
}
|
||||
async fn css() -> cot::Result<Response> {
|
||||
include_str!("../static/app.css")
|
||||
.with_content_type("text/css; charset=utf-8")
|
||||
.into_response()
|
||||
}
|
||||
async fn js() -> cot::Result<Response> {
|
||||
include_str!("../static/app.js")
|
||||
.with_content_type("text/javascript; charset=utf-8")
|
||||
.into_response()
|
||||
}
|
||||
async fn alpine() -> cot::Result<Response> {
|
||||
include_str!("../static/vendor/alpine.js")
|
||||
.with_content_type("text/javascript; charset=utf-8")
|
||||
.into_response()
|
||||
}
|
||||
async fn live() -> Json<Value> {
|
||||
Json(json!({"status":"ok"}))
|
||||
}
|
||||
async fn ready(request: Request) -> cot::Result<Response> {
|
||||
match request.context().database().raw("SELECT 1").await {
|
||||
Ok(_) => Json(json!({"status":"ready"})).into_response(),
|
||||
Err(_) => Json(json!({"status":"unavailable"}))
|
||||
.with_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Identity {
|
||||
pub user: User,
|
||||
pub csrf: String,
|
||||
pub token_hash: String,
|
||||
}
|
||||
|
||||
async fn authenticate(db: &Store, request: &Request) -> Result<Identity> {
|
||||
let cookie = request
|
||||
.headers()
|
||||
.get("cookie")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
let token = cookie
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.find_map(|c| c.strip_prefix("meguri_session="))
|
||||
.ok_or(Error::Unauthorized)?;
|
||||
if token.len() != 64 {
|
||||
return Err(Error::Unauthorized);
|
||||
}
|
||||
let hash = service::token_hash(token);
|
||||
let now = Utc::now();
|
||||
let session = query!(SessionRow,$token_hash==hash.clone() && $expires_at>now)
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.ok_or(Error::Unauthorized)?;
|
||||
let id = session.user_id;
|
||||
let user = query!(UserRow,$id==id)
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.ok_or(Error::Unauthorized)?;
|
||||
if user.demo_deleted_at.is_some() {
|
||||
return Err(Error::Unauthorized);
|
||||
}
|
||||
Ok(Identity {
|
||||
user: store::user(user)?,
|
||||
csrf: session.csrf,
|
||||
token_hash: hash,
|
||||
})
|
||||
}
|
||||
|
||||
async fn api(request: Request) -> cot::Result<Response> {
|
||||
let result = dispatch(request).await;
|
||||
secured(match result {
|
||||
Ok(response) => response,
|
||||
Err(error) => error.into_response()?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn dispatch(request: Request) -> Result<Response> {
|
||||
let db = Store {
|
||||
db: request.context().database().clone(),
|
||||
};
|
||||
let method = request.method().clone();
|
||||
let path = request.uri().path().to_owned();
|
||||
let query_string = request.uri().query().unwrap_or_default().to_owned();
|
||||
let mutation = method != Method::GET && method != Method::HEAD;
|
||||
if mutation {
|
||||
if request
|
||||
.headers()
|
||||
.get("origin")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
!= Some(origin().as_str())
|
||||
{
|
||||
return Err(Error::Forbidden);
|
||||
}
|
||||
ensure(
|
||||
request
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|s| s.starts_with("application/json")),
|
||||
crate::i18n::text("err_send_a_json_request_34adc9"),
|
||||
)?;
|
||||
}
|
||||
let identity = authenticate(&db, &request).await;
|
||||
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()}),
|
||||
),
|
||||
Err(Error::Unauthorized) => response(
|
||||
json!({"user":null,"allow_signup":enabled("MEGURI_ALLOW_SIGNUP"),"demo":crate::demo_runtime::active(),"demo_lifecycle":crate::demo_runtime::notice()}),
|
||||
),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
}
|
||||
let anonymous = path == "/api/login" || path == "/api/register";
|
||||
if mutation && !anonymous {
|
||||
let i = identity.as_ref().map_err(|_| Error::Unauthorized)?;
|
||||
if request
|
||||
.headers()
|
||||
.get("x-csrf-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
!= Some(&i.csrf)
|
||||
{
|
||||
return Err(Error::Forbidden);
|
||||
}
|
||||
}
|
||||
let body: Value = if mutation {
|
||||
let bytes = request
|
||||
.into_body()
|
||||
.into_bytes_limited(1_048_576)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
Error::Validation(crate::i18n::text("err_request_body_is_too_large_fda0d1").into())
|
||||
})?;
|
||||
serde_json::from_slice(&bytes).map_err(|_| {
|
||||
Error::Validation(crate::i18n::text("err_invalid_json_request_650225").into())
|
||||
})?
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
if anonymous && method == Method::POST {
|
||||
#[derive(Deserialize)]
|
||||
struct Credentials {
|
||||
username: String,
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
display_name: String,
|
||||
}
|
||||
let credentials: Credentials = decode(body)?;
|
||||
check_login_rate(&credentials.username)?;
|
||||
if path == "/api/register" {
|
||||
if !enabled("MEGURI_ALLOW_SIGNUP") {
|
||||
return Err(Error::Forbidden);
|
||||
}
|
||||
service::register(
|
||||
&db,
|
||||
credentials.username.clone(),
|
||||
credentials.display_name,
|
||||
credentials.password.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let (user, token, csrf) =
|
||||
service::login(&db, credentials.username, credentials.password).await?;
|
||||
let secure = if origin().starts_with("https://") {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return response(json!({"user":user,"csrf":csrf}))?
|
||||
.with_header(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
"meguri_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200{secure}"
|
||||
),
|
||||
)
|
||||
.into_response()
|
||||
.map_err(|e| Error::Internal(e.to_string()));
|
||||
}
|
||||
let identity = identity?;
|
||||
let actor = identity.user.id;
|
||||
if path == "/api/logout" && method == Method::POST {
|
||||
let token = identity.token_hash;
|
||||
query!(SessionRow,$token_hash==token).delete(&db.db).await?;
|
||||
return response(json!({"ok":true}))?
|
||||
.with_header(
|
||||
"Set-Cookie",
|
||||
"meguri_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
|
||||
)
|
||||
.into_response()
|
||||
.map_err(|e| Error::Internal(e.to_string()));
|
||||
}
|
||||
if path == "/api/workspaces" && method == Method::GET {
|
||||
return response(service::list_workspaces(&db, actor).await?);
|
||||
}
|
||||
if path == "/api/account" && method == Method::PUT {
|
||||
let name = body["display_name"].as_str().unwrap_or_default();
|
||||
validate_name(name)?;
|
||||
let id = actor.to_string();
|
||||
let mut user = query!(UserRow,$id==id)
|
||||
.get(&db.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
if let Some(password) = body["password"].as_str().filter(|p| !p.is_empty()) {
|
||||
let current = body["current_password"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let (_, temporary, _) = service::login(&db, user.username.clone(), current).await?;
|
||||
let hash = service::token_hash(&temporary);
|
||||
query!(SessionRow,$token_hash==hash).delete(&db.db).await?;
|
||||
user.password_hash = service::hash_password(password.to_owned()).await?;
|
||||
}
|
||||
user.display_name = name.into();
|
||||
if let Some(language) = body.get("language") {
|
||||
ensure(
|
||||
language.is_null() || language.is_string(),
|
||||
crate::i18n::text("language_invalid"),
|
||||
)?;
|
||||
user.language = language
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned);
|
||||
ensure(
|
||||
user.language
|
||||
.as_deref()
|
||||
.is_none_or(|l| ["en", "ru", "fr"].contains(&l)),
|
||||
crate::i18n::text("language_invalid"),
|
||||
)?;
|
||||
}
|
||||
user.country = text(&body, "country")?.to_ascii_uppercase();
|
||||
user.timezone = body["timezone"]
|
||||
.as_str()
|
||||
.filter(|z| !z.is_empty())
|
||||
.map(str::to_owned);
|
||||
ensure(
|
||||
valid_country(&user.country),
|
||||
crate::i18n::text("choose_a_country_7843c8"),
|
||||
)?;
|
||||
ensure(
|
||||
user.timezone
|
||||
.as_ref()
|
||||
.is_none_or(|z| z.parse::<chrono_tz::Tz>().is_ok()),
|
||||
crate::i18n::text("err_choose_a_valid_iana_timezone_176bd9"),
|
||||
)?;
|
||||
service::save_profile(&db, &user).await?;
|
||||
return response(json!({"user":store::user(user)?}));
|
||||
}
|
||||
let parts: Vec<_> = path.trim_matches('/').split('/').collect();
|
||||
if parts == ["api", "workspaces"] && method == Method::POST {
|
||||
return response(serde_json::to_value(
|
||||
service::create_workspace(&db, actor, text(&body, "name")?).await?,
|
||||
)?);
|
||||
}
|
||||
if parts.len() < 3 || parts[1] != "workspaces" {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
let workspace = parse_id(parts[2])?;
|
||||
if parts.len() == 4 && parts[3] == "working-interval" && method == Method::PUT {
|
||||
service::set_working_interval(
|
||||
&db,
|
||||
actor,
|
||||
workspace,
|
||||
revision(&body)?,
|
||||
decode(body["interval"].clone())?,
|
||||
)
|
||||
.await?;
|
||||
return response(json!({"ok":true}));
|
||||
}
|
||||
if parts.len() == 3 && method == Method::PATCH {
|
||||
service::set_drift_policy(
|
||||
&db,
|
||||
actor,
|
||||
workspace,
|
||||
revision(&body)?,
|
||||
body["overwrite_drift"].as_bool().ok_or_else(|| {
|
||||
Error::Validation(crate::i18n::text("err_choose_a_drift_policy_5a1cd6").into())
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
return response(json!({"ok":true}));
|
||||
}
|
||||
let grants = db.require(workspace, actor, Permission::View).await?;
|
||||
if parts.len() == 3 && method == Method::GET {
|
||||
let record = db.workspace(workspace).await?;
|
||||
let audit = if grants.contains(&Permission::ViewAudit) {
|
||||
db.audit(workspace).await?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let now = Utc::now();
|
||||
let coverage = if record
|
||||
.document
|
||||
.absences
|
||||
.iter()
|
||||
.any(|a| a.end >= now.date_naive() - Duration::days(2))
|
||||
{
|
||||
engine::publication_window(&record.document, now)
|
||||
.and_then(|(from, to)| engine::timeline(&record.document, from, to))
|
||||
.map(|timeline| {
|
||||
let gaps: Vec<_> = timeline.assignments.into_iter().filter(|a| a.unavailable && a.end > now).collect();
|
||||
json!({"total":gaps.len(),"issues":gaps.into_iter().take(50).collect::<Vec<_>>()})
|
||||
}).unwrap_or_else(|e| json!({"total":0,"issues":[],"error":e.to_string()}))
|
||||
} else {
|
||||
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}),
|
||||
);
|
||||
}
|
||||
let action = parts.get(3).copied().unwrap_or_default();
|
||||
if parts.len() == 4 && method == Method::POST && action == "generation-preview" {
|
||||
return response(
|
||||
crate::generation_service::preview(&db, actor, workspace, decode(body)?).await?,
|
||||
);
|
||||
}
|
||||
if parts.len() == 4 && method == Method::POST && action == "generation" {
|
||||
return response(
|
||||
crate::generation_service::apply(&db, actor, workspace, decode(body)?).await?,
|
||||
);
|
||||
}
|
||||
if parts.len() == 4 && method == Method::POST && action == "absence-preview" {
|
||||
return response(service::preview_absence(&db, actor, workspace, decode(body)?).await?);
|
||||
}
|
||||
if parts.len() == 4 && method == Method::POST && action == "absences" {
|
||||
return response(serde_json::to_value(
|
||||
service::create_absence(&db, actor, workspace, decode(body)?).await?,
|
||||
)?);
|
||||
}
|
||||
if action == "schedules" && parts.len() == 6 {
|
||||
let schedule = parse_id(parts[4])?;
|
||||
if parts[5] == "removal" && method == Method::GET {
|
||||
db.require(workspace, actor, Permission::EditSchedules)
|
||||
.await?;
|
||||
let record = db.workspace(workspace).await?;
|
||||
return response(
|
||||
json!({"revision":record.revision,"plan":crate::configuration::schedule_removal_plan(&record.document,schedule,Utc::now())?}),
|
||||
);
|
||||
}
|
||||
if parts[5] == "remove" && method == Method::POST {
|
||||
return response(serde_json::to_value(
|
||||
service::remove_schedule(&db, actor, workspace, schedule, decode(body)?).await?,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
if action == "routes" && parts.len() == 5 && parts[4] == "order" && method == Method::PUT {
|
||||
return response(serde_json::to_value(
|
||||
service::reorder_routes(&db, actor, workspace, decode(body)?).await?,
|
||||
)?);
|
||||
}
|
||||
if action == "holiday-preview" && method == Method::POST {
|
||||
db.require(workspace, actor, Permission::EditSchedules)
|
||||
.await?;
|
||||
let record = db.workspace(workspace).await?;
|
||||
return response(
|
||||
json!({"revision":record.revision,"preview":crate::holidays::compare(&text(&body,"csv")?,&record.document.holidays)?}),
|
||||
);
|
||||
}
|
||||
if action == "holiday-import" && method == Method::POST {
|
||||
return response(serde_json::to_value(
|
||||
service::import_holidays(
|
||||
&db,
|
||||
actor,
|
||||
workspace,
|
||||
revision(&body)?,
|
||||
&text(&body, "csv")?,
|
||||
)
|
||||
.await?,
|
||||
)?);
|
||||
}
|
||||
if action == "calendar" && method == Method::GET {
|
||||
let record = db.workspace(workspace).await?;
|
||||
let url =
|
||||
reqwest::Url::parse(&format!("http://localhost/?{query_string}")).map_err(|_| {
|
||||
Error::Validation(crate::i18n::text("err_invalid_calendar_query_feced6").into())
|
||||
})?;
|
||||
let params: std::collections::BTreeMap<_, _> = url.query_pairs().into_owned().collect();
|
||||
let day: NaiveDate = params
|
||||
.get("from")
|
||||
.map(String::as_str)
|
||||
.unwrap_or_default()
|
||||
.parse()
|
||||
.map_err(|_| {
|
||||
Error::Validation(crate::i18n::text("err_choose_a_calendar_date_e25e18").into())
|
||||
})?;
|
||||
ensure(
|
||||
(2000..=2100).contains(&chrono::Datelike::year(&day)),
|
||||
crate::i18n::text("err_calendar_year_must_be_2000_2100_8681fc"),
|
||||
)?;
|
||||
let days: i64 = params
|
||||
.get("days")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(28);
|
||||
ensure(
|
||||
(1..=366).contains(&days),
|
||||
crate::i18n::text("err_calendar_range_must_be_1_366_days_8e177e"),
|
||||
)?;
|
||||
let schedule = params
|
||||
.get("schedule")
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| parse_id(s))
|
||||
.transpose()?
|
||||
.or_else(|| record.document.schedules.first().map(|s| s.id));
|
||||
let boundary = |date| match schedule {
|
||||
Some(id) => engine::schedule_handover(&record.document, id, date),
|
||||
None => engine::handover(&record.document, date),
|
||||
};
|
||||
let from = boundary(day)?;
|
||||
let to = boundary(day + Duration::days(days))?;
|
||||
let mut result = serde_json::to_value(db.historical_timeline(&record, from, to).await?)?;
|
||||
let mut duty_days = vec![];
|
||||
for offset in 0..days {
|
||||
let date = day + Duration::days(offset);
|
||||
duty_days.push(json!({"date":date,"start":boundary(date)?,"end":boundary(date+Duration::days(1))?}));
|
||||
}
|
||||
result["days"] = json!(duty_days);
|
||||
return response(result);
|
||||
}
|
||||
if action == "preview" && method == Method::GET {
|
||||
db.require(workspace, actor, Permission::ManageIntegration)
|
||||
.await?;
|
||||
let record = db.workspace(workspace).await?;
|
||||
let (from, to) = engine::publication_window(&record.document, Utc::now())?;
|
||||
return response(serde_json::to_value(crate::providers::compile(
|
||||
&record,
|
||||
db.historical_timeline(&record, from, to).await?,
|
||||
Utc::now(),
|
||||
)?)?);
|
||||
}
|
||||
if action == "config" && method == Method::PUT {
|
||||
return response(serde_json::to_value(
|
||||
service::update_document(
|
||||
&db,
|
||||
actor,
|
||||
workspace,
|
||||
revision(&body)?,
|
||||
decode(body["document"].clone())?,
|
||||
)
|
||||
.await?,
|
||||
)?);
|
||||
}
|
||||
if action == "integration" && method == Method::PUT {
|
||||
return response(serde_json::to_value(
|
||||
service::update_integration(
|
||||
&db,
|
||||
actor,
|
||||
workspace,
|
||||
revision(&body)?,
|
||||
decode(body["integration"].clone())?,
|
||||
)
|
||||
.await?,
|
||||
)?);
|
||||
}
|
||||
if action == "members" && method == Method::PUT {
|
||||
return response(serde_json::to_value(
|
||||
service::set_member(
|
||||
&db,
|
||||
actor,
|
||||
workspace,
|
||||
revision(&body)?,
|
||||
text(&body, "username")?,
|
||||
decode(body["permissions"].clone())?,
|
||||
)
|
||||
.await?,
|
||||
)?);
|
||||
}
|
||||
if ["requests", "request-preview"].contains(&action)
|
||||
&& parts.len() == 4
|
||||
&& method == Method::POST
|
||||
{
|
||||
let mut body = body;
|
||||
if body.get("legs").is_none() {
|
||||
let record = db.workspace(workspace).await?;
|
||||
let from = parse_id(&text(&body, "from_user")?)?;
|
||||
let to = parse_id(&text(&body, "to_user")?)?;
|
||||
let schedule = parse_id(&text(&body, "schedule_id")?)?;
|
||||
let leg = |start: &str,
|
||||
end: &str,
|
||||
start_clock: &str,
|
||||
end_clock: &str,
|
||||
from,
|
||||
to|
|
||||
-> Result<ReplacementLeg> {
|
||||
let start: NaiveDate = text(&body, start)?.parse().map_err(|_| {
|
||||
Error::Validation(crate::i18n::text("err_invalid_start_date_92aea1").into())
|
||||
})?;
|
||||
let end: NaiveDate = text(&body, end)?.parse().map_err(|_| {
|
||||
Error::Validation(crate::i18n::text("err_invalid_end_date_68eb79").into())
|
||||
})?;
|
||||
Ok(ReplacementLeg {
|
||||
schedule_id: schedule,
|
||||
start: replacement_boundary(
|
||||
&record.document,
|
||||
schedule,
|
||||
start,
|
||||
body.get(start_clock),
|
||||
)?,
|
||||
end: replacement_boundary(
|
||||
&record.document,
|
||||
schedule,
|
||||
end,
|
||||
body.get(end_clock),
|
||||
)?,
|
||||
from_user: from,
|
||||
to_user: to,
|
||||
})
|
||||
};
|
||||
let mut legs = vec![leg(
|
||||
"start_day",
|
||||
"end_day",
|
||||
"start_time",
|
||||
"end_time",
|
||||
from,
|
||||
to,
|
||||
)?];
|
||||
if body["swap"].as_bool() == Some(true) {
|
||||
legs.push(leg(
|
||||
"return_start",
|
||||
"return_end",
|
||||
"return_start_time",
|
||||
"return_end_time",
|
||||
to,
|
||||
from,
|
||||
)?);
|
||||
}
|
||||
body["legs"] = serde_json::to_value(legs)?;
|
||||
}
|
||||
let input: service::NewRequest = decode(body)?;
|
||||
if action == "request-preview" {
|
||||
return response(service::preview_replacement(&db, actor, workspace, input).await?);
|
||||
}
|
||||
return response(serde_json::to_value(
|
||||
service::request_replacement(&db, actor, workspace, input).await?,
|
||||
)?);
|
||||
}
|
||||
if action == "requests" && parts.len() == 5 && method == Method::POST {
|
||||
return response(serde_json::to_value(
|
||||
service::decide_request(&db, actor, workspace, parse_id(parts[4])?, decode(body)?)
|
||||
.await?,
|
||||
)?);
|
||||
}
|
||||
if action == "sync" && method == Method::POST {
|
||||
db.retry(workspace, actor).await?;
|
||||
return response(json!({"ok":true}));
|
||||
}
|
||||
Err(Error::NotFound)
|
||||
}
|
||||
|
||||
fn replacement_boundary(
|
||||
doc: &WorkspaceDocument,
|
||||
schedule: Uuid,
|
||||
date: NaiveDate,
|
||||
clock: Option<&Value>,
|
||||
) -> Result<chrono::DateTime<Utc>> {
|
||||
match clock {
|
||||
None => engine::schedule_handover(doc, schedule, date),
|
||||
Some(value) => {
|
||||
let time = value
|
||||
.as_str()
|
||||
.filter(|s| s.len() == 5)
|
||||
.and_then(|s| chrono::NaiveTime::parse_from_str(s, "%H:%M").ok())
|
||||
.ok_or_else(|| Error::Validation(crate::i18n::text("cover_invalid_time").into()))?;
|
||||
engine::local_time(doc, date, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_id(value: &str) -> Result<Uuid> {
|
||||
value.parse().map_err(|_| Error::NotFound)
|
||||
}
|
||||
fn text(body: &Value, key: &str) -> Result<String> {
|
||||
body[key].as_str().map(str::to_string).ok_or_else(|| {
|
||||
Error::Validation(crate::i18n::message(
|
||||
"err_missing_key_364001",
|
||||
&[("key", (key).to_string())],
|
||||
))
|
||||
})
|
||||
}
|
||||
fn revision(body: &Value) -> Result<i64> {
|
||||
body["revision"].as_i64().ok_or_else(|| {
|
||||
Error::Validation(crate::i18n::text("err_missing_configuration_revision_07cf43").into())
|
||||
})
|
||||
}
|
||||
fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T> {
|
||||
serde_json::from_value(value).map_err(|e| {
|
||||
Error::Validation(crate::i18n::message(
|
||||
"err_invalid_request_e_2228c5",
|
||||
&[("e", (e).to_string())],
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn check_login_rate(username: &str) -> Result<()> {
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Mutex, OnceLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
static ATTEMPTS: OnceLock<Mutex<HashMap<String, (Instant, u16)>>> = OnceLock::new();
|
||||
let mut attempts = ATTEMPTS
|
||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
.lock()
|
||||
.map_err(|_| {
|
||||
Error::Internal(crate::i18n::text("err_login_limiter_unavailable_4f8aae").into())
|
||||
})?;
|
||||
attempts.retain(|_, (at, _)| at.elapsed() < Duration::from_secs(300));
|
||||
if attempts.len() > 2000 {
|
||||
return Err(Error::Validation(
|
||||
crate::i18n::text("err_too_many_sign_in_attempts_try_again_later_abceb2").into(),
|
||||
));
|
||||
}
|
||||
let entry = attempts
|
||||
.entry(username.trim().to_ascii_lowercase())
|
||||
.or_insert((Instant::now(), 0));
|
||||
entry.1 += 1;
|
||||
ensure(
|
||||
entry.1 <= 30,
|
||||
crate::i18n::text("err_too_many_sign_in_attempts_try_again_in_five_minu_cc6ccd"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod time_input_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_cover_times_use_workspace_zone_and_reject_dst_gaps_and_folds() {
|
||||
let mut doc = WorkspaceDocument::new("Test".into());
|
||||
doc.timezone = "Europe/Paris".into();
|
||||
let schedule = Uuid::new_v4();
|
||||
let day = "2026-09-24".parse().unwrap();
|
||||
let start = replacement_boundary(&doc, schedule, day, Some(&json!("13:15"))).unwrap();
|
||||
let end = replacement_boundary(&doc, schedule, day, Some(&json!("15:45"))).unwrap();
|
||||
assert_eq!(start.to_rfc3339(), "2026-09-24T11:15:00+00:00");
|
||||
assert_eq!(end - start, Duration::minutes(150));
|
||||
for date in ["2026-03-29", "2026-10-25"] {
|
||||
assert!(
|
||||
replacement_boundary(&doc, schedule, date.parse().unwrap(), Some(&json!("02:30")))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for bad in [
|
||||
json!(""),
|
||||
json!("24:01"),
|
||||
json!("13:00:01"),
|
||||
json!(null),
|
||||
json!(7),
|
||||
] {
|
||||
assert!(replacement_boundary(&doc, schedule, day, Some(&bad)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_calendar_day_across_dst_uses_wall_clock_endpoints() {
|
||||
let mut doc = WorkspaceDocument::new("Test".into());
|
||||
doc.timezone = "Europe/Paris".into();
|
||||
let schedule = Uuid::new_v4();
|
||||
let clock = json!("11:00");
|
||||
for (from, to, hours) in [
|
||||
("2026-03-28", "2026-03-29", 23),
|
||||
("2026-10-24", "2026-10-25", 25),
|
||||
] {
|
||||
let start =
|
||||
replacement_boundary(&doc, schedule, from.parse().unwrap(), Some(&clock)).unwrap();
|
||||
let end =
|
||||
replacement_boundary(&doc, schedule, to.parse().unwrap(), Some(&clock)).unwrap();
|
||||
assert_eq!((end - start).num_hours(), hours);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use crate::{
|
||||
engine,
|
||||
error::{Error, Result},
|
||||
models::SyncJobRow,
|
||||
providers,
|
||||
store::{Store, uuid},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use cot::db::query;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn run(db: Store) {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(error) = tick(&db).await {
|
||||
tracing::error!(%error,"publication worker iteration failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.sort_by_key(|job| job.next_attempt);
|
||||
for job in jobs.into_iter().take(20) {
|
||||
let token = Uuid::new_v4().to_string();
|
||||
// Publication is bounded to four minutes; a five-minute lease survives a lost worker.
|
||||
let claimed=db.db.raw_with("UPDATE meguri__sync_jobs SET lease_token=$1,lease_until=now()+interval '5 minutes',state='publishing' WHERE workspace_id=$2 AND (lease_until IS NULL OR lease_until<now()) AND next_attempt<=now()",&[&token,&job.workspace_id]).await?;
|
||||
if claimed.rows_affected().0 == 0 {
|
||||
continue;
|
||||
}
|
||||
let workspace = uuid(&job.workspace_id)?;
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(240),
|
||||
publish_one(db, workspace),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
Err(Error::Provider(
|
||||
crate::i18n::text("err_publication_time_limit_reached_completed_steps_a_e404c5")
|
||||
.into(),
|
||||
))
|
||||
});
|
||||
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?;
|
||||
}
|
||||
Err(error) => {
|
||||
let conflict = matches!(error, Error::Conflict(_));
|
||||
let state = if conflict { "conflict" } else { "failed" };
|
||||
let delay = if conflict {
|
||||
3600
|
||||
} else {
|
||||
(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?;
|
||||
tracing::warn!(workspace=%job.workspace_id,%error,"publication did not complete");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn publish_one(db: &Store, workspace: Uuid) -> Result<(i64, chrono::DateTime<Utc>)> {
|
||||
let record = db.workspace(workspace).await?;
|
||||
let now = Utc::now();
|
||||
let (from, to) = engine::publication_window(&record.document, now)?;
|
||||
let timeline = db.historical_timeline(&record, from, to).await?;
|
||||
let plan = providers::compile(&record, timeline, now)?;
|
||||
providers::publish(db, &record, &plan, record.document.overwrite_drift).await?;
|
||||
Ok((record.revision, to))
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
|
||||
:root{font-family:Inter,Segoe UI,Arial,sans-serif;color:#243a36;background:#f7f9f8;font-size:14px;--green:#176b52;--ink:#233d35;--muted:#7d8a85;--line:#e3e9e5;--soft:#edf3ef}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer;background:white;border:1px solid #dbe3de;border-radius:7px;padding:9px 13px;transition:background .15s}button:hover{background:#eef4f0}button:disabled{opacity:.5;cursor:wait}button.primary{background:var(--green);color:white;border-color:var(--green);font-weight:600}button.primary:hover{background:#12543f}button.text{border:0;background:none;padding:6px 3px;color:var(--green)}button.danger{color:#a55252}input,select,textarea{border:1px solid #d8e1da;border-radius:7px;background:white;padding:11px 12px;max-width:100%;outline:none}input:focus,select:focus,textarea:focus,button:focus-visible{outline:2px solid #4f9d7e;outline-offset:2px}textarea{min-height:85px;resize:vertical}label{display:flex;flex-direction:column;gap:8px;font-size:13px;font-weight:600;margin:16px 0}label.check{flex-direction:row;align-items:center;font-weight:400;gap:9px}input[type=checkbox]{accent-color:var(--green);width:16px;height:16px}h1,h2,p{margin-top:0}h1{font-size:30px;letter-spacing:-1px;font-weight:600;margin-bottom:9px}h2{font-size:17px;letter-spacing:-.3px;font-weight:600;margin-bottom:12px}p{line-height:1.6}small{font-size:12px}.muted,small{color:var(--muted)}.eyebrow{font-size:10px;letter-spacing:1.8px;font-weight:700;color:#788b81}.brand{display:flex;align-items:center;gap:10px;color:#215b45;text-decoration:none;font-size:38px}.brand span{font-size:28px;font-weight:650;letter-spacing:-1px}.shell{display:flex;min-height:100vh}.sidebar{width:236px;padding:29px 21px 15px;background:#fff;border-right:1px solid var(--line);position:fixed;top:0;bottom:0;display:flex;flex-direction:column}.workspace-label{margin-top:38px;margin-bottom:11px}.workspace-select{width:100%;font-weight:600;background:#f6f8f6;padding:12px 10px}.sidebar nav{margin-top:24px;display:flex;flex-direction:column;gap:6px}.sidebar nav :is(button,a){display:flex;align-items:center;gap:12px;border:0;background:none;text-align:left;padding:13px 12px;color:#728179;font-size:13px}.sidebar nav :is(button,a).active{background:#eaf3ed;color:#236648;font-weight:600}.nav-icon{font-size:20px;width:21px;text-align:center;font-weight:400}.counter{margin-left:auto;background:#d8e8df;padding:1px 6px;border-radius:4px;font-size:11px}.sidebar-bottom{margin-top:auto}.side-note{display:flex;align-items:center;gap:7px;font-size:10px;color:#8a958e;margin:30px 0 22px}.status-dot{display:inline-block;width:7px;height:7px;background:#639b7c;border-radius:50%;flex-shrink:0}.account-button{display:flex;width:100%;gap:10px;text-align:left;padding:14px 0;border:0;border-top:1px solid var(--line);border-radius:0;background:none}.account-button strong,.account-button small{display:block;margin:2px 0}.account-button>span:last-child{margin-left:auto;align-self:center;color:#8c9991}.avatar{width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;font-size:12px;font-weight:600}.avatar.small{width:27px;height:27px;font-size:10px}.teal{background:#e3f2ed;color:#3d846b}.purple{background:#eee9f7;color:#8874aa}.amber{background:#fcf0da;color:#a27d39}.blue{background:#e6effa;color:#5b84b4}.rose{background:#f7e8eb;color:#ac7482}.signout{font-size:11px;margin-left:47px}.main{margin-left:236px;flex:1;min-width:0}.topbar{height:78px;background:#fff;border-bottom:1px solid var(--line);padding:0 40px;display:flex;align-items:center;justify-content:space-between;font-size:12px}.slash{margin:0 14px;color:#b7c1ba}.top-actions{display:flex;gap:20px;align-items:center}.content{max-width:1460px;margin:0 auto;padding:38px 40px 45px}.page-heading{display:flex;justify-content:space-between;align-items:center;gap:15px;margin-bottom:24px}.page-heading .eyebrow{margin-bottom:10px}.page-heading p{margin:0;font-size:13px}.sync-bar{display:flex;align-items:center;gap:9px;border:1px solid #dfe9e2;background:#eef5ef;border-radius:7px;padding:11px 15px;font-size:11px;margin-bottom:25px;flex-wrap:wrap}.sync-bar .muted{margin-left:8px}.sync-bar button{margin-left:auto;font-size:11px}.sync-problem{background:#fff4e8;border-color:#edd6bb}.amber-dot{background:#c2a268}.sync-error{color:#ac733d;max-width:480px;overflow-wrap:anywhere}.summary-grid{display:grid;grid-template-columns:1.35fr 1fr 1fr;gap:18px;margin-bottom:24px}.summary-card{border:1px solid var(--line);border-radius:9px;background:#fff;padding:19px 22px;min-height:120px}.summary-card .eyebrow{display:block;margin-bottom:17px}.summary-card small{display:block;margin-top:5px}.person-line{display:flex;align-items:center;gap:12px}.person-line .avatar{width:42px;height:42px}.person-line strong{font-size:17px}.stat{font-size:23px;font-weight:600;letter-spacing:-.4px}.panel{background:white;border:1px solid var(--line);border-radLine truncated
|
||||
@media(min-width:1600px){.content{padding-top:48px}.day{min-height:130px}}@media(max-width:1200px){.sidebar{width:210px;padding:25px 16px}.main{margin-left:210px}.content{padding:28px 24px}.topbar{padding:0 24px}.page-footer{margin:0 24px}.summary-card{padding:18px 15px}.person-line strong{font-size:15px}.toolbar-controls{gap:7px}.calendar-toolbar{padding:18px 15px}.day{padding:9px 5px;min-height:102px}.assignment{padding:6px 4px;font-size:10px}}@media(max-width:950px){.sidebar{width:75px;padding:23px 10px}.sidebar .brand span,.workspace-label,.workspace-select,.sidebar nav button>span:not(.nav-icon),.side-note,.account-button>span:not(.avatar),.signout{display:none}.sidebar nav{margin-top:40px}.sidebar nav :is(button,a){padding:12px;justify-content:center}.sidebar .brand{justify-content:center}.main{margin-left:75px}.sidebar .account-button{justify-content:center}.summary-grid{gap:10px}.summary-card{padding:15px 12px}.page-heading h1{font-size:25px}.page-heading>.primary{font-size:12px}.top-actions{gap:10px}.login-story{padding:40px}.login-story h1{font-size:38px}}@media(max-width:700px){.sidebar{position:sticky;top:0;width:100%;height:auto;z-index:10;flex-direction:row;align-items:center;gap:10px;padding:8px 12px;border-right:0;border-bottom:1px solid var(--line)}.sidebar .brand{font-size:29px}.sidebar nav{margin:0;flex-direction:row;gap:0;flex:1;justify-content:center}.sidebar nav :is(button,a){padding:8px}.sidebar .nav-icon{font-size:20px;width:18px}.sidebar-bottom{margin:0}.account-button{padding:0;border:0}.account-button .avatar{width:28px;height:28px;font-size:10px}.shell{display:block}.main{margin:0}.topbar{height:auto;min-height:57px;padding:10px 15px;gap:10px;flex-wrap:wrap}.topbar strong{font-size:11px}.top-actions{margin-left:auto}.top-actions button{font-size:10px}.content{padding:25px 15px}.page-heading{align-items:flex-start;flex-direction:column}.page-heading h1{font-size:27px}.page-heading p{font-size:12px}.summary-grid{grid-template-columns:1fr 1fr}.summary-card:first-child{grid-column:1/-1}.summary-card{min-height:100px}.summary-card .eyebrow{margin-bottom:13px}.calendar-toolbar{flex-wrap:wrap;padding:16px 12px}.toolbar-controls{width:100%;justify-content:space-between}.toolbar-controls select{max-width:165px}.weekdays span{padding:10px 3px;text-align:center;font-size:8px}.day{min-height:90px;padding:6px 3px}.day-date{font-size:10px;width:20px;height:20px;line-height:20px;margin-bottom:4px}.assignment{font-size:9px;padding:6px 3px;gap:3px}.mini-dot{display:none}.calendar-footer{padding:12px;flex-direction:column;font-size:9px}.two-columns,.card-grid{grid-template-columns:1fr}.modal .two-columns{grid-template-columns:1fr 1fr}.sync-bar{font-size:10px;padding:10px}.sync-bar .muted{margin-left:0}.page-footer{margin:0 15px;gap:20px}.page-footer span{font-size:9px;letter-spacing:0}.section-line h2{font-size:15px}.rule-card{padding:20px}.login{grid-template-columns:1fr}.login-story{min-height:0;padding:28px 30px}.login-story>div{display:none}.login-story .quiet{margin-top:14px}.login-form{padding:35px 0}.modal-backdrop{padding:12px}.modal{padding:20px;max-height:calc(100dvh - 24px)}.modal-heading h2{font-size:22px}.modal-footer button{font-size:11px;padding:9px}.audit-row summary{font-size:11px}.audit-row time{margin-left:0}}
|
||||
|
||||
.mobile-workspace-select{display:none}@media(max-width:950px){.mobile-workspace-select{display:block;max-width:210px}}@media(max-width:700px){.mobile-workspace-select{width:100%;max-width:100%}}
|
||||
/* People, country calendars, and readable policy flows. */
|
||||
.person-select{display:flex;align-items:center;justify-content:space-between;gap:12px;text-align:left;min-height:44px}.wide{width:100%;margin-top:15px}.picker-results{max-height:260px;overflow:auto;border:1px solid var(--line);border-radius:8px}.picker-option{display:flex;align-items:center;gap:12px;width:100%;border:0;border-bottom:1px solid var(--line);border-radius:0;text-align:left;padding:13px}.picker-option>span:nth-child(2){flex:1;min-width:0;overflow-wrap:anywhere}.picker-option strong,.picker-option small{display:block}.picker-option small{margin-top:5px}.picker-option .badge{font-size:9px}.email-contact{margin-top:20px;padding:18px;background:#f3f7f4;border-radius:8px}.person-picker .section-line{margin-top:0}.person-picker h3{margin:0}.person-picker .primary{overflow-wrap:anywhere;max-width:100%}.holiday-group{margin:12px 0 18px}.holiday-group h3{display:flex;gap:8px;align-items:center;font-size:14px;border-bottom:1px solid var(--line);padding-bottom:12px}.holiday-group .list-row{padding:10px 0}.csv-input{min-height:200px;font-family:Consolas,monospace;font-size:12px;line-height:1.6}.import-summary{display:flex;gap:10px;flex-wrap:wrap;padding:15px;background:#eaf4ed;border-radius:8px;font-size:12px}.import-summary strong{color:var(--green)}.import-table{max-height:260px;overflow:auto;border:1px solid var(--line);border-radius:8px}.import-table>div{display:grid;grid-template-columns:1fr 100px 1.5fr;gap:10px;padding:10px;border-bottom:1px solid var(--line);font-size:12px;overflow-wrap:anywhere}.import-table-head{background:#f2f6f2;font-weight:600;position:sticky;top:0}.validation-error,.validation-warning{padding:9px 12px;border-radius:6px;font-size:12px}.validation-error{background:#fff0eb;color:#9c492f}.validation-warning{background:#fff6e5;color:#806136}.danger-text{color:#9c492f}.flow-origin{display:inline-block;border:1px dashed #a9c4b2;color:#678373;border-radius:20px;padding:7px 12px;font-size:11px}.flow-arrow{color:#789184;font-size:11px;padding:9px 0;display:flex;gap:10px;align-items:center}.flow-arrow>span:first-child{font-size:22px;line-height:1}.flow-node{display:flex;align-items:center;gap:11px;background:#f3f7f4;border:1px solid #dce8df;border-radius:8px;padding:13px;font-size:12px}.step-number{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;background:#dcecdf;color:#407553;font-size:11px;flex-shrink:0}.escalation-flow{margin:18px 0}.coverage-flow>div{display:flex;align-items:center;gap:10px;font-size:12px}.coverage-flow b{display:inline-grid;place-items:center;width:23px;height:23px;background:#edf3ef;border-radius:50%;font-size:11px}.coverage-flow small{margin-left:auto}.coverage-flow .flow-arrow{padding-left:7px}.routing-list{display:grid;gap:12px}.routing-card{padding:18px 21px}.route-heading{display:flex;align-items:center;gap:12px}.route-heading button{font-size:11px}.route-flow{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:15px;font-size:12px}.logic-key{font-size:10px;font-weight:700;letter-spacing:1px;color:#468364}.logic-conditions{display:flex;align-items:center;flex-wrap:wrap;gap:7px;flex:1;min-width:140px}.condition-chip{display:inline-block;padding:7px 9px;border:1px solid #dae5df;border-radius:5px;background:#f5f8f6;overflow-wrap:anywhere;max-width:100%;font-size:11px}.condition-chip.hours{background:#f9f3e8;color:#967444;border-color:#ebdec3}.condition-group{display:inline-flex;align-items:center;gap:7px;max-width:100%}.logic-and{font-size:9px;font-weight:700;color:#93a399;letter-spacing:.5px}.route-result{padding:9px 12px;border-radius:6px;background:#eaf3ed;color:#33744e;font-weight:600;max-width:100%;overflow-wrap:anywhere}.horizontal{font-size:23px}.logic-editor{background:#f7f9f7;border:1px solid #e0e8e1;border-radius:9px;padding:15px;margin-top:20px}.logic-header{display:flex;gap:10px;align-items:center;font-size:12px;flex-wrap:wrap}.condition-editor{display:grid;grid-template-columns:32px minmax(75px,1fr) minmax(85px,1fr) minmax(90px,1.1fr) auto 28px;gap:6px;align-items:center;margin:12px 0}.condition-editor select,.condition-editor input:not([type=checkbox]){width:100%;max-width:none;font-size:11px;padding:8px 6px;min-width:0}.condition-editor label{margin:0;font-size:11px;gap:4px}.condition-editor button{padding:5px}.day-clock{font-size:9px;color:#899b8e;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.day{min-height:135px}.rule-card p,.list-row strong{overflow-wrap:anywhere}.step-editor .flow-arrow{border-bottom:1px solid var(--line);margin-top:12px}.modal details summary{cursor:pointer;font-size:12px;color:var(--green);margin:10px 0}.section-line .actions{margin-top:0}.display-zone{font-size:10px;color:#6f8779}.topbar{flex-wrap:wrap;gap:8px}.topbar .top-actions{margin-left:auto}
|
||||
@media(max-width:700px){.condition-editor{grid-template-columns:1fr 1fr 1fr}.condition-editor>.logic-and{grid-column:1/-1}.condition-editor label{justify-self:start}.condition-editor button{justify-self:end}.condition-editor input:not([type=checkbox]){grid-column:3}.route-flow{gap:8px}.logic-conditions{flex-basis:80%}.routing-card{padding:15px}.route-result{font-size:11px}.import-table>div{grid-template-columns:75px 85px 1fr;font-size:10px}.day-clock{font-size:7px}.day{min-height:100px}.picker-option{padding:10px 7px}.picker-option .badge{display:none}.coverage-flow>div{flex-wrap:wrap}.coverage-flow small{margin-left:0}}
|
||||
/* Configuration graph operations. */
|
||||
.drag-handle{padding:5px 7px;font-size:22px;line-height:1;border-color:transparent;background:transparent;color:#8ba292;cursor:grab;touch-action:manipulation}.drag-handle:active{cursor:grabbing}.route-moves{display:flex;gap:3px}.move-route{padding:5px 8px;font-size:13px!important}.move-route:disabled{cursor:default;opacity:.3}.routing-card{position:relative;transition:opacity .15s,box-shadow .15s}.route-dragging{opacity:.4}.routing-card.drop-before:before,.routing-card.drop-after:after{content:'';position:absolute;height:3px;left:0;right:0;background:#2e8b60;border-radius:3px}.routing-card.drop-before:before{top:-8px}.routing-card.drop-after:after{bottom:-8px}.route-heading .route-position{min-width:28px;padding:6px}.route-heading .grow{overflow-wrap:anywhere}.dependency-list{border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin:16px 0}.dependency-list h3{font-size:12px;color:#68816f;margin:0 0 8px}.dependency-list .list-row{font-size:12px}.removal-preview .validation-warning p{margin:7px 0 0;font-weight:600}button.primary.remove-confirm{background:#985b45;border-color:#985b45}.removal-preview p{font-size:13px}.removal-preview .badge{white-space:nowrap}
|
||||
@media(max-width:700px){.route-heading{flex-wrap:wrap;gap:7px}.route-heading .grow{flex-basis:calc(100% - 70px)}.route-heading .route-moves{margin-left:auto}.dependency-list .list-row{align-items:flex-start;flex-wrap:wrap}.removal-preview .badge{font-size:9px}}
|
||||
.date-field{display:flex;position:relative;align-items:stretch;min-width:0}.date-field>input[type=text]{width:100%;min-width:0;border-radius:7px 0 0 7px}.date-field>button{border-left:0;border-radius:0 7px 7px 0;padding:8px 11px}.date-field .native-date{position:absolute;width:1px;height:1px;padding:0;border:0;opacity:0;bottom:0;right:0;pointer-events:none}.holiday-group>summary{display:flex;align-items:center;gap:10px;cursor:pointer;padding:17px 0;font-weight:600;list-style:none}.holiday-group>summary::before{content:'▸';color:var(--muted)}.holiday-group[open]>summary::before{content:'▾'}.holiday-group>summary .badge{margin-left:auto}.absence-range{padding:0 14px 12px;border:1px solid var(--line);border-radius:8px;margin:14px 0}.absence-preview{border-top:1px solid var(--line);margin-top:22px;padding-top:8px}.impact-scroll{overflow-x:auto}.impact-table{width:100%;border-collapse:collapse;font-size:12px}.impact-table th{text-align:left;color:var(--muted);font-weight:500}.impact-table td,.impact-table th{padding:12px 8px;border-bottom:1px solid var(--line);vertical-align:top}.impact-table small{display:block;font-size:10px;line-height:1.6;margin-top:5px}.impact-table td:first-child{min-width:160px}.impact-table td{overflow-wrap:anywhere}.preview-count{font-size:12px;color:var(--green)}
|
||||
|
||||
/* Typed workspace data and generation preview. */
|
||||
.wide-modal{width:100%;max-width:1000px}.generation-toolbar{display:flex;align-items:center;gap:12px;margin-bottom:18px;flex-wrap:wrap}.generation-toolbar .info-note{flex-basis:45%;margin:0}.generation-panel{padding:22px;min-width:0}.generation-panel .section-line{margin-top:0}.generation-panel>p{font-size:12px;line-height:1.6}.generation-columns{align-items:start}.variable-card{display:flex;align-items:center;gap:7px;padding:16px 0;border-top:1px solid var(--line);font-size:13px}.variable-card .grow{min-width:0;overflow-wrap:anywhere}.variable-card small{display:block;margin-top:6px;color:var(--muted);font-size:10px}.variable-card button{font-size:10px;padding:7px}.generation-preview{margin-top:18px}.data-tree{border:1px solid var(--line);border-radius:8px;overflow:hidden}.data-row{display:grid;grid-template-columns:minmax(80px,1fr) 130px minmax(180px,2fr) 92px;gap:8px;align-items:center;padding:10px;border-bottom:1px solid var(--line);background:#fafcf9}.data-row input,.data-row select{min-width:0;width:100%;font-size:12px}.data-key{font-size:12px;color:var(--muted)}.data-value{min-width:0}.data-depth-1{padding-left:24px}.data-depth-2{padding-left:40px}.data-depth-3,.data-depth-4,.data-depth-5,.data-depth-6,.data-depth-7,.data-depth-8{padding-left:56px}.data-actions{display:flex;gap:3px}.data-actions button{padding:5px}.data-value .actions{margin:0;flex-wrap:wrap}.data-value .actions button{padding:7px;font-size:11px}.person-value{display:grid;gap:7px}.bulk-editor,.binding-editor{background:#edf5ef;border:1px solid #c9dfce;border-radius:10px;padding:18px;margin:18px 0}.bulk-editor h3,.binding-editor h3{font-size:15px}.bulk-editor textarea{width:100%}.binding-button{text-align:left;width:100%;color:#34744f;background:#f0f7f1;border-color:#d6e6d8;overflow-wrap:anywhere;line-height:1.5;min-width:0}.binding-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 18px}.binding-grid label{min-width:0}.generation-block{border:1px solid var(--line);border-radius:10px;padding:18px;margin:18px 0}.iteration-row,.generation-step{display:flex;gap:8px;align-items:center;margin:13px 0}.iteration-row>*{min-width:0}.iteration-row>input{max-width:150px}.generation-step{flex-wrap:wrap}.generation-step .binding-button{width:auto}.generation-step .grow{flex:1}.generation-step select{font-size:11px;max-width:180px}.generation-step .check{margin:0}.binding-source{padding:0 4px}.binding-key{margin:14px 0 0 16px;padding-left:16px;border-left:2px solid #b5d2be}.priority-person{margin:14px 0}.demo-note dl{margin:8px 0}.demo-note dl>div{display:flex;gap:12px}.demo-note dt{min-width:34px;font-weight:600}.demo-note dd{margin:0}.demo-note{font-size:11px;line-height:1.8}
|
||||
@media(max-width:900px){.generation-columns{grid-template-columns:1fr}.data-row{grid-template-columns:80px 110px minmax(100px,1fr)}.data-actions{grid-column:3;justify-content:flex-end}.iteration-row{flex-wrap:wrap}.iteration-row .binding-button{flex-basis:100%}}
|
||||
@media(max-width:700px){.wide-modal .two-columns,.binding-grid{grid-template-columns:1fr}.data-row{grid-template-columns:1fr 1fr;padding:12px}.data-value{grid-column:1/-1}.data-actions{grid-column:2}.data-depth-1{border-left:8px solid #e6eee8}.data-depth-2,.data-depth-3{border-left:16px solid #e6eee8}.generation-toolbar>button{flex:1}.generation-block{padding:12px}.generation-panel{padding:15px}.variable-card{flex-wrap:wrap}.variable-card .grow{flex-basis:50%}.binding-key{margin-left:5px;padding-left:10px}.sidebar nav :is(button,a){padding:6px}.sidebar .nav-icon{font-size:18px;width:17px}}
|
||||
|
||||
.demo-banner{position:sticky;top:0;z-index:40;background:#fff4d9;border-bottom:1px solid #dfc68e;padding:10px 22px;color:#725624;font-size:11px;line-height:1.6}.demo-banner>div{display:flex;gap:8px 20px;flex-wrap:wrap}.demo-banner strong{font-weight:700}.demo-protection{margin:5px 0 0;border-left:3px solid #b36040;padding-left:10px;color:#8a4229}.demo-mode .sidebar{top:var(--demo-offset,0px);height:calc(100dvh - var(--demo-offset,0px))}@media(max-width:700px){.demo-banner{padding:8px 12px;font-size:10px}.demo-banner>div{gap:3px 10px}.demo-mode .sidebar{height:auto}}
|
||||
|
||||
@media(min-width:701px){.sidebar{overflow-y:auto}.sidebar-bottom{padding-top:20px}}
|
||||
/* Daily collaboration has one primary action; setup is a separate destination. */
|
||||
.sr-only{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap}.sidebar nav .setup-toggle{margin-top:20px;border-top:1px solid var(--line);border-radius:0;padding-top:20px}.setup-nav{padding-left:20px;border-left:1px solid var(--line);margin-left:20px}.sidebar nav .setup-nav :is(button,a){width:100%;padding:10px;font-size:12px}.daily-overview{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px}.today-card{background:white;border:1px solid var(--line);padding:20px 22px;border-radius:12px;display:flex;flex-direction:column;gap:13px}.today-card .person-line small,.today-card>small{display:block;margin-top:5px;line-height:1.6}.attention-panel{background:#fff;border:1px solid #bdd8cb;border-radius:12px;padding:0 20px;margin-bottom:24px}.reply-row{display:flex;align-items:center;gap:14px;padding:18px 0;border-top:1px solid var(--line)}.reply-row small{display:block;margin:7px 0;line-height:1.6}.reply-row p{margin:8px 0 0}.reply-row .actions{flex-shrink:0;margin:0}.waiting-line{display:flex;align-items:center;gap:15px;margin-bottom:18px;font-size:12px;color:var(--muted)}.action-comment{font-size:13px;border-left:2px solid #cadbce;padding-left:10px;overflow-wrap:anywhere}.page-heading .actions{margin:0}.schedule-control{position:relative}.schedule-trigger{min-width:180px;display:flex;justify-content:space-between;gap:12px;font-weight:600}.schedule-picker{position:absolute;right:0;top:45px;width:280px;padding:12px;background:white;box-shadow:0 8px 28px #142a2825;border:1px solid var(--line);border-radius:10px;z-index:15}.schedule-picker input{width:100%}.schedule-results{max-height:240px;overflow:auto;margin-top:8px}.schedule-results button{width:100%;display:block;text-align:left;border:0;padding:12px}.chosen{background:#e6f2eb!important;border-color:#548b6b!important;color:#19513a!important}.day{transition:background .15s,box-shadow .15s}.day:hover{background:#edf5ef;box-shadow:inset 0 0 0 1px #b5cebc}.shift-ticket{background:#f3f7f4;border:1px solid #dce6df;border-radius:12px;padding:18px;margin:16px 0 22px}.shift-ticket .person-line .badge{margin-left:auto}.shift-times{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:20px;padding-top:15px;border-top:1px solid #dde7e0}.shift-times strong{display:block;font-size:12px;margin-top:6px}.shift-times>span{color:#85968a}.cover-self{display:flex;align-items:center;gap:12px;margin:18px 0;flex-wrap:wrap}.cover-options{display:flex;gap:8px;flex-wrap:wrap;max-height:190px;overflow:auto}.cover-options button{display:flex;align-items:center;gap:8px;padding:10px 13px}.selection-tick{color:var(--green);font-weight:bold}.range-options{margin:20px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:5px 0}.range-options>summary{padding:8px 0;font-weight:500}.request-ready{font-size:12px;background:#f0f7f1;padding:12px;border-radius:8px;line-height:1.6}.request-ready small{display:block;margin-bottom:6px;font-size:10px}.cover-footer{flex-wrap:wrap;gap:8px}.cover-footer .primary{margin-left:auto}.force-action{color:#82633e;font-size:11px}.shift-option{display:flex;flex-direction:column;gap:10px;width:100%;text-align:left;margin:12px 0}.simple-list-row{display:flex;gap:8px;align-items:center;margin:12px 0}.simple-list-row input{flex:1;min-width:0}.simple-list-row button{padding:6px 9px}.matrix-head,.matrix-row{display:grid;grid-template-columns:150px 1fr 24px;gap:14px;align-items:center}.matrix-head{font-size:11px;color:var(--muted);padding:12px 0}.matrix-row{padding:16px 0;border-top:1px solid var(--line)}.matrix-row input{width:100%;min-width:0}.matrix-people{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.matrix-person{display:flex;align-items:center;gap:7px;border-radius:7px;background:#edf4ef;padding:7px 9px;font-size:12px}.matrix-person b{color:#84978a;font-weight:500;font-size:10px}.matrix-person button{padding:0 3px;background:none;border:0;color:#75917d}.inline-people{background:#f3f7f4;border-radius:10px;padding:16px;margin:16px 0}.inline-people input{width:100%;margin:10px 0}.inline-people .section-line{margin:0}.data-mode{margin:14px 0}.modal-heading .eyebrow{font-size:9px}.modal-footer{position:sticky;bottom:-28px;background:white;padding-bottom:18px;z-index:5}.modal{scrollbar-gutter:stable}.request-card .action-comment{margin-top:10px}
|
||||
@media(max-width:950px) and (min-width:701px){.sidebar .setup-nav{position:fixed;left:74px;top:calc(var(--demo-offset,0px) + 240px);background:white;box-shadow:0 6px 20px #20392c25;padding:12px;margin:0;width:210px;border-radius:8px;z-index:20}.sidebar nav .setup-toggle{justify-content:center}}
|
||||
@media(max-width:700px){.sidebar nav .setup-toggle{margin:0;border:0;padding:8px}.sidebar .setup-nav{position:absolute;right:10px;top:55px;width:220px;padding:12px;margin:0;background:white;border:1px solid var(--line);border-radius:10px;box-shadow:0 8px 25px #1f372925}.sidebar nav .setup-nav :is(button,a){display:block;text-align:left;padding:12px}.sidebar nav>button span:not(.nav-icon){display:none}.daily-overview{grid-template-columns:1fr;gap:10px}.today-card{padding:16px}.reply-row{flex-wrap:wrap;align-items:flex-start}.reply-row>.grow{width:calc(100% - 60px)}.reply-row>.actions{margin-left:50px}.attention-panel{padding:0 14px}.shift-times{align-items:flex-start;flex-direction:column;gap:10px}.shift-times>span{display:none}.shift-times>div{display:flex;justify-content:space-between;gap:10px;width:100%}.shift-times strong{margin:0}.cover-footer .force-action{order:3;width:100%;border:0}.cover-footer .primary{flex:1}.modal-footer{bottom:-20px}.schedule-picker{left:0;right:auto;width:min(280px,calc(100vw - 55px))}.matrix-head{display:none}.matrix-row{grid-template-columns:1fr 24px;gap:10px}.matrix-people{grid-column:1/-1;grid-row:2}.matrix-row>button{grid-column:2;grid-row:1}.simple-list-row{gap:4px}.simple-list-row button{padding:5px}.simple-list-row .step-number{display:none}.top-actions>.text,.top-actions>button{font-size:10px}.cover-options button{font-size:12px}.cover-sheet .two-columns{grid-template-columns:1fr}.day-clock{display:none}.day{min-height:85px}}
|
||||
.shift-ticket .person-line small{display:block;margin-top:5px}.request-ready>span{display:block;font-weight:500;color:var(--green)}
|
||||
|
||||
.sidebar nav a{text-decoration:none;border-radius:7px}.sidebar nav a:focus-visible{outline:2px solid #4f9d7e;outline-offset:2px}.setup-section{scroll-margin-top:150px;margin-bottom:38px}.generated-group{border-top:1px dashed #baccc2;margin-top:24px;padding-top:18px}.generated-group>h3{font-size:12px;color:var(--muted);font-weight:500}.generated-resource{background:#f3f6f5;box-shadow:none}.manual-resource{border-left:3px solid #59977c}.resource-origin{display:block;font-size:10px;color:#667b70;margin-bottom:10px}
|
||||
|
||||
.recipe-section{border-top:1px solid var(--line);margin-top:18px;padding-top:18px}.recipe-section h3{display:flex;align-items:center;gap:10px;font-size:15px}.recipe-number{display:inline-flex;width:25px;height:25px;align-items:center;justify-content:center;background:#e3eee8;color:#236648;border-radius:50%;font-size:12px}.advanced-rule{font-size:12px;color:#687e71;margin:12px 0}.advanced-rule summary{cursor:pointer}.recipe-field{min-width:0}.recipe-field>label{margin:12px 0 6px}.recipe-field>select,.recipe-field .inline-value{width:100%}.recipe-field .inline-value{margin-top:7px}.binding-tweak{font-size:11px!important}.recipe-example{background:#f3f7f4;border-radius:9px;padding:16px}.recipe-example table{width:100%;font-size:12px;border-collapse:collapse;text-align:left}.recipe-example td,.recipe-example th{padding:9px 8px;border-bottom:1px solid var(--line)}
|
||||
|
||||
.coverage-problems{border-color:#e5c9a8;background:#fffbf5}.coverage-issue-list{max-height:230px;overflow:auto}
|
||||
|
||||
.setup-jumps{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 20px}.setup-jumps a{padding:8px 12px;border:1px solid var(--line);border-radius:6px;color:var(--green);text-decoration:none;font-size:12px;background:white}
|
||||
|
||||
.recipe-number{flex-shrink:0}.rule-identity{grid-template-columns:minmax(0,1fr) auto!important;align-items:end}.rule-identity .check{margin-bottom:25px}@media(max-width:640px){.recipe-section>.section-line{flex-direction:column;align-items:stretch;gap:4px}.recipe-section>.section-line>select{width:100%}.recipe-section h3{margin:0 0 12px}.recipe-section{margin-top:12px;padding-top:14px}.rule-identity{gap:12px!important}}
|
||||
|
||||
.cover-range{margin-top:18px;padding-top:14px;border-top:1px solid var(--line)}.cover-boundary{display:grid;grid-template-columns:minmax(0,1fr) 130px;gap:14px}.cover-boundary label{margin:10px 0}.cover-boundary input{min-width:0;width:100%}.modal-footer{flex-wrap:wrap}.generation-live-status{flex-basis:100%;width:100%;padding:10px 12px;border-radius:7px;background:#edf5ef;font-size:12px}.generation-live-status.has-errors{background:#fff0ed;color:#913d32;border:1px solid #e9beb5}.validation-status-line{display:flex;align-items:center;justify-content:space-between;gap:10px}.validation-status-line button{font-size:11px;padding:5px 8px}.validation-first{margin:6px 0 0;line-height:1.4;overflow-wrap:anywhere}.generation-live-errors{max-height:150px;overflow:auto}.generation-live-errors p{line-height:1.4;overflow-wrap:anywhere}.generation-live-status .validation-first{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}@media(max-width:500px){.cover-boundary{grid-template-columns:minmax(0,1fr) 110px;gap:10px}.generation-live-errors{max-height:100px}}
|
||||
|
||||
.recipe-field>div>select{width:100%}.name-composer{display:flex;align-items:center;min-width:0;border:1px solid #d8e1da;border-radius:7px;background:white;overflow:hidden}.name-composer:focus-within{outline:2px solid #4f9d7e;outline-offset:2px}.recipe-field .name-composer>select{flex:0 1 46%;width:46%;min-width:0;border:0;border-right:1px solid #d8e1da;border-radius:0;background:white;padding:11px 8px;text-overflow:ellipsis}.recipe-field .name-composer>.inline-value{flex:1 1 54%;width:54%;min-width:0;margin:0;border:0;border-radius:0;padding:11px 8px}.name-composer>input:focus,.name-composer>select:focus{outline:none}.name-composer>.name-prefix{flex:0 1 25%;width:25%;min-width:0;border:0;border-radius:0;padding:11px 6px}
|
||||
|
||||
.binding-value-preview{margin:16px 0;padding:12px;background:#f3f7f4;border:1px solid var(--line);border-radius:8px;min-width:0}.binding-value-preview .section-line{margin:0 0 8px;gap:8px;flex-wrap:wrap}.binding-value-preview strong{font-size:12px}.binding-value-preview small{font-size:11px;color:#65796d;overflow-wrap:anywhere}.binding-value-preview textarea{display:block;width:100%;box-sizing:border-box;height:116px;min-height:80px;max-height:240px;resize:vertical;overflow:auto;white-space:pre;font:12px/1.5 Consolas,"Courier New",monospace;background:#fff;color:#244332;margin:0;padding:10px}.binding-value-preview .validation-warning{margin-bottom:0}
|
||||
|
||||
.template-token{font-family:Consolas,"Courier New",monospace!important;font-size:12px;letter-spacing:0;font-weight:500;color:#285c48;background:#edf5f0;border-radius:5px}.binding-template{display:block;padding:8px 10px;overflow-wrap:anywhere;margin:12px 0}.route-heading .template-token{padding:5px 8px}
|
||||
|
||||
/* Schedule setup: sources and review, contiguous resources, occasional calendar maintenance. */
|
||||
#generation .setup-intro{margin:24px 0 18px}#generation .setup-intro h2{font-size:20px;margin-bottom:6px}#generation .setup-intro p{margin:0}#generation .generation-columns{grid-template-columns:minmax(0,.85fr) minmax(0,1.35fr);gap:18px;align-items:start}#generation .generation-panel{padding:20px}#generation .variable-card{gap:8px;padding:12px 0}#generation .variable-card .grow{min-width:0}#generation .variable-card strong{overflow-wrap:anywhere}#generation .variable-card button{padding:7px 9px;font-size:11px}#generation .section-line{margin-top:0}.generation-review-bar{margin-top:18px;border:1px solid var(--line);border-radius:9px;background:#fff}.generation-review>summary{display:flex;align-items:center;justify-content:space-between;gap:16px;cursor:pointer;padding:15px 18px;font-size:13px;list-style:disclosure-closed}.generation-review .review-status{font-size:12px}.generation-review[open]>summary{border-bottom:1px solid var(--line)}.review-body{padding:16px 18px}.review-body .generation-preview>h3{display:none}.review-actions{border-top:1px solid var(--line);padding:12px 18px;justify-content:flex-end}.setup-resources{margin-top:32px;padding-top:24px;border-top:1px solid var(--line)}.setup-resources>.section-line{margin-top:0}.section-count{font-size:12px;color:var(--muted);font-weight:400;margin-left:10px}.setup-resources .rule-card{padding:18px}.setup-resources .rule-card h2{font-size:16px}.setup-resources .generated-group{margin-top:18px;padding-top:16px}.setup-resources .generated-group>h3{font-size:11px;color:var(--muted);letter-spacing:.5px}.setup-resources .escalation-flow{margin:12px 0}.setup-availability{margin-top:32px;padding-top:24px;border-top:1px solid var(--line)}.setup-utilities{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start}.setup-widget{background:#fff;border:1px solid var(--line);border-radius:9px;padding:16px 18px;min-width:0}.widget-heading{display:flex;gap:10px;align-items:center}.widget-heading h3{font-size:14px;margin:0}.widget-heading button{margin-left:auto}.setup-widget>p{font-size:12px;margin:12px 0 0}.holiday-widget>summary{display:flex;align-items:center;gap:10px;cursor:pointer;font-weight:600;flex-wrap:wrap}.holiday-widget>summary:before,.absence-widget-list>summary:before{content:'›';font-size:20px;color:var(--muted)}.holiday-widget[open]>summary:before,.absence-widget-list[open]>summary:before{transform:rotate(90deg)}.holiday-widget>summary small{width:100%;font-weight:400;padding-left:18px}.widget-body{margin-top:16px}.widget-scroll{max-height:360px;overflow:auto}.absence-widget-list>summary{display:flex;align-items:center;gap:8px;cursor:pointer;margin-top:8px;font-size:12px}.setup-widget .holiday-group{margin:10px 0 0}.setup-widget .list-row{padding:10px 0}.setup-widget .list-row strong{font-size:12px}.setup-widget .badge{font-size:11px}.setup-widget .actions button{font-size:12px}.setup-jumps a{background:white}.setup-jumps a:hover{border-color:#8eb5a2}
|
||||
@media(max-width:1050px){#generation .generation-columns{grid-template-columns:1fr}}@media(max-width:650px){.setup-utilities{grid-template-columns:1fr}.generation-review>summary{align-items:flex-start;flex-direction:column;gap:6px}.review-actions{flex-wrap:wrap}.setup-resources>.section-line{align-items:flex-start;gap:12px;flex-direction:column}.setup-resources>.section-line .actions{flex-wrap:wrap}#generation .generation-panel{padding:16px}.setup-widget{padding:14px}.variable-card{flex-wrap:wrap}}
|
||||
|
||||
.resource-summary{display:flex;align-items:center;justify-content:space-between;gap:14px;cursor:pointer;list-style:none}.resource-summary>div{min-width:0}.resource-summary h2{margin:5px 0 0;overflow-wrap:anywhere}.resource-summary .resource-origin{display:block;margin-bottom:6px}.resource-summary .eyebrow{display:block}.resource-summary>button{font-size:11px;flex-shrink:0}.resource-summary:before{content:'›';color:var(--muted);font-size:22px}.resource-disclosure[open]>.resource-summary:before{transform:rotate(90deg)}.resource-summary>div{flex:1}.resource-body{margin-top:16px}.setup-resources .card-grid{align-items:start}.setup-resources .resource-disclosure:not([open]){padding-top:14px;padding-bottom:14px}@media(max-width:500px){.resource-summary{gap:8px}.resource-summary>button{max-width:100px;white-space:normal}}
|
||||
/* Compact setup utilities and a shared resource inventory. */
|
||||
#availability.setup-availability{margin:0 0 20px;padding:0;border:0}.setup-utilities{display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap}.setup-utilities .setup-widget{padding:0;flex:0 1 auto;min-width:180px}.setup-widget>summary{display:flex;align-items:center;gap:10px;padding:10px 14px;cursor:pointer;font-size:12px;list-style:none}.setup-widget>summary:before{content:'›';font-size:17px;color:var(--muted)}.setup-widget[open]>summary:before{transform:rotate(90deg)}.setup-widget[open]{flex-basis:420px}.setup-widget .widget-body{padding:0 14px 14px;margin-top:8px}.setup-widget .badge{margin-left:auto}.setup-widget .widget-scroll{max-height:280px}
|
||||
#generation .setup-intro{margin:0 0 14px}#generation .setup-intro h2{font-size:16px}#generation .setup-intro p{display:none}#generation .generation-panel{padding:16px}#generation .generation-columns{gap:12px}.generation-review-bar{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;margin-top:12px}.generation-review>summary{justify-content:flex-start;gap:12px;padding:13px 14px;min-height:48px;font-size:12px}.generation-review>summary:before{content:'›';font-size:18px}.generation-review[open]>summary:before{transform:rotate(90deg)}.generation-review .review-status{font-size:11px}.review-actions{border:0;padding:8px;gap:6px}.review-actions button{font-size:11px;padding:7px 9px}.generation-review-bar:has(.generation-review[open]){grid-template-columns:1fr}.generation-review-bar:has(.generation-review[open]) .review-actions{border-top:1px solid var(--line)}
|
||||
.resource-inventory{margin-top:24px}.resource-inventory>.section-line{margin:0 0 12px}.resource-create{gap:7px}.resource-create button,.schedule-create>summary{font-size:12px;padding:8px 11px}.schedule-create{position:relative}.schedule-create>summary{cursor:pointer;border:1px solid #dbe3de;border-radius:7px;background:#fff;list-style:none}.schedule-create-options{position:absolute;right:0;top:calc(100% + 5px);min-width:220px;padding:5px;border:1px solid var(--line);border-radius:8px;background:white;box-shadow:0 8px 24px #233d351a;z-index:5;display:grid;gap:3px}.schedule-create-options button{border:0;text-align:left}
|
||||
.resource-table{border:1px solid var(--line);border-radius:9px;overflow:hidden;background:white}.resource-table .resource-group{margin:0;padding:0;border:0}.resource-table .generated-group{border-top:2px solid #d6dfe8}.resource-group-label{margin:0;padding:10px 15px;font-size:11px;color:var(--muted);font-weight:500;background:#f7f9f8}.resource-table .generated-group .resource-group-label{background:#eef2f8;color:#63768e}.resource-table .resource-row{margin:0;padding:0;border:0;border-top:1px solid var(--line);border-radius:0;box-shadow:none}.resource-table .manual-resource{background:white;border-left:3px solid #59977c}.resource-table .generated-resource{background:#f7f9fc;color:#3e5069;border-left:3px solid #aabbd3}.resource-table .resource-summary{display:grid;grid-template-columns:12px 105px minmax(0,1fr) 20px;gap:12px;padding:12px 15px;min-height:47px}.resource-table .resource-summary:hover{background:#edf2f566}.resource-table .resource-summary:before{font-size:19px}.resource-kind{font-size:11px;color:var(--muted)}.resource-name{font-size:13px;font-weight:500;overflow-wrap:anywhere}.generated-mark{font-size:17px;color:#7b8fab}.resource-table .resource-body{border-top:1px solid var(--line);margin:0;padding:16px 30px 18px 42px;font-size:12px}.resource-table .resource-body>p:last-child{margin-bottom:0}.resource-table .escalation-flow{margin-top:0}.resource-table .routing-list{display:block}.resource-table .route-heading{margin-bottom:12px}.resource-table .route-flow{margin:0}.resource-table .route-position{font-size:10px;padding:2px 5px;margin-right:6px}.resource-table .route-dragging{opacity:.5}.resource-table .drop-before{border-top:2px solid var(--green)}.resource-table .drop-after{border-bottom:2px solid var(--green)}
|
||||
@media(max-width:700px){.resource-inventory>.section-line{align-items:flex-start;flex-direction:column;gap:10px}.resource-create{flex-wrap:wrap}.resource-create button,.schedule-create>summary{font-size:11px;padding:8px}.generation-review-bar{grid-template-columns:1fr}.generation-review>summary{flex-direction:row;align-items:center;flex-wrap:wrap}.review-actions{justify-content:flex-start;padding-top:0}.resource-table .resource-summary{grid-template-columns:10px 75px minmax(0,1fr) 15px;gap:7px;padding:11px 9px}.resource-kind{font-size:10px}.resource-name{font-size:12px}.resource-table .resource-body{padding:14px}.setup-utilities .setup-widget{min-width:0;flex:1}.setup-utilities .setup-widget[open]{flex-basis:100%}.schedule-create-options{left:0;right:auto}}
|
||||
|
||||
.generation-review-bar .review-actions{margin:0}
|
||||
.resource-table .resource-summary .resource-title{min-width:0}.resource-title .resource-name{display:block}.resource-brief{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:5px;font-size:11px;font-weight:400;color:#77857e}.resource-source{display:flex;justify-content:flex-end;margin:-5px 0 12px}.source-rule-link{font-size:11px;padding:5px 8px;border:1px solid #d8e2ed;background:#f1f5fa;color:#526e90;max-width:100%;text-align:right;overflow-wrap:anywhere}.source-rule-link:disabled{opacity:1;cursor:default}.resource-table .resource-summary:has(.resource-title){align-items:start}.resource-table .resource-summary>.resource-kind{padding-top:2px}@media(max-width:700px){.resource-brief{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.resource-source{margin-top:0}}
|
||||
.form-validation{width:100%;flex-basis:100%}.validation-control{width:100%;font-size:12px}.validation-toggle{width:100%;display:flex;align-items:center;justify-content:space-between;gap:12px;background:#f4f6f5;font-size:12px}.validation-toggle.is-checked{background:#edf5ef;color:#27684b}.validation-toggle.has-errors{background:#fff0ed;border-color:#e9beb5;color:#913d32}.validation-toggle.is-checking{opacity:.8}.validation-details{max-height:230px;overflow:auto;padding:8px 12px;border:1px solid var(--line);border-top:0;background:white}.validation-issue{padding:10px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}.validation-issue>strong,.validation-issue>small{display:block;margin-bottom:5px}.validation-issue .actions{margin:6px 0 0;flex-wrap:wrap}.validation-issue button{font-size:11px;padding:5px 8px}.page-validation{position:sticky;top:70px;z-index:4;margin-top:12px}.generation-block{scroll-margin-top:16px}@media(max-width:700px){.validation-details{max-height:160px}.page-validation{top:150px}}
|
||||
.absence-range .absence-zone{display:block;margin:8px 0}.absence-range .two-columns{align-items:start}.absence-range input[type=time]{width:100%}.impact-table .impact-interval{min-width:180px}.impact-interval>strong{display:block;margin-bottom:6px;overflow-wrap:anywhere}.impact-interval>small{display:flex;flex-direction:column;gap:3px;line-height:1.5;white-space:normal}.absence-range select{min-width:0;width:100%}
|
||||
|
||||
.coverage-problems.panel{padding:20px;margin:20px 0 26px;border:1px solid #e5c9a8;border-radius:10px}
|
||||
.coverage-problems>p{margin-bottom:16px}.coverage-issue-list{display:flex;flex-direction:column;gap:12px;max-height:320px}
|
||||
.coverage-issue{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:14px 16px;border:1px solid #ebc9c2;border-radius:8px;background:#fff8f6}
|
||||
.coverage-issue.is-pending{background:#fffdf7;border-color:#e5dcc0}.coverage-issue-body{display:flex;flex-direction:column;gap:8px;min-width:0;overflow-wrap:anywhere}
|
||||
.coverage-state{font-size:12px;font-weight:600;color:#9a4436}.is-pending .coverage-state{color:#806221}.coverage-times{display:flex;flex-wrap:wrap;gap:6px;line-height:1.6}.coverage-issue>button{flex-shrink:0}
|
||||
@media(max-width:640px){.coverage-problems.panel{padding:16px}.coverage-issue{align-items:stretch;flex-direction:column;padding:12px}.coverage-times{flex-direction:column;gap:3px}}
|
||||
|
||||
#generation .generation-columns{grid-template-columns:minmax(0,.9fr) minmax(0,1.15fr) minmax(0,.85fr);gap:16px;align-items:start}
|
||||
#generation .generation-panel{padding:18px;min-width:0}#generation .generation-panel>.section-line{flex-wrap:wrap;gap:10px}#generation .generation-panel h2{font-size:15px}
|
||||
#generation .variable-card{flex-wrap:wrap;gap:6px}#generation .variable-card>.grow{flex:1 0 100%;min-width:0}#generation .variable-card button{font-size:11px;padding:6px 8px}
|
||||
#generation #availability{margin:0;min-width:0}#availability .setup-utilities{display:flex;flex-direction:column;gap:16px}#availability .setup-widget{width:100%;min-width:0;padding:16px;flex:none}
|
||||
#availability .widget-heading{font-size:14px;font-weight:600}#availability .widget-body{padding:0;margin-top:12px}#availability .widget-scroll{max-height:240px;overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}
|
||||
#availability .actions{flex-wrap:wrap;gap:8px;margin-top:0;margin-bottom:12px}#availability .list-row{gap:8px;align-items:flex-start}#availability .list-row>div{min-width:0;flex:1}#availability .list-row small{display:block;margin-top:5px;line-height:1.5}#availability .list-row button{font-size:11px;padding:5px 0}
|
||||
#availability .holiday-group>summary{display:flex;gap:8px;align-items:center;padding:8px 0;font-size:12px;cursor:pointer}
|
||||
@media(max-width:1200px){#generation .generation-columns{grid-template-columns: minmax(0,1fr) minmax(0,1fr)}#generation #availability{grid-column:1/-1}#availability .setup-utilities{display:grid;grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:700px){#generation .generation-columns{grid-template-columns:minmax(0,1fr)}#availability .setup-utilities{grid-template-columns:minmax(0,1fr)}}
|
||||
|
||||
#generation .variable-card .grow>small{display:block;white-space:normal;overflow-wrap:anywhere;line-height:1.5}
|
||||
|
||||
.workspace-variable-list,.generation-rule-list{max-height:360px;overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;padding-right:4px}.workspace-variable-list:focus-visible,.generation-rule-list:focus-visible{outline:2px solid #4f9d7e;outline-offset:3px;border-radius:4px}
|
||||
|
||||
.resource-inventory{padding:20px;background:#fff;border:1px solid var(--line);border-radius:12px}
|
||||
.resource-icon{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
|
||||
.resource-kind{display:flex;align-items:center;gap:7px}.resource-table .resource-summary{grid-template-columns:12px 116px minmax(0,1fr) 18px;align-items:center}
|
||||
.resource-table .resource-body{padding:16px 20px 20px 150px;background:#fbfcfc;border-top:1px solid #e6ebe8}.resource-table .generated-resource .resource-body{background:#f8fafc}
|
||||
.resource-source{margin:0 0 14px;min-height:0}.source-rule-link{display:inline-flex;align-items:center;font-size:12px;padding:7px 10px;border-radius:6px}
|
||||
.resource-detail-list{border:1px solid var(--line);border-radius:8px;background:white;overflow:hidden}
|
||||
.resource-detail-item{display:flex;position:relative;align-items:flex-start;gap:14px;padding:14px 16px}.resource-detail-item+.resource-detail-item{border-top:1px solid var(--line)}
|
||||
.resource-detail-icon{width:32px;height:32px;display:flex;align-items:center;justify-content:center;border:1px solid #dce7e1;border-radius:8px;background:#f0f5f2;color:#54806a;flex-shrink:0;margin-top:2px}
|
||||
.resource-detail-copy{min-width:0;display:flex;flex-direction:column;gap:6px;overflow-wrap:anywhere}.resource-detail-copy strong{font-size:13px;font-weight:600;line-height:1.5}.resource-detail-copy small{line-height:1.5;display:block}.resource-detail-label{font-size:11px;color:#6c8075}
|
||||
.resource-detail-item.is-flow-step:before{content:'';position:absolute;top:-5px;left:29px;width:6px;height:6px;border-right:1.5px solid #8aaa97;border-bottom:1.5px solid #8aaa97;transform:rotate(45deg);background:white;z-index:1}
|
||||
.resource-actions,.resource-body .route-heading{margin:14px 0 0;gap:8px}.resource-body .route-heading{padding:0 0 14px}.resource-actions button,.resource-body .route-heading button{font-size:12px}
|
||||
@media(max-width:700px){.resource-inventory{padding:12px}.resource-table .resource-summary{grid-template-columns:10px 25px minmax(0,1fr) 15px}.resource-kind>span{display:none}.resource-table .resource-body{padding:12px}.resource-detail-item{padding:12px;gap:10px}.resource-detail-item.is-flow-step:before{left:25px}.resource-source{justify-content:flex-start}.source-rule-link{text-align:left}.resource-detail-copy strong{font-size:12px}}
|
||||
|
||||
@media(max-width:700px){.resource-table .resource-summary{grid-template-columns:10px 66px minmax(0,1fr) 15px}.resource-kind{flex-direction:column;align-items:flex-start;gap:5px}.resource-kind>span{display:block;font-size:9px}}
|
||||
|
||||
.resource-table .resource-body{padding:10px 18px 14px 42px}.resource-source{margin:0 0 8px}.source-rule-link{font-size:11px;padding:4px 7px}
|
||||
.resource-widget{min-width:0}.widget-meta{display:flex;align-items:center;flex-wrap:wrap;gap:8px 16px;font-size:11px;color:#748379;margin-bottom:10px}.widget-type{display:inline-flex;align-items:center;gap:6px;color:#486153;font-weight:600}
|
||||
.widget-chain{display:flex;align-items:center;flex-wrap:wrap;gap:12px 24px}.widget-node{position:relative;display:flex;align-items:center;gap:8px;min-width:0;max-width:100%;padding:8px 11px;border:1px solid #dce5e0;border-radius:7px;background:white;font-size:12px;overflow-wrap:anywhere}
|
||||
.widget-node>div{min-width:0}.widget-node strong{display:block;font-size:12px;font-weight:600;line-height:1.5}.widget-node small{display:block;font-size:10px;line-height:1.5}.widget-node~.widget-node:before{content:'';position:absolute;left:-17px;top:calc(50% - 3px);width:6px;height:6px;border-top:1.5px solid #8ba595;border-right:1.5px solid #8ba595;transform:rotate(45deg)}
|
||||
.widget-order{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#edf3ef;color:#5e7e6b;font-size:10px;flex-shrink:0}.person-node{min-height:38px}.fallback-node,.route-destination{background:#eef5f1}.trigger-node{border:0;background:none;padding-left:0;color:#738578}.escalation-node{align-items:flex-start}.widget-end{margin:10px 0 0}.widget-end .resource-icon{width:14px;height:14px}.widget-condition-list{display:flex;flex-wrap:wrap;gap:5px;margin-top:3px}.widget-condition{padding:3px 6px;background:#f2f5f3;border-radius:4px;font-size:11px}.routing-conditions{flex:0 1 auto}.routing-widget{align-items:center}
|
||||
@media(max-width:700px){.resource-table .resource-body{padding:10px}.widget-chain{gap:10px 20px}.widget-node{padding:7px 9px}.widget-node~.widget-node:before{left:-14px}.widget-meta{gap:6px 12px}.source-rule-link{font-size:10px}}
|
||||
|
||||
.modal.constructor-modal{width:min(1120px,calc(100vw - 40px));max-width:1120px}
|
||||
.rule-constructor .rule-identity{grid-template-columns:minmax(0,1fr) auto!important;align-items:center;gap:16px}.rule-constructor .rule-identity label{margin:8px 0 16px}.rule-constructor .rule-identity .check{margin:8px 0 16px}
|
||||
.constructor-layout{display:grid;grid-template-columns:260px minmax(0,1fr);gap:18px;align-items:start}.constructor-inputs{background:#f5f8f6;border:1px solid var(--line);border-radius:8px;padding:12px;min-width:0}.constructor-section+.constructor-section{border-top:1px solid var(--line);margin-top:14px;padding-top:12px}
|
||||
.rule-constructor h3{font-size:13px;margin:0}.rule-constructor .section-line{margin:0 0 10px;gap:8px;flex-wrap:wrap}.constructor-loop{border:1px solid #dbe5de;border-left:3px solid #94b5a1;border-radius:6px;padding:10px;margin-top:10px;background:white}.constructor-loop-title{display:flex;align-items:center;justify-content:space-between;gap:8px}.constructor-loop label{margin:8px 0;gap:4px;font-size:11px}.constructor-loop input,.constructor-loop select{width:100%;min-width:0}.constructor-predicate{display:grid;gap:7px}.constructor-filter>.check{font-size:11px;line-height:1.5;margin:10px 0}.constructor-empty{font-size:12px;color:#7d8a85;padding:12px;border:1px dashed #d3dfd7;border-radius:6px;margin:8px 0}
|
||||
.rule-constructor input:not([type=checkbox]),.rule-constructor select,.rule-constructor button{font-size:12px;padding:7px 9px;min-height:32px}.rule-constructor button.text{padding:3px 5px;min-height:24px}.rule-constructor .binding-button{background:white;color:inherit;font-size:11px;padding:7px 9px}.constructor-binding{font-family:Consolas,monospace}.constructor-outputs{min-width:0}.constructor-outputs>.recipe-section{margin:0;padding:0;border:0}.constructor-outputs .generation-block{padding:12px;margin:10px 0;border-radius:7px;background:#fff}.constructor-outputs .generation-block>.route-heading{padding-bottom:9px;border-bottom:1px solid var(--line);gap:6px;flex-wrap:wrap}.constructor-outputs .route-heading>.grow{font-size:12px;min-width:100px}.constructor-outputs .badge{font-size:10px}
|
||||
.rule-constructor .binding-grid{gap:8px 14px;align-items:start}.rule-constructor .recipe-field{position:relative;min-width:0;padding-top:6px}.rule-constructor .recipe-field>label{font-size:11px;margin:0 0 5px;padding-right:54px;line-height:1.4}.rule-constructor .recipe-field input,.rule-constructor .recipe-field select{width:100%;min-width:0;margin-top:0}.rule-constructor .recipe-field .binding-tweak{position:absolute;top:2px;right:0;font-size:10px!important;min-height:20px;padding:2px 3px}.rule-constructor .recipe-field .inline-value{margin-top:5px}.rule-constructor .generation-step{gap:6px;padding:8px 0;border-bottom:1px solid var(--line);margin:0}.rule-constructor .generation-step .flow-arrow{padding:0}.rule-constructor .recipe-example{margin-top:14px;padding:12px;background:#f5f8f6;border-radius:7px}.rule-constructor .recipe-example h3{margin-bottom:7px}.rule-constructor .recipe-example td,.rule-constructor .recipe-example th{padding:7px;font-size:11px}
|
||||
@media(max-width:800px){.constructor-layout{grid-template-columns:1fr}.constructor-inputs{display:grid;grid-template-columns:1fr 1fr;gap:12px}.constructor-section+.constructor-section{border:0;margin:0;padding:0}.modal.constructor-modal{width:calc(100vw - 20px)}.rule-constructor .binding-grid{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:520px){.constructor-inputs,.rule-constructor .binding-grid{grid-template-columns:1fr}.rule-constructor .rule-identity{gap:8px}.rule-constructor .name-composer{flex-wrap:wrap}.constructor-layout{gap:12px}}
|
||||
|
||||
.compact-preview{margin:12px 0 0!important;border:1px solid var(--line);border-radius:7px;background:#fff;padding:0!important}.compact-preview>summary{display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;padding:9px 12px!important;font-size:11px!important;cursor:pointer;min-height:36px!important}.preview-totals{display:flex;flex-wrap:wrap;gap:10px;color:#738478}.compact-preview-body{padding:8px 12px}.compact-change-list{max-height:180px;overflow:auto}.compact-change{display:flex;gap:10px;align-items:center;padding:6px 0;border-bottom:1px solid var(--line)}.compact-change>div{min-width:0}.compact-change strong,.compact-change small{display:block;overflow-wrap:anywhere;line-height:1.4}.compact-change strong{font-size:11px}.compact-change small{font-size:10px;margin-top:2px}.compact-change .badge{font-size:10px;min-width:58px;text-align:center}.compact-preview-body>div>small{font-size:10px}.compact-example{border:1px solid var(--line)!important;padding:0!important;margin-top:12px!important}.compact-example>summary{font-size:11px;font-weight:600;padding:9px 12px;cursor:pointer}.compact-example table{font-size:11px}.generation-review-bar>.compact-preview{margin:0!important;border:0;border-radius:0;background:transparent;min-width:0}.generation-review-bar .review-actions{padding:10px 12px;gap:8px;flex-wrap:wrap;align-items:center}
|
||||
.binding-popover{position:fixed;inset:auto;margin:0!important;padding:12px!important;border:1px solid #bacec1;border-radius:9px;background:#fff;box-shadow:0 12px 36px #1b342833;overflow:auto;overscroll-behavior:contain;z-index:1000;color:var(--ink);font-size:12px}.binding-popover::backdrop{background:transparent}.binding-popover>.section-line{position:sticky;top:-12px;background:white;padding:8px 0;z-index:2;margin:-4px 0 8px;border-bottom:1px solid var(--line)}.binding-popover h3{font-size:13px;margin:0}.binding-popover label{font-size:11px;margin:9px 0;gap:4px}.binding-popover input,.binding-popover select,.binding-popover textarea{font-size:12px;padding:7px 8px;min-width:0;width:100%}.binding-popover button{font-size:11px;padding:5px 8px}.binding-popover .two-columns{gap:10px;grid-template-columns:1fr 1fr}.binding-popover .binding-source{padding:0}.binding-popover .binding-key{margin:10px 0 0 8px;padding-left:10px}.binding-popover .binding-template{font-size:11px;margin-bottom:8px;max-width:100%;overflow-wrap:anywhere}.binding-popover .info-note{font-size:10px;padding:7px;margin:7px 0}.binding-popover .binding-value-preview{margin-top:12px;padding:8px;border-radius:5px}.binding-popover .binding-value-preview textarea{min-height:65px;max-height:130px;font-size:11px}.binding-popover .binding-value-preview .section-line{margin:0 0 5px}.binding-popover .binding-value-preview strong{font-size:11px}.binding-popover .binding-value-preview small{font-size:10px}
|
||||
.rule-constructor .recipe-field{padding-right:34px}.rule-constructor .recipe-field>label{padding-right:0}.rule-constructor .recipe-field .binding-tweak{top:26px;right:0;font-size:20px!important;border:1px solid var(--line);width:29px;height:32px;line-height:1;background:white}.inline-binding{display:flex;gap:5px;align-items:center;min-width:0}.inline-delay{max-width:210px}.inline-delay input{width:65px!important;min-width:0}.binding-popup-trigger{font-size:20px!important;padding:0 7px!important;line-height:1}.inline-delay .binding-button{max-width:145px}.inline-delay>span{font-size:11px}
|
||||
|
||||
.compact-preview .compact-change-list{max-height:320px;overscroll-behavior:contain}.compact-preview .preview-status{display:inline-block;padding:3px 7px;border-radius:4px;font-size:10px;line-height:1.4;font-weight:500}.compact-preview .status-create{background:#e5f3eb;color:#246346}.compact-preview .status-update{background:#e8effb;color:#365e98}.compact-preview .status-remove{background:#fbe9e5;color:#a24435}.compact-preview .status-unchanged{background:#edf0f2;color:#626f79}.preview-list-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;padding-top:9px}.compact-preview .preview-list-toggle{font-size:11px!important;padding:4px 7px!important;min-height:0!important;line-height:1.4;border-radius:5px;font-weight:500}.compact-preview .preview-list-toggle:hover{background:#edf3ef}.compact-preview .preview-totals{gap:6px}@media(max-width:640px){.compact-preview .compact-change-list{max-height:280px}}
|
||||
|
||||
.rule-result{border:1px solid var(--line)!important;border-radius:7px;padding:0!important;margin-top:12px!important}.rule-result>summary{padding:10px 12px;cursor:pointer;font-size:12px}.rule-result>summary small{margin-left:12px;font-weight:400;color:var(--muted)}.rule-result-list{max-height:260px;overflow:auto;overscroll-behavior:contain}.rule-result-row{display:flex;gap:9px;padding:9px 12px;border-top:1px solid var(--line);align-items:flex-start}.rule-result-row>div{min-width:0}.rule-result-row small{display:block;font-size:11px;margin-top:4px;overflow-wrap:anywhere}.rule-result-name{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:12px}.rule-result-row svg{width:17px;height:17px;flex:none}.rule-result>summary .danger{color:#a24435}
|
||||
|
||||
/* Scalar controls fit their values; references keep room for descriptive names. */
|
||||
input[type=number]{max-width:110px;min-width:0}
|
||||
input[type=time]{max-width:132px;min-width:0}
|
||||
input[type=date]:not(.native-date),.date-field{max-width:176px;min-width:0}
|
||||
.date-field{width:176px;justify-self:start}
|
||||
.date-field>input:not(.native-date){width:100%;min-width:0}
|
||||
.rule-constructor .recipe-field.compact-scalar-field{width:210px;max-width:100%;justify-self:start}
|
||||
.rule-constructor .recipe-field.compact-number-field.compact-scalar-field{width:144px}
|
||||
.rule-constructor .compact-scalar-field .inline-value{margin-top:0}
|
||||
|
||||
.rule-constructor .compact-time-field.compact-scalar-field{width:166px}
|
||||
.rule-constructor .compact-scalar-field .binding-tweak{top:auto;bottom:0}
|
||||
|
||||
/* Keep each value picker in the same grid row as its control, below its label. */
|
||||
.rule-constructor .recipe-field{display:grid;grid-template-columns:minmax(0,1fr) 29px;column-gap:5px;row-gap:5px;padding-right:0;align-items:start}
|
||||
.rule-constructor .recipe-field>label{grid-column:1 / -1;margin:0;padding:0}
|
||||
.rule-constructor .recipe-field>input,.rule-constructor .recipe-field>div{grid-column:1;grid-row:2;min-width:0}
|
||||
.rule-constructor .recipe-field .binding-tweak{position:static;grid-column:2;grid-row:2;align-self:start;margin:0}
|
||||
.rule-constructor .recipe-field .inline-value{margin-top:0}
|
||||
.rule-constructor .recipe-field .check{margin:0;min-height:32px;justify-content:flex-start}
|
||||
.rule-constructor .recipe-field input[type=checkbox]{width:16px;height:16px;min-height:16px;flex:0 0 16px;margin:0;padding:0}
|
||||
.rule-constructor .recipe-field .date-field{width:100%}
|
||||
|
||||
.generation-review-bar{overflow:hidden}
|
||||
.generation-review-bar>.compact-preview>summary{padding:12px 14px!important;justify-content:flex-start;gap:8px 12px}
|
||||
.generation-review-bar .preview-totals{flex-wrap:wrap;gap:6px}
|
||||
.generation-review-bar .preview-status{white-space:nowrap}
|
||||
.generation-review-bar>.compact-preview>.compact-preview-body{padding:12px 14px}
|
||||
@media(max-width:700px){.generation-review-bar .review-actions{border-top:1px solid var(--line);justify-content:flex-start;padding:10px 14px}}
|
||||
|
||||
/* Compact variable tree: values first, schema controls on demand. */
|
||||
.compact-data-tree{max-height:52vh;overflow:auto;background:var(--surface,#fff)}
|
||||
.compact-data-tree .data-row{grid-template-columns:minmax(130px,1fr) minmax(140px,2fr) auto;gap:8px;padding:6px 8px 6px calc(12px + var(--data-depth,0)*20px);border-left:0;background:transparent;min-height:43px}
|
||||
.compact-data-tree.structure-mode .data-row{grid-template-columns:minmax(100px,1fr) 112px minmax(130px,2fr) auto}
|
||||
.compact-data-tree .container-row{background:#f7f9f7}
|
||||
.compact-data-tree .data-key{display:flex;align-items:center;gap:5px;min-width:0;color:var(--ink);font-family:Consolas,monospace}
|
||||
.compact-data-tree .data-key>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.compact-data-tree .data-disclosure{border:0;background:none;padding:4px;width:23px;flex-shrink:0}
|
||||
.compact-data-tree input,.compact-data-tree select{padding:6px 8px;min-height:32px;margin:0}
|
||||
.compact-data-tree .data-value .actions{flex-wrap:nowrap;gap:6px;min-width:0}
|
||||
.data-container-preview{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;font-size:11px;color:var(--muted)}
|
||||
.compact-data-tree .data-actions{grid-column:auto;gap:2px}
|
||||
.compact-data-tree .data-actions button{padding:4px;min-width:23px;font-size:11px}
|
||||
.compact-data-tree .data-value .actions button{padding:4px 6px;white-space:nowrap;font-size:10px}
|
||||
@media(max-width:700px){.compact-data-tree .data-row,.compact-data-tree.structure-mode .data-row{grid-template-columns:minmax(0,1fr) auto;padding-left:calc(12px + var(--data-depth,0)*12px);gap:5px}.compact-data-tree .data-key{grid-column:1}.compact-data-tree .data-actions{grid-column:2;grid-row:auto}.compact-data-tree .data-value{grid-column:1/-1;grid-row:auto}.compact-data-tree .data-type{grid-column:1/-1}.compact-data-tree .data-container-preview{max-width:180px}}
|
||||
|
||||
/* Depth lives in the stylesheet: CSP deliberately rejects inline styles. */
|
||||
.compact-data-tree .data-depth-0{--data-depth:0}
|
||||
.compact-data-tree .data-depth-1{--data-depth:1}
|
||||
.compact-data-tree .data-depth-2{--data-depth:2}
|
||||
.compact-data-tree .data-depth-3{--data-depth:3}
|
||||
.compact-data-tree .data-depth-4{--data-depth:4}
|
||||
.compact-data-tree .data-depth-5{--data-depth:5}
|
||||
.compact-data-tree .data-depth-6{--data-depth:6}
|
||||
.compact-data-tree .data-depth-7{--data-depth:7}
|
||||
.compact-data-tree .data-depth-8{--data-depth:8}
|
||||
.compact-data-tree .data-depth-9{--data-depth:9}
|
||||
|
||||
.compact-data-tree .data-row{position:relative;--tree-step:20px}
|
||||
.compact-data-tree .data-row::before{content:"";position:absolute;inset:0 auto 0 12px;width:calc(var(--data-depth,0)*var(--tree-step));background:repeating-linear-gradient(to right,var(--line) 0 1px,transparent 1px var(--tree-step));pointer-events:none}
|
||||
.compact-data-tree .data-row:not(.container-row) .data-key{padding-left:28px}
|
||||
.compact-data-tree .data-key{min-width:0}
|
||||
@media(max-width:700px){.compact-data-tree .data-row{--tree-step:12px}.compact-data-tree .data-row:not(.container-row) .data-key{padding-left:0}}
|
||||
|
||||
.generation-map{width:calc(100vw - 36px);max-width:1600px;height:calc(100vh - 36px);max-height:100vh;border:1px solid var(--line);border-radius:16px;padding:0;color:var(--ink);background:#fff;overflow:hidden}
|
||||
.generation-map::backdrop{background:#172e24a6;backdrop-filter:blur(3px)}
|
||||
.generation-map[open]{display:flex;flex-direction:column}.map-heading{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:20px 24px 12px}.map-heading h2{margin:0;font-size:22px}.map-heading p{font-size:12px;color:var(--muted);margin:8px 0 0}.map-toolbar{display:flex;align-items:center;gap:8px;padding:10px 24px;border-bottom:1px solid var(--line);flex-wrap:wrap}.map-toolbar button,.map-heading button{font-size:12px;padding:7px 12px}.map-status{font-size:11px;color:#33764f}.map-status-error{color:#a54432}.map-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;flex:1;min-height:0}.map-canvas{min-height:0;position:relative;background-color:#f7faf8;background-image:radial-gradient(#c8d7ce 1px,transparent 1px);background-size:20px 20px;overflow:hidden}.map-inspector{padding:18px;border-left:1px solid var(--line);overflow:auto;min-width:0}.map-inspector label{margin:0 0 14px}.map-inspector select{font-size:12px;padding:8px;width:100%}.map-inspector h3{font-size:16px;margin:14px 0 7px;overflow-wrap:anywhere}.map-inspector h4{font-size:12px;margin:18px 0 8px}.map-inspector p{font-size:12px;line-height:1.5}.map-inspector .actions{margin:12px 0;gap:6px;flex-wrap:wrap}.map-inspector button{padding:7px 10px;font-size:11px}.map-inputs,.map-resource{border:1px solid var(--line);border-radius:7px;margin:8px 0;overflow:hidden}.map-inspector summary{cursor:pointer;padding:10px;font-size:12px}.map-resource strong{display:block;margin:5px 0 0}.map-inspector pre{font:11px/1.6 Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f8f6;margin:0;padding:10px;max-height:280px;overflow:auto}.map-legend{display:flex;gap:16px;align-items:center;flex-wrap:wrap;border-top:1px solid var(--line);padding:10px 24px;font-size:10px;color:var(--muted)}.map-legend span:last-child{margin-left:auto}.map-legend-variable::before,.map-legend-rule::before,.map-legend-iteration::before{content:"";display:inline-block;width:9px;height:9px;border-radius:3px;margin-right:5px;background:#d8c9aa}.map-legend-rule::before{background:#79a68a}.map-legend-iteration::before{background:#94add2}
|
||||
@media(max-width:700px){.generation-map{width:calc(100vw - 12px);height:calc(100dvh - 12px)}.map-heading{padding:12px}.map-heading h2{font-size:18px}.map-toolbar{padding:8px 12px}.map-layout{grid-template-columns:1fr;grid-template-rows:minmax(180px,1fr) minmax(150px,40%)}.map-inspector{border-left:0;border-top:1px solid var(--line);padding:12px}.map-legend{padding:8px 12px;gap:8px}.map-legend span:last-child{display:none}}
|
||||
|
||||
.map-input-content{padding:0 12px 12px;max-height:420px;overflow:auto}.map-input-section+.map-input-section{border-top:1px solid var(--line);margin-top:14px}.map-input-section h4{color:var(--muted);font-size:11px;margin:12px 0}.map-input-field{margin:12px 0}.map-input-label{display:flex;gap:8px;justify-content:space-between;font-size:11px;margin-bottom:5px;color:var(--muted)}.map-input-label small{font-size:9px}.map-input-value+.map-input-value{border-top:1px dashed var(--line);margin-top:6px;padding-top:6px}.map-input-token{font-size:12px;overflow-wrap:anywhere;margin:3px 0}.map-input-token>small{display:block;font:10px Consolas,monospace;color:var(--muted);margin-bottom:3px}.map-input-token strong{font-weight:500}.map-reference{border:1px solid #dce6df;background:#f5f8f6;border-radius:6px;padding:7px 9px;display:flex;gap:5px;flex-wrap:wrap;align-items:center}.map-reference>small{flex-basis:100%}.map-reference-kind{font-size:10px;color:#54836a}.map-reference strong{font-weight:600}
|
||||
|
||||
.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}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
const copy = value => JSON.parse(JSON.stringify(value));
|
||||
const countryCodes = 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW'.split(' ');
|
||||
|
||||
|
||||
const timeValue = value => value?.length === 5 ? value + ':00' : value;
|
||||
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const displayDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value || '') ? value.split('-').reverse().join('-') : '';
|
||||
const parseDisplayDate = value => {
|
||||
if(!/^\d{2}-\d{2}-\d{4}$/.test(value))return '';
|
||||
const iso=value.split('-').reverse().join('-'),date=new Date(iso+'T12:00:00Z');
|
||||
return !isNaN(date) && date.toISOString().slice(0,10)===iso ? iso : '';
|
||||
};
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('dateField',()=>({
|
||||
value:'',text:'',init(){this.$watch('value',v=>{if(v)this.text=displayDate(v);});},
|
||||
edit(input){this.text=input.value;this.value=parseDisplayDate(this.text);input.setCustomValidity(this.value?'':tr('enter_a_valid_date_as_dd_mm_yyyy_f6df03'));},
|
||||
picked(){this.text=displayDate(this.value);this.$refs.textDate.setCustomValidity('');}
|
||||
}));
|
||||
Alpine.data('meguri', () => ({
|
||||
...generationUi(),
|
||||
user: null, csrf: '', ready: false, busy: false, error: '', toast: '', allowSignup: false, demo: false, demoLifecycle:null,
|
||||
loginForm: { username: '', password: '', display_name: '' }, 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,
|
||||
t:tr, l:I18n.diagnostic,
|
||||
get language(){return I18n.locale();},
|
||||
label(value){return MEGURI_MESSAGES['enum_'+value]?tr('enum_'+value):value;},
|
||||
get countries(){const names=new Intl.DisplayNames([this.language],{type:'region'});return countryCodes.map(code=>({code,name:names.of(code)})).sort((a,b)=>a.name.localeCompare(b.name,this.language));},
|
||||
browserZone, zones: [...new Set(['UTC',browserZone,...Intl.supportedValuesOf('timeZone')])].sort(),
|
||||
draftPeople: [], picker: null, personQuery: '', pickerCountry: 'CY', pickerName: '', pickerEmail: '', pickerEditing: null,
|
||||
csvText: '', csvResult: null, csvLoading: false, csvError: '', csvSequence: 0,
|
||||
removal: null, draggedRoute: '', dragRevision: null, dragWorkspace: '', routeDropTarget: '', routeDropSide: 'before',
|
||||
absenceResult:null, absenceError:'', absenceLoading:false, absenceSequence:0, absenceRevision:0, showAllAbsence:false,
|
||||
formatDate:displayDate,
|
||||
ttlText(seconds){return seconds>=3600?tr('duration_hours',{count:seconds/3600}):tr('duration_minutes',{count:seconds/60});},
|
||||
get orderedRoutes() { return [...(this.doc?.routes || [])].sort((a,b)=>a.order-b.order); },
|
||||
get canRemoveSchedule() { const p=this.removal?.plan; return !!p && !p.active_replacements.length && (!!this.form.redirect_to || (!p.coverage_fallbacks.length && !p.policies_emptied.length)); },
|
||||
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}]));
|
||||
for(const p of [...(this.doc?.people || []),...this.draftPeople]) people.set(p.id,{...people.get(p.id),...p});
|
||||
return [...people.values()];
|
||||
},
|
||||
get pickerResults() { const q=this.personQuery.trim().toLowerCase(); return this.people.filter(p=>[p.display_name,p.email,p.username].some(s=>s?.toLowerCase().includes(q))); },
|
||||
get selectedSchedule() { return this.doc?.schedules.find(s=>s.id===this.scheduleId); },
|
||||
get holidayGroups() { return [...new Set((this.doc?.holidays || []).map(h=>h.country))].sort((a,b)=>this.countryName(a).localeCompare(this.countryName(b))).map(country=>({country,holidays:this.doc.holidays.filter(h=>h.country===country).sort((a,b)=>a.date.localeCompare(b.date))})); },
|
||||
countryName(code) { return code==='*'?tr('all_countries_legacy_04451d'):new Intl.DisplayNames([this.language],{type:'region'}).of(code || 'CY'); },
|
||||
holidaysOn(day) { return (this.doc?.holidays || []).filter(h=>h.date===day.date).map(h=>h.country+' · '+h.name).join(' / '); },
|
||||
workingSummary() { const w=this.doc?.working_interval; return w ? w.weekdays.map(d=>[tr('mon_24b2a0'),tr('tue_529541'),tr('wed_23408b'),tr('thu_3593cc'),tr('fri_bbd6e3'),tr('sat_6b782d'),tr('sun_48c98c')][d-1]).join(', ')+' · '+w.start.slice(0,5)+'–'+w.end.slice(0,5)+' · '+w.timezone : ''; },
|
||||
conditionText(c) { return (c.negate?tr('not_975146'):'')+this.label(c.field)+' '+this.label(c.operation)+' “'+c.value+'”'; },
|
||||
scheduleTime(id) { return this.doc?.schedules.find(s=>s.id===id)?.handover?.slice(0,5) || '11:00'; },
|
||||
get nav(){return [{ id: 'calendar', name: tr('calendar_adab50'), icon: '▦' }, { id: 'requests', name: tr('requests_f7194e'), icon: '⇄' }, { id: 'configuration', name: tr('schedule_setup'), icon: '⊞' }, { id: 'activity', name: tr('activity_81c0d9'), icon: '≡' }, { id: 'settings', name: tr('team_settings_06b774'), icon: '⚙' }];},
|
||||
get grantOptions(){return [{ id: 'view', name: tr('view_calendar_b702b1') }, { id: 'request_overrides', name: tr('request_replacements_386c5e') }, { id: 'edit_schedules', name: tr('edit_schedules_policies_a02744') }, { id: 'force_overrides', name: tr('apply_without_confirmation_f0350d') }, { id: 'manage_members', name: tr('manage_team_access_db8b53') }, { id: 'manage_integration', name: tr('manage_provider_804901') }, { id: 'view_audit', name: tr('view_audit_history_896487') }];},
|
||||
get workspace() { return this.detail?.workspace; },
|
||||
get doc() { return this.workspace?.document; },
|
||||
get members() { return this.detail?.members || []; },
|
||||
get pending() { return (this.doc?.requests || []).filter(r => r.status === 'pending'); },
|
||||
get coverageItems() {
|
||||
return (this.detail?.coverage?.issues||[]).flatMap(gap=>{
|
||||
const pending=this.pending.flatMap(request=>request.legs.filter(leg=>leg.schedule_id===gap.schedule_id && leg.from_user===gap.user_id && Date.parse(leg.start)<Date.parse(gap.end) && Date.parse(gap.start)<Date.parse(leg.end)).map(leg=>({request,leg})));
|
||||
const bounds=[...new Set([Date.parse(gap.start),Date.parse(gap.end),...pending.flatMap(({leg})=>[Math.max(Date.parse(leg.start),Date.parse(gap.start)),Math.min(Date.parse(leg.end),Date.parse(gap.end))])])].sort((a,b)=>a-b);
|
||||
return bounds.slice(0,-1).map((start,i)=>{
|
||||
const end=bounds[i+1],requests=pending.filter(({leg})=>Date.parse(leg.start)<=start && end<=Date.parse(leg.end)).map(({request})=>request);
|
||||
const approvers=[...new Set(requests.flatMap(r=>r.required_approvers.filter(id=>!r.approvals.includes(id))))];
|
||||
return {...gap,start:new Date(start).toISOString(),end:new Date(end).toISOString(),awaiting:requests.length>0,approvers,substitutes:[...new Set(pending.filter(({leg})=>Date.parse(leg.start)<=start && end<=Date.parse(leg.end)).map(({leg})=>leg.to_user))]};
|
||||
});
|
||||
});
|
||||
},
|
||||
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 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(); },
|
||||
get title() { return this.nav.find(n => n.id === this.view)?.name || tr('calendar_adab50'); },
|
||||
get monthTitle() { return new Date((this.startDay || this.today()) + 'T12:00:00Z').toLocaleDateString(this.language, { month: 'long', year: 'numeric', timeZone: 'UTC' }); },
|
||||
get nowAssignment() { const now = new Date(); return this.calendar.assignments.find(a => !a.unavailable && a.schedule_id === this.scheduleId && new Date(a.start) <= now && now < new Date(a.end)); },
|
||||
can(permission) { return (this.detail?.permissions || []).includes(permission); },
|
||||
person(id) { return this.people.find(p=>p.id===id)?.display_name || this.members.find(m=>m.user.id===id)?.user.display_name || tr('unknown_employee_f68692'); },
|
||||
shortName(id) { return this.person(id).split(' ')[0]; },
|
||||
initials(id) { return this.person(id).split(' ').map(s => s[0]).slice(0, 2).join(''); },
|
||||
color(id) { return ['teal', 'purple', 'amber', 'blue', 'rose'][Math.max(0, this.people.findIndex(p => p.id === id)) % 5]; },
|
||||
scheduleName(id) { return this.doc?.schedules.find(s => s.id === id)?.name || tr('archived_schedule_40f4da'); },
|
||||
today() { return this.dayKey(new Date(), this.doc?.timezone || 'UTC'); },
|
||||
dayKey(date, zone = 'UTC') { const p = Object.fromEntries(new Intl.DateTimeFormat('en', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date).map(p => [p.type, p.value])); return `${p.year}-${p.month}-${p.day}`; },
|
||||
addDays(day, count) { const d = new Date(day + 'T12:00:00Z'); d.setUTCDate(d.getUTCDate() + count); return this.dayKey(d); },
|
||||
monday() { const day = this.today(); const d = new Date(day + 'T12:00:00Z').getUTCDay(); return this.addDays(day, -((d + 6) % 7)); },
|
||||
dayNumber(day) { return new Intl.DateTimeFormat('en',{timeZone:this.displayZone,day:'numeric'}).format(new Date(day.start)); },
|
||||
weekday(day) { return new Intl.DateTimeFormat(this.language,{timeZone:this.displayZone,weekday:'short'}).format(new Date(day.start)).toUpperCase(); },
|
||||
localClock(value) { return new Date(value).toLocaleTimeString('en-GB',{timeZone:this.displayZone,hour:'2-digit',minute:'2-digit',timeZoneName:'shortOffset'}); },
|
||||
dayInterval(day) { return this.formatTime(day.start)+' → '+this.formatTime(day.end); },
|
||||
assignmentInterval(a,day) { return this.formatTime(new Date(Math.max(Date.parse(a.start),Date.parse(day.start))))+' → '+this.formatTime(new Date(Math.min(Date.parse(a.end),Date.parse(day.end)))); },
|
||||
onDay(day) { return this.calendar.assignments.filter(a => a.schedule_id === this.scheduleId && Date.parse(a.start) < Date.parse(day.end) && Date.parse(day.start) < Date.parse(a.end)); },
|
||||
formatTime(value) { const p=Object.fromEntries(new Intl.DateTimeFormat('en-GB',{timeZone:this.displayZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',timeZoneName:'shortOffset'}).formatToParts(new Date(value)).map(p=>[p.type,p.value]));return `${p.day}-${p.month}-${p.year} ${p.hour}:${p.minute}:${p.second} ${p.timeZoneName}`; },
|
||||
endpoint(suffix = '') { return `/api/workspaces/${this.workspace.id}${suffix}`; },
|
||||
async api(path, method = 'GET', body) {
|
||||
const response = await fetch(path, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf }, credentials: 'same-origin', ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
const data = await response.json();
|
||||
if (!response.ok) { if (response.status === 401) this.user = null; throw new Error(data.error || tr('the_request_failed_02d674')); }
|
||||
return data;
|
||||
},
|
||||
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')||''};},
|
||||
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 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();});
|
||||
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);
|
||||
},
|
||||
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 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 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();}},
|
||||
open(type, data = {}) {
|
||||
this.error = ''; this.toast=''; this.menu = false; this.modal = type; this.form = copy(data); this.draftPeople=[]; this.picker=null; this.csvSequence++;
|
||||
if(type==='holiday-import') { this.csvText='';this.csvResult=null;this.csvError='';this.csvLoading=false; }
|
||||
if(type==='working') this.form=copy(this.doc.working_interval);
|
||||
if(type==='person') this.form=copy(data);
|
||||
if (type === 'rotation' && !data.id) this.form = { id: crypto.randomUUID(), name: '', type: 'rotation', anchor: this.today(), shift_days: 7, handover:'11:00:00', participants: this.members.map(m => m.user.id) };
|
||||
if (type === 'priority') this.form = { id: crypto.randomUUID(), name: '', type: 'priority', handover:'11:00:00', participants: this.members.slice(0,2).map(m=>m.user.id), fallback: this.doc.schedules.find(s => s.type === 'rotation')?.id, ...data };
|
||||
if (type === 'absence') {this.absenceResult=null;this.absenceError='';this.absenceRevision=this.workspace.revision;this.showAllAbsence=false;this.form = { id: crypto.randomUUID(), user_id: this.user.id, start: this.today(), end: this.today(), ranges:[],force:false,reason:'',...data };this.absenceChanged();}
|
||||
if (type === 'holiday') this.form = { date: this.today(), name: '', country: this.user.country || 'CY', ...data };
|
||||
if (type === 'member') this.form = { username: '', permissions: ['view', 'request_overrides'], ...data };
|
||||
if (type === 'workspace') this.form = { name: '' };
|
||||
if (type === 'account') this.form = { language:this.user.language || '', display_name: this.user.display_name, country: this.user.country || 'CY', timezone:this.user.timezone || '', current_password: '', password: '' };
|
||||
if (type === 'policy') this.form = { id: crypto.randomUUID(), name: '', steps: [{ delay_minutes: 0, condition: 'if-not-acked', target: `schedule:${this.doc.schedules[0]?.id}` }], repeat: { count: 0, wait_minutes: 0, reset_recipient_states: false, close_alert_after_all: false }, ...data };
|
||||
if (type === 'policy' && data.steps) this.form.steps = data.steps.map(s => ({ ...s, target: s.recipient.id ? `${s.recipient.type}:${s.recipient.id}` : s.recipient.type }));
|
||||
if (type === 'route') this.form = { id: crypto.randomUUID(), name: '', conditions: [], escalation_id: '', working: false, ...data, position:data.id?this.orderedRoutes.findIndex(r=>r.id===data.id)+1:this.doc.routes.length+1 };
|
||||
if (type === 'route' && data.window) Object.assign(this.form, { working: true, ...data.window });
|
||||
if (type === 'integration') this.form = { provider: 'mock', cloud_id: '', team_id: '', advanced_escalations: false, users: {}, ...copy(this.doc.integration) };
|
||||
if (type === 'preferences') this.form = { name: this.doc.name, horizon_days: this.doc.horizon_days };
|
||||
this.$nextTick(() => this.$refs.dialog?.querySelector('input,select,button')?.focus());
|
||||
},
|
||||
choosePerson(target, index=null) { this.picker={target,index}; this.personQuery=''; this.pickerEditing=null; this.pickerName=''; this.pickerEmail=''; this.pickerCountry='CY'; this.$nextTick(()=>this.$refs.personSearch?.focus()); },
|
||||
pickPerson(person) {
|
||||
const p=copy(person); delete p.username;
|
||||
if(!this.doc.people?.some(x=>x.id===p.id) && !this.draftPeople.some(x=>x.id===p.id)) this.draftPeople.push(p);
|
||||
if(this.picker.target==='participants') this.addParticipant(p.id); else if(this.picker.target==='absenceReplacement')this.form.ranges[this.picker.index].replacement=p.id;else this.form[this.picker.target]=p.id;
|
||||
this.picker=null;
|
||||
},
|
||||
addEmailPerson() {
|
||||
const email=this.personQuery.trim().toLowerCase();
|
||||
if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {this.error=tr('enter_a_complete_email_address_5f5f50');return;}
|
||||
const existing=this.people.find(p=>p.email?.toLowerCase()===email);
|
||||
if(existing) {this.pickPerson(existing);return;}
|
||||
this.pickPerson({id:crypto.randomUUID(),user_id:null,email,display_name:this.pickerName.trim() || email,country:this.pickerCountry});
|
||||
},
|
||||
editPerson(id) {
|
||||
const p=this.people.find(p=>p.id===id); if(!p)return;
|
||||
this.picker={target:'edit'};this.pickerEditing=p.id;this.personQuery='';this.pickerName=p.display_name;this.pickerEmail=p.email || '';this.pickerCountry=p.country || 'CY';
|
||||
},
|
||||
savePersonDetails() {
|
||||
const p=copy(this.people.find(p=>p.id===this.pickerEditing));delete p.username;
|
||||
Object.assign(p,{display_name:this.pickerName,email:this.pickerEmail.trim().toLowerCase() || null,country:this.pickerCountry});
|
||||
this.draftPeople=this.draftPeople.filter(x=>x.id!==p.id);this.draftPeople.push(p);this.picker=null;
|
||||
},
|
||||
async previewHolidays() {
|
||||
const sequence=++this.csvSequence;const csv=this.csvText; this.csvResult=null;this.csvError='';
|
||||
if(!csv.trim()){this.csvLoading=false;return;}
|
||||
this.csvLoading=true;
|
||||
try { const result=await this.api(this.endpoint('/holiday-preview'),'POST',{csv}); if(sequence===this.csvSequence && this.modal==='holiday-import') this.csvResult={...result,csv}; }
|
||||
catch(e){if(sequence===this.csvSequence)this.csvError=e.message;}
|
||||
finally{if(sequence===this.csvSequence)this.csvLoading=false;}
|
||||
},
|
||||
csvChanged() { this.csvSequence++;this.csvResult=null;this.csvLoading=true;clearTimeout(this.csvTimer);this.csvTimer=setTimeout(()=>this.previewHolidays(),300); },
|
||||
get canImport() { return !this.csvLoading && this.csvResult?.csv===this.csvText && !this.csvResult.preview.errors.length && this.csvResult.preview.additions.length>0; },
|
||||
absencePayload() {
|
||||
const people=new Map(this.draftPeople.map(p=>[p.id,copy(p)]));
|
||||
for(const id of [this.form.user_id,...this.form.ranges.map(r=>r.replacement)]) {
|
||||
if(id && !this.doc.people.some(p=>p.id===id) && !people.has(id)){const p=copy(this.people.find(p=>p.id===id));delete p.username;people.set(id,p);}
|
||||
}
|
||||
return {revision:this.absenceRevision,absence:{id:this.form.id,user_id:this.form.user_id,start:this.form.start,end:this.form.end},ranges:this.form.ranges.map(r=>({start:r.start,end:r.end,replacement:r.replacement,replace_projects:r.replace_projects,local:{schedule_id:r.schedule_id||null,start:r.start+'T'+timeValue(r.start_time),end:r.end+'T'+timeValue(r.end_time)}})),people:[...people.values()],force:this.form.force,reason:this.form.reason};
|
||||
},
|
||||
absenceChanged() {this.absenceSequence++;this.absenceResult=null;this.absenceError='';this.absenceLoading=true;clearTimeout(this.absenceTimer);this.absenceTimer=setTimeout(()=>this.previewAbsence(),300);},
|
||||
async previewAbsence() {
|
||||
const seq=++this.absenceSequence;
|
||||
if(!this.form.start || !this.form.end || !this.form.user_id || this.form.ranges.some(r=>!r.start || !r.end || !r.start_time || !r.end_time || !r.replacement)){this.absenceLoading=false;this.absenceError=tr('complete_the_dates_and_choose_a_person_for_each_repl_6187c4');return;}
|
||||
const payload=this.absencePayload();
|
||||
try{const result=await this.api(this.endpoint('/absence-preview'),'POST',payload);if(seq===this.absenceSequence && this.modal==='absence')this.absenceResult={...result,key:JSON.stringify(payload)};}
|
||||
catch(e){if(seq===this.absenceSequence)this.absenceError=e.message;}
|
||||
finally{if(seq===this.absenceSequence)this.absenceLoading=false;}
|
||||
},
|
||||
get canSaveAbsence(){return this.modal==='absence' && !this.absenceLoading && this.absenceResult && !this.absenceResult.plan.errors.length && this.absenceResult.key===JSON.stringify(this.absencePayload());},
|
||||
newAbsenceRange(values={}) { const clock=(this.doc?.working_interval?.start||'11:00:00');return {id:crypto.randomUUID(),start:this.form.start,end:this.addDays(this.form.end,1),start_time:clock,end_time:clock,schedule_id:'',replacement:'',replace_projects:false,...values}; },
|
||||
coverAbsenceRow(row){
|
||||
const clock=iso=>new Intl.DateTimeFormat('en-GB',{timeZone:this.doc.timezone,hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'}).format(new Date(iso));
|
||||
const values={schedule_id:row.schedule_id,start:this.dayKey(new Date(row.start),this.doc.timezone),end:this.dayKey(new Date(row.end),this.doc.timezone),start_time:clock(row.start),end_time:clock(row.end)};
|
||||
let index=this.form.ranges.findIndex(r=>Object.entries(values).every(([k,v])=>r[k]===v));
|
||||
if(index<0){this.form.ranges.push(this.newAbsenceRange(values));index=this.form.ranges.length-1;}
|
||||
this.choosePerson('absenceReplacement',index);
|
||||
},
|
||||
addAbsenceRange(){const last=this.form.ranges.at(-1);this.form.ranges.push(this.newAbsenceRange(last?{start:last.end,start_time:last.end_time}:{}));},
|
||||
clickDay(day) {
|
||||
const assignments=this.onDay(day);
|
||||
if(assignments.length===1){this.openDuty(day,assignments[0]);return;}
|
||||
this.open('day',{day,assignments});
|
||||
},
|
||||
openDuty(day,assignment) {
|
||||
const start=new Date(Math.max(Date.parse(day.start),Date.parse(assignment.start))).toISOString(),end=new Date(Math.min(Date.parse(day.end),Date.parse(assignment.end))).toISOString();
|
||||
this.open('request',{revision:this.workspace.revision,schedule_id:assignment.schedule_id,from_user:assignment.user_id,to_user:assignment.user_id===this.user.id?'':this.user.id,start,end,start_day:this.dayKey(new Date(start),this.doc.timezone),end_day:this.dayKey(new Date(end),this.doc.timezone),start_time:this.workspaceClock(start),end_time:this.workspaceClock(end),return_start:this.addDays(day.date,7),return_end:this.addDays(day.date,8),return_start_time:this.doc?.working_interval?.start?.slice(0,5)||'11:00',return_end_time:this.doc?.working_interval?.start?.slice(0,5)||'11:00',reason:'',swap:false,custom_range:false,force_mode:false});
|
||||
this.coverQuery='';this.requestResult=null;this.requestError='';this.requestChanged();
|
||||
},
|
||||
nextOwnDuty() {
|
||||
const day=this.calendar.days.find(d=>Date.parse(d.end)>Date.now() && this.onDay(d).some(a=>a.user_id===this.user.id));
|
||||
if(day){this.openDuty(day,this.onDay(day).find(a=>a.user_id===this.user.id));const time=this.doc?.working_interval?.start?.slice(0,5)||'11:00';Object.assign(this.form,{start_day:day.date,end_day:this.addDays(day.date,1),start_time:time,end_time:time,custom_range:true});}
|
||||
else {this.view='calendar';this.toast=tr('choose_the_day_you_want_to_change_6ce301');setTimeout(()=>this.toast='',4500);}
|
||||
},
|
||||
workspaceClock(value){return new Intl.DateTimeFormat('en-GB',{timeZone:this.doc.timezone,hour:'2-digit',minute:'2-digit',hourCycle:'h23'}).format(new Date(value));},
|
||||
coverRangeChanged(){this.form.custom_range=true;},
|
||||
requestPayload() {
|
||||
const f=this.form,p={revision:f.revision,reason:f.reason.trim(),force:!!f.force_mode,remaining:true};
|
||||
if(f.custom_range || f.swap)Object.assign(p,{schedule_id:f.schedule_id,from_user:f.from_user,to_user:f.to_user,start_day:f.start_day,end_day:f.end_day,start_time:f.start_time,end_time:f.end_time,swap:f.swap,return_start:f.return_start,return_end:f.return_end,return_start_time:f.return_start_time,return_end_time:f.return_end_time});
|
||||
else p.legs=[{schedule_id:f.schedule_id,from_user:f.from_user,to_user:f.to_user,start:f.start,end:f.end}];
|
||||
return p;
|
||||
},
|
||||
requestChanged() {this.requestSequence++;this.requestResult=null;this.requestError='';clearTimeout(this.requestTimer);this.requestLoading=!!this.form.to_user;this.requestTimer=setTimeout(()=>this.previewRequest(),250);},
|
||||
async previewRequest() {
|
||||
const seq=++this.requestSequence;
|
||||
if(this.modal!=='request' || !this.form.to_user || !this.form.start_day || !this.form.end_day){this.requestLoading=false;return;}
|
||||
const payload=this.requestPayload();
|
||||
try {const result=await this.api(this.endpoint('/request-preview'),'POST',payload);if(seq===this.requestSequence && this.modal==='request')this.requestResult={...result,key:JSON.stringify(payload)};}
|
||||
catch(e){if(seq===this.requestSequence)this.requestError=e.message;}
|
||||
finally{if(seq===this.requestSequence)this.requestLoading=false;}
|
||||
},
|
||||
get canSendRequest(){return this.modal==='request' && !!this.form.to_user && !this.requestLoading && this.requestResult?.key===JSON.stringify(this.requestPayload());},
|
||||
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'));
|
||||
},
|
||||
requestTitle(r){const leg=r.legs[0];return leg.to_user===r.requested_by?this.shortName(r.requested_by)+tr('offers_to_cover_935d75')+this.shortName(leg.from_user):this.shortName(r.requested_by)+tr('needs_cover_9e1ff7');},
|
||||
async locateCoverage(gap){this.view='calendar';this.scheduleId=gap.schedule_id;const local=this.dayKey(new Date(gap.start),this.doc.timezone);this.startDay=this.addDays(local,-1);await this.run(()=>this.loadCalendar());const day=this.calendar.days.find(d=>Date.parse(d.start)<=Date.parse(gap.start) && Date.parse(gap.start)<Date.parse(d.end));const assignment=this.calendar.assignments.find(a=>a.schedule_id===gap.schedule_id && a.user_id===gap.user_id && Date.parse(a.start)<=Date.parse(gap.start) && Date.parse(gap.start)<Date.parse(a.end));if(day && assignment)this.openDuty(day,assignment);},
|
||||
async chooseSchedule(id){this.scheduleId=id;this.schedulePicker=false;this.scheduleSearch='';await this.run(()=>this.loadCalendar());},
|
||||
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.bindingEditor || this.rulePeopleTarget){this.closeRuleEditor();return;}
|
||||
if(this.modal==='request'){await this.sendQuickRequest();return;}
|
||||
if(['variable','generation-rule'].includes(this.modal)){this.saveGenerationModal();return;}
|
||||
await this.run(async () => {
|
||||
const f = copy(this.form); const kind = this.modal; const revision = this.workspace?.revision;
|
||||
if (kind === 'workspace') { const created=await this.api('/api/workspaces','POST',f);this.selected=created.id; }
|
||||
else if (kind === 'account') { const r = await this.api('/api/account', 'PUT', f); this.user = r.user; }
|
||||
else if(kind==='working') await this.api(this.endpoint('/working-interval'),'PUT',{revision,interval:{...f,weekdays:f.weekdays.map(Number),start:timeValue(f.start),end:timeValue(f.end)}});
|
||||
else if(kind==='holiday-import') { if(!this.canImport)throw new Error(tr('preview_the_csv_and_fix_any_errors_before_importing_2ccc4c'));await this.api(this.endpoint('/holiday-import'),'POST',{revision:this.csvResult.revision,csv:this.csvText}); }
|
||||
else if(kind==='remove-schedule') { if(!this.canRemoveSchedule)throw new Error(tr('resolve_the_listed_dependencies_before_removing_this_fa1445'));await this.api(this.endpoint('/schedules/'+f.id+'/remove'),'POST',{revision:this.removal.revision,redirect_to:f.redirect_to || null}); }
|
||||
else if(kind==='absence') {if(!this.canSaveAbsence)throw new Error(tr('wait_for_a_valid_absence_preview_before_saving_c743bf'));await this.api(this.endpoint('/absences'),'POST',this.absencePayload());}
|
||||
else if (kind === 'member') await this.api(this.endpoint('/members'), 'PUT', { revision, ...f });
|
||||
else if (kind === 'integration') await this.api(this.endpoint('/integration'), 'PUT', { revision, integration: f.provider === 'mock' ? { provider: 'mock' } : {...f,users:Object.fromEntries(Object.entries(f.users).filter(([id,value])=>value?.trim()).map(([id,value])=>[id,value.trim()]))} });
|
||||
else if (kind === 'force') await this.api(this.endpoint(`/requests/${f.id}`), 'POST', { revision, action: 'force', reason: f.reason });
|
||||
else {
|
||||
const doc = copy(this.doc);
|
||||
const people=new Map((doc.people || []).map(p=>[p.id,p]));
|
||||
for(const p of this.draftPeople)people.set(p.id,p);
|
||||
if(kind==='person'){ const p=copy(f);delete p.username;people.set(p.id,p); }
|
||||
// Snapshot selected workspace users so their scheduling country and external identity are explicit.
|
||||
const selectedIds=['rotation','priority'].includes(kind)?f.participants:kind==='absence'?[f.user_id]:[];
|
||||
for(const id of selectedIds){if(!people.has(id)){const p=copy(this.people.find(p=>p.id===id));delete p.username;people.set(id,p);}}
|
||||
doc.people=[...people.values()];
|
||||
const upsert = (list, entry) => { const i = list.findIndex(x => x.id === entry.id); i === -1 ? list.push(entry) : list.splice(i, 1, entry); };
|
||||
if (['rotation','priority'].includes(kind)) { if(kind==='rotation')f.shift_days = Number(f.shift_days); f.handover=timeValue(f.handover || '11:00'); upsert(doc.schedules, f); }
|
||||
if (kind === 'absence') upsert(doc.absences, f);
|
||||
if (kind === 'holiday') { if(doc.holidays.some(h=>h.date===f.date && h.country===f.country))throw new Error(tr('this_country_already_has_a_holiday_on_that_date_7a457a'));doc.holidays.push(f); }
|
||||
if (kind === 'preferences') Object.assign(doc, f, { horizon_days: Number(f.horizon_days) });
|
||||
if (kind === 'policy') { f.steps = f.steps.map(s => { const [type, id] = s.target.split(':'); return { delay_minutes: Number(s.delay_minutes), condition: s.condition, recipient: id ? { type, id } : { type } }; }); f.repeat.count = Number(f.repeat.count); f.repeat.wait_minutes = Number(f.repeat.wait_minutes); upsert(doc.escalations, f); }
|
||||
if (kind === 'route') {
|
||||
const position=Number(f.position);delete f.position;f.escalation_id ||= null;f.window = f.working ? copy(this.doc.working_interval) : null;
|
||||
doc.routes=this.orderedRoutes.filter(r=>r.id!==f.id).map(copy);doc.routes.splice(position-1,0,f);doc.routes.forEach((r,index)=>r.order=index);
|
||||
}
|
||||
await this.api(this.endpoint('/config'), 'PUT', { revision, document: doc });
|
||||
}
|
||||
await this.loadWorkspaces(); this.modal = ''; this.draftPeople=[];
|
||||
}, force ? tr('replacement_applied_publication_is_queued_89e283') : tr('changes_saved_5ffbaf'));
|
||||
},
|
||||
async decide(request, action) { await this.run(async () => { await this.api(this.endpoint(`/requests/${request.id}`), 'POST', { revision: this.workspace.revision, action }); await this.loadWorkspace(); }, tr('request_updated_c52ad2')); },
|
||||
async removeItem(collection, id) {
|
||||
if(collection==='schedules') { await this.run(async()=>{const result=await this.api(this.endpoint('/schedules/'+id+'/removal'));this.open('remove-schedule',{id,redirect_to:''});this.removal=result;});return; }
|
||||
await this.run(async () => { const doc = copy(this.doc); if (collection === 'holidays') doc.holidays=doc.holidays.filter(h=>h.country+':'+h.date!==id); else doc[collection] = doc[collection].filter(x => x.id !== id); if(collection==='routes')doc.routes.sort((a,b)=>a.order-b.order).forEach((r,index)=>r.order=index); await this.api(this.endpoint('/config'), 'PUT', { revision: this.workspace.revision, document: doc }); await this.loadWorkspace(); }, tr('configuration_updated_a81653'));
|
||||
},
|
||||
startRouteDrag(event,id) {
|
||||
if(!this.can('edit_schedules') || this.busy){event.preventDefault();return;}
|
||||
this.draggedRoute=id;this.dragRevision=this.workspace.revision;this.dragWorkspace=this.workspace.id;
|
||||
event.dataTransfer.effectAllowed='move';event.dataTransfer.setData('text/plain',id);
|
||||
},
|
||||
overRoute(event,id) {
|
||||
if(!this.draggedRoute || this.draggedRoute===id || this.dragWorkspace!==this.workspace.id)return;
|
||||
event.dataTransfer.dropEffect='move';const box=event.currentTarget.getBoundingClientRect();
|
||||
this.routeDropTarget=id;this.routeDropSide=event.clientY<box.top+box.height/2?'before':'after';
|
||||
},
|
||||
clearRouteDrag() { this.draggedRoute='';this.routeDropTarget='';this.dragRevision=null;this.dragWorkspace=''; },
|
||||
async dropRoute(event,id) {
|
||||
if(!this.draggedRoute || this.draggedRoute===id || this.dragWorkspace!==this.workspace.id){this.clearRouteDrag();return;}
|
||||
const moving=this.draggedRoute,revision=this.dragRevision;
|
||||
const box=event.currentTarget.getBoundingClientRect(),after=event.clientY>=box.top+box.height/2;
|
||||
const ids=this.orderedRoutes.map(r=>r.id).filter(r=>r!==moving);const block=ids.filter(r=>this.generated(r));const target=this.generated(id)?(after?block.at(-1):block[0]):id;ids.splice(ids.indexOf(target)+(after?1:0),0,moving);
|
||||
this.clearRouteDrag();await this.saveRouteOrder(ids,revision);
|
||||
},
|
||||
async moveRoute(id,delta) {
|
||||
const ids=this.orderedRoutes.map(r=>r.id),from=ids.indexOf(id);let to=from+delta;
|
||||
if(to<0 || to>=ids.length)return;if(this.generated(ids[to])){while(to+delta>=0 && to+delta<ids.length && this.generated(ids[to+delta]))to+=delta;}ids.splice(from,1);ids.splice(to,0,id);
|
||||
await this.saveRouteOrder(ids,this.workspace.revision);
|
||||
this.$nextTick(()=>this.$refs.routingList?.querySelector('[data-route-id="'+id+'"] .move-route')?.focus());
|
||||
},
|
||||
async saveRouteOrder(ids,revision) {
|
||||
if(ids.join(',')===this.orderedRoutes.map(r=>r.id).join(','))return;
|
||||
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')); },
|
||||
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'); },
|
||||
secretPrefix() { return 'MEGURI_JSM_' + this.workspace.id.replaceAll('-', '').toUpperCase(); },
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<template x-if="bindingEditor"><section class="binding-editor binding-popover" popover="manual" role="region" :aria-label="t('value_binding_1bb7eb')" @click.outside="if(bindingEditor && !$event.target.closest('.binding-popup-trigger,.binding-tweak,.binding-button,select'))closeRuleEditor(false)" @resize.window="positionBindingPopover()" @keydown.escape.stop.prevent="closeRuleEditor()" @keydown.enter.stop="if($event.target.tagName!=='TEXTAREA'){$event.preventDefault();closeRuleEditor()}" x-effect="if(modal!=='generation-rule')closeRuleEditor(false);else if(bindingEditor){JSON.stringify(bindingEditor.binding);$nextTick(()=>positionBindingPopover())}" @scroll.window.capture="positionBindingPopover()">
|
||||
<div class="section-line"><h3 x-text="bindingEditor.label"></h3><button type="button" @click="closeRuleEditor()"><span x-text="t('done_e9b450')"></span></button></div>
|
||||
<code class="template-token binding-template" x-show="bindingEditor.binding?.kind!=='literal' && bindingEditor.binding?.kind!=='output'" x-text="bindingSummary(bindingEditor.binding)"></code>
|
||||
<template x-for="row in bindingRows()" :key="row.depth"><div class="binding-source" :class="{'binding-key':row.depth}">
|
||||
<template x-if="row.parent"><div class="section-line"><button type="button" :disabled="row.index===0" :aria-label="t('move_block_up_b67b5b')" @click="const items=row.parent.items;[items[row.index-1],items[row.index]]=[items[row.index],items[row.index-1]]" x-text="t('symbol_9555ff')"></button><button type="button" class="text danger" @click="row.parent.items.splice(row.index,1)" x-text="t('remove_e96390')"></button></div></template>
|
||||
<label><span x-text="row.label"></span><select :aria-label="t('value_source_c0ae15')" :value="row.binding.kind" @change="changeBindingKind(row.binding,$event.target.value)"><option value="list" x-show="row.binding.kind==='list'" x-text="t('responsible_people_in_priority_order_7205a5')"></option><option value="literal" x-text="t('fixed_value_191429')"></option><option value="variable" x-text="t('workspace_variable_7abc57')"></option><option value="item" x-text="t('current_iteration_value_39357a')"></option><option value="key" x-text="t('current_iteration_key_347e79')"></option><option value="lookup" x-text="t('dictionary_lookup_f38672')"></option><option value="output" x-text="t('created_resource_c04e1e')"></option></select></label>
|
||||
<template x-if="row.binding.kind==='list'"><button type="button" @click="row.binding.items.push(literal(''))" x-text="t('add_person_c126e2')"></button></template>
|
||||
<template x-if="row.binding.kind==='literal'"><div>
|
||||
<template x-if="row.type==='list' || row.type==='collection'"><label><span x-text="t('values_one_per_line_b8e245')"></span><textarea :aria-label="t('fixed_list_a199a2')" :value="Array.isArray(row.binding.value)?row.binding.value.join('\n'):''" @input="row.binding.value=$event.target.value.split(/[\n,;]+/).map(s=>s.trim()).filter(Boolean)" :placeholder="t('alice_example_com_bob_example_com_f2697e')"></textarea></label></template>
|
||||
<template x-if="row.type==='text'"><label><span x-text="t('fixed_text_ab18dc')"></span><input :aria-label="t('fixed_text_ab18dc')" x-model="row.binding.value"></label></template>
|
||||
<template x-if="row.type==='number'"><label><span x-text="t('fixed_number_017ee6')"></span><input :aria-label="t('fixed_number_017ee6')" type="number" :value="row.binding.value" @input="row.binding.value=$event.target.value===''?null:Number($event.target.value)"></label></template>
|
||||
<template x-if="row.type==='boolean'"><label><span x-text="t('fixed_boolean_38876f')"></span><select :aria-label="t('fixed_boolean_38876f')" :value="String(row.binding.value)" @change="row.binding.value=$event.target.value==='true'"><option value="true" x-text="t('true_88b33e')"></option><option value="false" x-text="t('false_97cdbd')"></option></select></label></template>
|
||||
<template x-if="row.type==='time'"><label><span x-text="t('fixed_time_40176a')"></span><input :aria-label="t('fixed_time_40176a')" type="time" x-model="row.binding.value"></label></template>
|
||||
<template x-if="row.type==='date'"><label><span x-text="t('fixed_date_1c63b4')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="row.binding.value"><input type="text" :aria-label="t('fixed_date_1c63b4')" x-ref="textDate" :value="text" @input="edit($event.target)" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10"><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked()" tabindex="-1" aria-hidden="true"></span></label></template>
|
||||
<template x-if="row.type==='person'"><div><label><span x-text="t('workspace_person_844f8f')"></span><select :aria-label="t('workspace_person_844f8f')" :value="people.some(p=>p.id===row.binding.value)?row.binding.value:''" @change="row.binding.value=$event.target.value"><option value="" x-text="t('use_email_address_c12ddf')"></option><template x-for="p in people" :key="p.id"><option :value="p.id" :selected="row.binding.value===p.id" x-text="p.display_name"></option></template></select></label><label x-show="!people.some(p=>p.id===row.binding.value)"><span x-text="t('email_84add5')"></span><input :aria-label="t('fixed_email_189696')" :type="people.some(p=>p.id===row.binding.value)?'text':'email'" x-model="row.binding.value" :placeholder="t('person_example_com_371ab9')"></label></div></template>
|
||||
<p class="validation-warning" x-show="['schedule','policy'].includes(row.type) && row.binding.kind==='literal' && generated(row.binding.value)" x-text="t('generated_direct_reference_hint')"></p>
|
||||
<template x-if="row.type==='schedule' || row.type==='policy'"><label><span x-text="t('existing_resource_1752f5')"></span><select :aria-label="t('existing_resource_1752f5')" x-model="row.binding.value"><option value="" x-text="t('choose_a_resource_3e0493')"></option><template x-for="resource in manualResources(row.type)" :key="resource.id"><option :value="resource.id" :selected="row.binding.value===resource.id" x-text="resource.name"></option></template></select></label></template>
|
||||
</div></template>
|
||||
<label x-show="['variable','lookup'].includes(row.binding.kind)"><span x-text="t('variable_19de69')"></span><select :aria-label="t('binding_variable_137329')" x-model="row.binding.variable"><option value="" x-text="t('choose_a_variable_e27462')"></option><template x-for="v in generationDraft.variables" :key="v.id"><option :value="v.id" :selected="row.binding.variable===v.id" x-text="bindingSummary({kind:'variable',variable:v.id})+' · '+label(v.node.kind)"></option></template></select></label>
|
||||
<label x-show="['item','key'].includes(row.binding.kind)"><span x-text="t('iteration_18905a')"></span><select :aria-label="t('binding_iteration_f1778d')" x-model="row.binding.iteration"><option value="" x-text="t('choose_an_iteration_5d2162')"></option><template x-for="i in form.iterations" :key="i.id"><option :value="i.id" :selected="row.binding.iteration===i.id" x-text="bindingSummary({kind:row.binding.kind==='key'?'key':'item',iteration:i.id})"></option></template></select></label>
|
||||
<p class="info-note" x-show="row.binding.kind==='output'" x-text="t('generated_reference_hint')"></p>
|
||||
<label x-show="row.binding.kind==='output'"><span x-text="t('output_block_3853ab')"></span><select :aria-label="t('binding_output_block_e2b0ab')" x-model="row.binding.output"><option value="" x-text="t('choose_an_output_747632')"></option><template x-for="b in matchingOutputs(row.type)" :key="b.id"><option :value="b.id" :selected="row.binding.output===b.id" x-text="generatedResourceLabel(b)+' · '+bindingSummary(b.fields.name)"></option></template></select></label>
|
||||
<label x-show="!['literal','output','key'].includes(row.binding.kind)"><span x-text="t('nested_field_list_position_optional_bac49a')"></span><input :aria-label="t('nested_field_or_position_eaeb07')" :value="(row.binding.path||[]).join(' / ')" @input="row.binding.path=$event.target.value.split('/').map(s=>s.trim()).filter(Boolean)" :placeholder="t('primary_1_4b34b8')"><small><span x-text="t('leave_empty_to_use_the_whole_value_list_positions_st_8da342')"></span></small></label>
|
||||
<div class="two-columns" x-show="row.type==='text'"><label><span x-text="t('prefix_90eceb')"></span><input :aria-label="t('text_prefix_62b30b')" x-model="row.binding.prefix" :placeholder="t('optional_0c6c41')"></label><label><span x-text="t('suffix_885db9')"></span><input :aria-label="t('text_suffix_f191fa')" x-model="row.binding.suffix" :placeholder="t('optional_0c6c41')"></label></div>
|
||||
</div></template>
|
||||
<div class="binding-value-preview" x-data="{sample:{text:'',context:'',error:''}}" x-effect="sample=bindingValuePreview()">
|
||||
<div class="section-line"><strong x-text="t('binding_preview_title')"></strong><small x-text="sample.context"></small></div>
|
||||
<textarea readonly spellcheck="false" wrap="off" rows="3" :aria-label="t('binding_preview_title')" :value="sample.text" :placeholder="sample.error"></textarea>
|
||||
<p class="validation-warning" x-show="sample.error" x-text="sample.error"></p>
|
||||
</div>
|
||||
</section></template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<dialog class="generation-map" x-ref="generationMap" @cancel.prevent="closeGenerationMap()">
|
||||
<header class="map-heading"><div><h2 x-text="t('map_title')"></h2><p x-text="t('map_hint')"></p></div><button type="button" @click="closeGenerationMap()" :aria-label="t('close_dialog_7b2902')" x-text="t('map_close')"></button></header>
|
||||
<div class="map-toolbar"><span class="map-status" :class="{'map-status-error':mapSnapshot?.report.issues.length}" x-text="mapBusy?t('cover_checking'):mapSnapshot?.report.issues.length?t('diagnostics_count',{count:mapSnapshot.report.issues.length}):t('generation_checked')"></span><button @click="refreshGenerationMap()" :disabled="mapBusy" x-text="t('map_refresh')"></button><div class="grow"></div><button @click="mapZoom(.8)" :aria-label="t('map_zoom_out')" x-text="t('map_minus')"></button><button @click="mapZoom(1.25)" :aria-label="t('map_zoom_in')" x-text="t('map_plus')"></button><button @click="mapFit()" x-text="t('map_fit')"></button></div>
|
||||
<p class="validation-error" x-show="mapError" x-text="mapError"></p>
|
||||
<div class="map-layout"><div class="map-canvas" x-ref="generationCanvas" :aria-label="t('map_title')"></div><aside class="map-inspector">
|
||||
<label><span x-text="t('map_selected')"></span><select :aria-label="t('map_selected')" :value="mapSelected" @change="mapSelect($event.target.value)"><template x-for="n in mapNodes" :key="n.id"><option :value="n.id" x-text="n.title"></option></template></select></label>
|
||||
<template x-if="mapCurrent()"><div><h3 x-text="mapCurrent()?.title"></h3><p class="muted" x-text="mapCurrent()?.subtitle"></p><div class="actions"><button x-show="mapCurrent()?.iteration" @click="mapToggle()" x-text="mapExpanded[mapCurrent()?.rule]?t('map_collapse'):t('map_expand')"></button><button @click="mapEdit()" x-text="t('map_edit')"></button></div>
|
||||
<label x-show="mapRows().length"><span x-text="t('map_pass')"></span><select :aria-label="t('map_pass')" x-model="mapRow"><option value="all" x-text="t('map_all')"></option><template x-for="(row,index) in mapRows()" :key="index"><option :value="String(index)" x-text="mapRowLabel(row,index)"></option></template></select></label>
|
||||
<details class="map-inputs" open><summary x-text="t('map_inputs')"></summary><div class="map-input-content"><template x-if="mapRow==='all'"><div class="map-source-list"><p class="muted" x-show="!mapInputSources().length" x-text="t('map_no_sources')"></p><template x-for="source in mapInputSources()" :key="source.id"><div class="map-source-card"><div class="map-source-title"><span class="badge" x-text="(source.generated?t('generated_symbol')+' ':'')+source.type"></span><strong x-text="source.name"></strong><small x-show="source.count>1" x-text="source.count"></small></div><div class="map-source-addresses"><template x-for="address in source.addresses" :key="address"><code x-text="address"></code></template></div><button class="text" x-show="source.target && source.target!==mapSelected" @click="mapSelect(source.target)" x-text="t('map_view_source')"></button></div></template><p class="muted" x-show="mapRows().length" x-text="t('map_choose_pass_values')"></p></div></template><div x-show="mapRow!=='all'"><p class="muted" x-show="!mapInputSections().length" x-text="t('map_no_inputs')"></p><template x-for="(section,sectionIndex) in mapInputSections()" :key="sectionIndex"><section class="map-input-section"><h4 x-text="section.title"></h4><template x-for="(input,inputIndex) in section.rows" :key="inputIndex"><div class="map-input-field"><div class="map-input-label"><span x-text="input.label"></span><small x-show="input.values.length>1" x-text="t('map_distinct_values',{count:input.values.length})"></small></div><template x-for="(value,valueIndex) in input.values" :key="valueIndex"><div class="map-input-value"><template x-for="(token,tokenIndex) in mapInputTokens(value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><strong x-text="token.text"></strong></div></template></div></template></div></template></section></template></div></div></details>
|
||||
<div class="map-issues"><template x-for="(issue,index) in mapIssues()" :key="index"><p class="validation-error" x-text="l(issue.message)"></p></template></div>
|
||||
<h4 x-text="t('map_resources',{count:mapResources().length})"></h4><p class="muted" x-show="!mapResources().length" x-text="mapSnapshot?.report.issues.length?t('map_unverified'):t('map_empty')"></p>
|
||||
<template x-for="r in mapResources()" :key="r.id"><details class="map-resource"><summary><span class="badge" x-text="label(r.kind)"></span><strong x-text="r.name"></strong></summary><div class="map-resource-fields"><template x-for="(field,fieldIndex) in mapResourceFields(r)" :key="fieldIndex"><div class="map-input-field"><div class="map-input-label" x-text="field.label"></div><small class="map-field-hint" x-show="field.hint" x-text="field.hint"></small><template x-for="(token,tokenIndex) in mapInputTokens(field.value)" :key="tokenIndex"><div class="map-input-token" :class="{'map-reference':token.kind}"><small x-show="token.path" x-text="token.path"></small><span class="map-reference-kind" x-show="token.kind" x-text="(token.generated?t('generated_symbol')+' ':'')+(token.kind||'')"></span><strong x-text="token.text"></strong></div></template></div></template></div></details></template>
|
||||
</div></template></aside></div>
|
||||
<footer class="map-legend"><span class="map-legend-variable" x-text="t('map_variables')"></span><span class="map-legend-rule" x-text="t('map_rules')"></span><span class="map-legend-iteration" x-text="t('map_iterations')"></span><span x-text="t('map_readonly')"></span></footer>
|
||||
</dialog>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Canvas graph is kept outside Alpine's reactive proxy. It never mutates scheduling state.
|
||||
function generationMapUi(){let graph=null,origin=null,request=0;return {
|
||||
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;}},
|
||||
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};});},
|
||||
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});},
|
||||
mapInputSources(){
|
||||
const n=this.mapCurrent(),snapshot=this.mapSnapshot;if(!n||!snapshot||this.mapBusy)return [];
|
||||
const {payload,report}=snapshot,rule=payload.rules.find(r=>r.id===n.rule),sources=new Map();
|
||||
const variable=(id,address)=>{const v=payload.variables.find(v=>v.id===id);if(!v)return;const key='v:'+id;if(!sources.has(key))sources.set(key,{id:key,name:v.name,type:this.label(v.node.kind),count:v.node.entries?.length||0,addresses:[],target:key});const item=sources.get(key);if(address&&!item.addresses.includes(address))item.addresses.push(address);};
|
||||
const scan=(value,owner=rule,depth=0)=>{if(!value||typeof value!=='object'||depth>12)return;
|
||||
if(value.kind==='variable'||value.kind==='lookup')variable(value.variable,'{ '+this.bindingAddress(value,0,owner?.iterations||[])+' }');
|
||||
if(value.kind==='item'||value.kind==='key'){const iteration=owner?.iterations.find(i=>i.id===value.iteration);if(iteration){const before=new Set(sources.keys());scan(iteration.source,owner,depth+1);for(const item of sources.values())if(item.id.startsWith('v:')&&(!before.has(item.id)||iteration.source.variable===item.id.slice(2))){const address='{ '+this.bindingAddress(value,0,owner.iterations)+' }';if(!item.addresses.includes(address))item.addresses.push(address);}}}
|
||||
if(value.kind==='output'){
|
||||
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(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()];
|
||||
},
|
||||
mapInputSections(){
|
||||
const n=this.mapCurrent();if(!n||!this.mapSnapshot||this.mapBusy)return [];
|
||||
if(n.variable)return [{title:n.title,rows:[{label:this.label(n.source.kind),values:[this.sampleNode(n.source)]}]}];
|
||||
const rule=this.mapSnapshot.payload.rules.find(r=>r.id===n.rule);if(!rule)return [];
|
||||
const all=this.mapRows(),rows=this.mapRow==='all'?all:[all[Number(this.mapRow)]].filter(Boolean),unique=values=>[...new Map(values.filter(v=>v!==undefined).map(v=>[JSON.stringify(v),v])).values()];
|
||||
if(n.kind==='iteration')return [{title:n.title,rows:[{label:n.source.name,values:unique(rows.map(r=>r.inputs?.[n.source.id]))}]}];
|
||||
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});}
|
||||
const maxSteps=Math.max(0,...inputs.map(b=>b.steps.length));for(let i=0;i<maxSteps;i++){out.push({label:tr('map_step_delay',{step:i+1}),values:unique(inputs.map(b=>b.steps[i]?.delay))},{label:tr('map_step_recipient',{step:i+1}),values:unique(inputs.map(b=>b.steps[i]?.recipient))});}
|
||||
const conditions=unique(inputs.flatMap(b=>b.conditions));for(const c of conditions)out.push({label:this.label(c.field)+' · '+(c.negate?tr('map_not')+' ':'')+this.label(c.operation),values:[c.value]});
|
||||
if(block.kind==='route'&&inputs.length&&!conditions.length)out.push({label:tr('map_condition'),values:[tr('map_matches_all')]});
|
||||
return {title:this.label(block.kind),rows:out};
|
||||
}).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});
|
||||
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);
|
||||
}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);}
|
||||
}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'));
|
||||
}
|
||||
return fields;
|
||||
},
|
||||
mapInputTokens(value){
|
||||
const result=this.mapSnapshot?.report.result,refs=new Map();
|
||||
for(const p of [...this.people,...(result?.people||[])]){const item={kind:tr('map_person'),text:p.display_name};refs.set(p.id,item);if(p.email)refs.set(p.email,item);}
|
||||
for(const [kind,list] of [['schedule',result?.schedules||this.doc.schedules],['escalation',result?.escalations||this.doc.escalations],['route',result?.routes||this.doc.routes]])for(const r of list){refs.set(r.id,{kind:this.label(kind),text:r.name,generated:!!result?.managed.some(m=>m.id===r.id)});}
|
||||
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});},
|
||||
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');
|
||||
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;}
|
||||
}
|
||||
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);
|
||||
}
|
||||
};}
|
||||
@@ -0,0 +1,73 @@
|
||||
<template x-if="modal==='variable'"><div>
|
||||
<label><span x-text="t('variable_name_39179c')"></span><input x-model="form.name" required maxlength="80" :placeholder="t('responsible_37926b')"></label>
|
||||
<div class="section-line data-mode"><span class="badge" x-text="label(form.node.kind)"></span><button type="button" class="text" @click="dataAdvanced=!dataAdvanced" x-text="dataAdvanced?t('data_values_mode'):t('data_structure_mode')"></button></div>
|
||||
<template x-if="!dataAdvanced && simpleData(form.node)"><div class="simple-data">
|
||||
<template x-if="form.node.kind==='list'"><div><template x-for="(entry,index) in form.node.entries" :key="entry.id"><div class="simple-list-row"><span class="step-number" x-text="index+1"></span><template x-if="entry.kind==='text'"><input :aria-label="t('list_value_4318c3')" x-model="entry.value" :placeholder="t('value_8dce17')"></template><template x-if="entry.kind==='person'"><button type="button" class="grow person-select" @click="editDataPeople(form.node,entry)" x-text="dataPersonName(entry.value)"></button></template><button type="button" :aria-label="t('move_entry_up_647a57')" @click="moveNode(form.node,index,-1)" :disabled="!index"><span x-text="t('symbol_9555ff')"></span></button><button type="button" :aria-label="t('move_entry_down_bc86af')" @click="moveNode(form.node,index,1)" :disabled="index===form.node.entries.length-1"><span x-text="t('symbol_fc4866')"></span></button><button type="button" class="text danger" :aria-label="t('remove_entry_18ffc8')" @click="form.node.entries.splice(index,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><div class="actions"><button type="button" @click="form.node.entries[0]?.kind==='person'?editDataPeople(form.node):addNode(form.node)"><span x-text="t('add_item_51e41d')"></span></button><button type="button" @click="openBulk(form.node)"><span x-text="t('paste_rows_24d667')"></span></button></div></div></template>
|
||||
<template x-if="form.node.kind!=='list'"><div><div class="matrix-head"><span><span x-text="t('project_key_1c60ec')"></span></span><span><span x-text="t('responsible_people_in_order_d69f7b')"></span></span></div><template x-for="(group,index) in form.node.entries" :key="group.id"><div class="matrix-row"><input :aria-label="t('dictionary_key_9777bd')" x-model="group.key" :placeholder="t('project_name_ab9773')"><div class="matrix-people"><template x-for="(entry,i) in group.entries" :key="entry.id"><span class="matrix-person"><b x-text="i+1"></b><span x-text="dataPersonName(entry.value)"></span><button type="button" :aria-label="t('move_person_earlier_52af52')" x-show="i>0" @click="moveNode(group,i,-1)"><span x-text="t('symbol_817964')"></span></button><button type="button" :aria-label="t('remove_person_2ea467')" @click="group.entries.splice(i,1)"><span x-text="t('symbol_67fba2')"></span></button></span></template><button type="button" class="text" @click="editDataPeople(group)" :aria-label="t('add_person_to_4670b9')+group.key"><span x-text="t('person_485c1d')"></span></button></div><button type="button" class="text danger" :aria-label="t('remove_project_333b0c')" @click="form.node.entries.splice(index,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><div class="actions"><button type="button" @click="addDataGroup()"><span x-text="t('add_project_key_71d0f2')"></span></button><button type="button" @click="openBulk(form.node)"><span x-text="t('paste_spreadsheet_rows_e07917')"></span></button></div></div></template>
|
||||
<div class="inline-people" x-show="dataPersonTarget"><div class="section-line"><strong x-text="t('add_to_10956d')+(dataPersonTarget?.key || form.name)"></strong><button type="button" @click="dataPersonTarget=null"><span x-text="t('done_e9b450')"></span></button></div><input type="search" :aria-label="t('find_person_or_email_b640f7')" x-model="dataPersonQuery" :placeholder="t('search_name_or_enter_email_863139')"><div class="cover-options"><template x-for="p in people.filter(p=>[p.display_name,p.email,p.username].some(v=>v?.toLowerCase().includes(dataPersonQuery.toLowerCase())))" :key="p.id"><button type="button" @click="addDataPerson(p.id)" :disabled="dataPersonTarget?.entries.some(e=>e.value===p.id)" x-text="p.display_name"></button></template><button type="button" x-show="/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(dataPersonQuery)" @click="addDataPerson(dataPersonQuery.toLowerCase())" x-text="t('add_e2b431')+dataPersonQuery"></button></div></div>
|
||||
</div></template>
|
||||
<div class="data-tree compact-data-tree" :class="{'structure-mode':dataAdvanced}" x-show="dataAdvanced || !simpleData(form.node)"><template x-for="row in nodeRows(form.node)" :key="row.node.id"><div class="data-row" :class="['data-depth-'+row.depth,{'container-row':dataContainer(row.node)}]">
|
||||
<div class="data-key"><button type="button" class="data-disclosure" x-show="dataContainer(row.node)" :aria-expanded="!!dataExpanded[row.node.id]" :aria-label="t('data_toggle',{name:row.node.key || nodeSummary(row.node)})" @click="toggleDataNode(row.node)" x-text="dataExpanded[row.node.id]?t('data_expanded_symbol'):t('data_collapsed_symbol')"></button><template x-if="row.parent && row.parent.kind!=='list'"><input x-show="dataAdvanced" :aria-label="t('dictionary_key_9777bd')" x-model="row.node.key" :placeholder="t('project_key_98f3fa')" maxlength="120"></template><span x-show="row.parent && row.parent.kind!=='list' && !dataAdvanced" x-text="row.node.key"></span><span x-show="!row.parent || row.parent.kind==='list'" x-text="row.parent?(row.node.kind==='dictionary'?nodeSummary(row.node):t('item_284e12')+(row.index+1)):form.name"></span></div>
|
||||
<select x-show="dataAdvanced" class="data-type" :aria-label="t('value_type_a95648')" :value="row.node.kind" @change="setNodeKind(row.node,$event.target.value)"><template x-for="kind in nodeKinds"><option :value="kind" :selected="row.node.kind===kind" x-text="kind==='duration'?t('duration_minutes_77d597'):label(kind)"></option></template></select>
|
||||
<div class="data-value">
|
||||
<template x-if="['list','dictionary'].includes(row.node.kind)"><div class="actions"><span class="data-container-preview" :title="dataCaption(row.node)" x-text="dataCaption(row.node)"></span><span class="badge" x-text="row.node.entries.length"></span><button type="button" @click="addNode(row.node)"><span x-text="t('entry_dd9298')"></span></button><button type="button" x-show="row.node.entries.every(n=>!dataContainer(n))" @click="openBulk(row.node)"><span x-text="t('paste_rows_24d667')"></span></button></div></template>
|
||||
<template x-if="row.node.kind==='text'"><input :aria-label="t('text_value_9fe378')" x-model="row.node.value" :placeholder="t('value_8dce17')" maxlength="2000"></template>
|
||||
<template x-if="row.node.kind==='person'"><div class="person-value"><select :aria-label="t('registered_person_734f05')" :value="people.some(p=>p.id===row.node.value)?row.node.value:''" @change="row.node.value=$event.target.value"><option value="" x-text="t('use_email_address_c12ddf')"></option><template x-for="person in people" :key="person.id"><option :value="person.id" :selected="row.node.value===person.id" x-text="person.display_name"></option></template></select><input :aria-label="t('person_email_1ed8a9')" :type="people.some(p=>p.id===row.node.value)?'text':'email'" x-show="!people.some(p=>p.id===row.node.value)" x-model="row.node.value" :placeholder="t('person_example_com_371ab9')"></div></template>
|
||||
<template x-if="['number','duration'].includes(row.node.kind)"><input :aria-label="t('number_value_f84357')" type="number" step="any" :value="row.node.value" @input="row.node.value=$event.target.value===''?null:Number($event.target.value)"></template>
|
||||
<template x-if="row.node.kind==='boolean'"><select :aria-label="t('boolean_value_4d92eb')" :value="String(row.node.value)" @change="row.node.value=$event.target.value==='true'"><option value="true" x-text="t('true_88b33e')"></option><option value="false" x-text="t('false_97cdbd')"></option></select></template>
|
||||
<template x-if="row.node.kind==='time'"><input :aria-label="t('time_value_1072a0')" type="time" x-model="row.node.value"></template>
|
||||
<template x-if="row.node.kind==='date'"><span class="date-field" x-data="dateField" x-modelable="value" x-model="row.node.value"><input type="text" :aria-label="t('date_value_1f290a')" x-ref="textDate" :value="text" @input="edit($event.target)" :placeholder="t('dd_mm_yyyy_d7e5fe')" inputmode="numeric" maxlength="10"><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked()" tabindex="-1" aria-hidden="true"></span></template>
|
||||
</div>
|
||||
<div class="data-actions" x-show="row.parent"><button type="button" :aria-label="t('data_duplicate')" @click="duplicateDataNode(row.parent,row.index)" x-text="t('data_duplicate_symbol')"></button><button type="button" :aria-label="t('move_entry_up_647a57')" @click="moveNode(row.parent,row.index,-1)" :disabled="row.index===0"><span x-text="t('symbol_9555ff')"></span></button><button type="button" :aria-label="t('move_entry_down_bc86af')" @click="moveNode(row.parent,row.index,1)" :disabled="!row.parent || row.index===row.parent.entries.length-1"><span x-text="t('symbol_fc4866')"></span></button><button type="button" class="text danger" :aria-label="t('remove_entry_18ffc8')" @click="row.parent.entries.splice(row.index,1)"><span x-text="t('symbol_67fba2')"></span></button></div>
|
||||
</div></template></div>
|
||||
<template x-if="bulkNode"><div class="bulk-editor"><h3><span x-text="t('append_rows_03d5d2')"></span></h3><p class="muted" x-text="bulkNode.kind==='list'?t('one_value_per_line_existing_entries_are_kept_244563'):t('paste_two_spreadsheet_columns_project_key_then_comma_9b3966')"></p><textarea :aria-label="t('rows_to_append_2ddee4')" x-model="bulkText" rows="5"></textarea><div class="actions"><button type="button" @click="try{appendBulk()}catch(e){error=e.message}"><span x-text="t('append_rows_03d5d2')"></span></button><button type="button" @click="bulkNode=null"><span x-text="t('cancel_paste_fbbf61')"></span></button></div></div></template>
|
||||
<div x-show="!rulePeopleTarget"><!-- GENERATION_PREVIEW --></div>
|
||||
|
||||
</div></template>
|
||||
|
||||
<template x-if="modal==='generation-rule'"><div><div class="rule-constructor" x-show="!rulePeopleTarget">
|
||||
<div class="two-columns rule-identity"><label><span x-text="t('rule_name_9e84d7')"></span><input x-model="form.name" required maxlength="120" :placeholder="t('builder_rule_name')"></label><label class="check"><input type="checkbox" x-model="form.enabled"><span x-text="t('enabled_df174a')"></span></label></div>
|
||||
<div class="constructor-layout"><aside class="constructor-inputs">
|
||||
<section class="constructor-section"><div class="section-line"><h3 x-text="t('builder_iterations')"></h3><button type="button" @click="addIteration()" :disabled="form.iterations.length>=4" x-text="t('iteration_3b56cf')"></button></div>
|
||||
<p class="constructor-empty" x-show="!form.iterations.length" x-text="t('builder_once')"></p>
|
||||
<template x-for="(iteration,index) in form.iterations" :key="iteration.id"><div class="constructor-loop">
|
||||
<div class="constructor-loop-title"><span class="logic-key" x-text="index?t('then_for_each_4ca48c'):t('for_each_fb4174')"></span><button type="button" class="text danger" :aria-label="t('builder_remove_iteration',{name:iteration.name})" @click="form.iterations.splice(index,1)" x-text="t('remove_e96390')"></button></div>
|
||||
<label><span x-text="t('builder_alias')"></span><input :aria-label="t('iteration_name_fc3d26')" x-model="iteration.name" :placeholder="t('builder_alias_placeholder')"></label>
|
||||
<label><span x-text="t('iteration_collection_bd6344')"></span><select :aria-label="t('iteration_collection_bd6344')" :value="simpleSource(iteration.source,'collection')" @change="selectSimpleSource(iteration.source,$event.target.value,'collection')"><option value="literal" x-text="t('builder_choose_collection')"></option><template x-for="option in sourceOptions('collection')" :key="option.id"><option :value="option.id" :selected="simpleSource(iteration.source,'collection')===option.id" x-text="option.name"></option></template><option value="advanced" x-text="t('custom_binding')"></option></select></label>
|
||||
<button type="button" class="binding-button constructor-binding" @click="editBinding(iteration.source,'collection',t('iteration_collection_bd6344'))" x-text="bindingSummary(iteration.source)"></button>
|
||||
</div></template>
|
||||
</section>
|
||||
<section class="constructor-section constructor-filter"><h3 x-text="t('builder_filter')"></h3><label class="check"><input type="checkbox" :checked="!!form.when" @change="toggleGenCondition($event.target.checked)"><span x-text="t('only_generate_when_a_condition_matches_78d185')"></span></label>
|
||||
<template x-if="form.when"><div class="constructor-predicate"><button type="button" class="binding-button" @click="editBinding(form.when.left,'text',t('condition_left_value_cc4063'))" x-text="bindingSummary(form.when.left)"></button><select :aria-label="t('generation_condition_424e30')" x-model="form.when.operation"><option value="equals" x-text="t('equals_09b6a6')"></option><option value="not_equals" x-text="t('does_not_equal_86e30f')"></option><option value="contains" x-text="t('contains_33e15d')"></option><option value="not_empty" x-text="t('is_not_empty_414a30')"></option></select><button type="button" class="binding-button" x-show="form.when.operation!=='not_empty'" @click="editBinding(form.when.right,'text',t('condition_right_value_5e741f'))" x-text="bindingSummary(form.when.right)"></button></div></template>
|
||||
</section></aside><main class="constructor-outputs">
|
||||
<section class="recipe-section"><div class="section-line"><h3><span x-text="t('builder_outputs')"></span></h3><select :aria-label="t('add_output_block_00047b')" @change="if($event.target.value)addBlock($event.target.value);$event.target.value=''"><option value="" x-text="t('add_another_resource')"></option><optgroup :label="t('schedules_26d9d0')"><template x-for="kind in ['priority','rotation']"><option :value="kind" x-text="label(kind)"></option></template></optgroup><optgroup :label="t('escalation_policies_ad128e')"><option value="escalation" x-text="label('escalation')"></option></optgroup><optgroup :label="t('routing_rules_49ce36')"><option value="route" x-text="label('route')"></option></optgroup></select></div>
|
||||
<p class="constructor-empty" x-show="!form.blocks.length" x-text="t('builder_add_output')"></p><template x-for="(block,index) in form.blocks" :key="block.id"><section class="generation-block" :data-block-id="block.id">
|
||||
<div class="route-heading"><span class="badge" x-text="(index+1)+' · '+label(block.kind)"></span><strong class="grow" :class="{'template-token':block.fields.name?.kind!=='literal'}" x-text="bindingSummary(block.fields.name)"></strong><button type="button" :aria-label="t('move_block_up_b67b5b')" :disabled="index===0" @click="moveBlock(index,-1)"><span x-text="t('symbol_9555ff')"></span></button><button type="button" :aria-label="t('move_block_down_714fea')" :disabled="index===form.blocks.length-1" @click="moveBlock(index,1)"><span x-text="t('symbol_fc4866')"></span></button><button type="button" class="text danger" @click="form.blocks.splice(index,1)"><span x-text="t('remove_e96390')"></span></button></div>
|
||||
<div class="binding-grid"><template x-for="field in fieldsFor(block.kind)" :key="field.key"><div class="recipe-field" :class="{'compact-scalar-field':['number','date','time','boolean'].includes(field.type) && block.fields[field.key]?.kind==='literal','compact-number-field':field.type==='number','compact-time-field':field.type==='time'}"><label :for="block.id+'-'+field.key" x-text="field.label"></label>
|
||||
<template x-if="field.key==='name'"><input class="block-name-input" :id="block.id+'-'+field.key" :aria-label="field.label" :value="bindingSummary(block.fields.name)" :readonly="block.fields.name.kind!=='literal' || !!block.fields.name.prefix || !!block.fields.name.suffix || !!block.fields.name.path?.length" @input="block.fields.name.value=$event.target.value"></template>
|
||||
<template x-if="field.key!=='name'"><div><select x-show="['list','schedule','policy'].includes(field.type) || block.fields[field.key]?.kind!=='literal'" :id="block.id+'-'+field.key" :aria-label="field.label" :value="simpleSource(block.fields[field.key],field.type)" @change="selectSimpleSource(block.fields[field.key],$event.target.value,field.type)"><option value="literal" x-text="t('fixed_value_191429')"></option><template x-for="option in sourceOptions(field.type)" :key="option.id"><option :value="option.id" :selected="simpleSource(block.fields[field.key],field.type)===option.id" x-text="option.name"></option></template><option value="advanced" x-text="simpleSource(block.fields[field.key],field.type)==='advanced'?bindingSummary(block.fields[field.key]):t('custom_binding')"></option></select>
|
||||
<template x-if="block.fields[field.key]?.kind==='literal' && !['schedule','policy'].includes(field.type)"><div>
|
||||
<template x-if="['text','time','number','person'].includes(field.type)"><input class="inline-value" :aria-label="field.label+' · '+t('fixed_value_191429')" :type="field.type==='number'?'number':field.type==='time'?'time':'text'" :value="block.fields[field.key].value" @input="block.fields[field.key].value=field.type==='number'?($event.target.value===''?null:Number($event.target.value)):$event.target.value"></template>
|
||||
<template x-if="field.type==='date'"><span class="date-field" x-data="dateField" x-modelable="value" x-model="block.fields[field.key].value"><input type="text" :aria-label="field.label+' · '+t('fixed_value_191429')" x-ref="textDate" :value="text" @input="edit($event.target)" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10"><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked()" tabindex="-1" aria-hidden="true"></span></template>
|
||||
<template x-if="field.type==='boolean'"><label class="check"><input type="checkbox" x-model="block.fields[field.key].value"><span x-text="t('enabled_df174a')"></span></label></template>
|
||||
<template x-if="field.type==='list'"><div><div class="people-row"><template x-for="(id,personIndex) in block.fields[field.key].value"><button type="button" class="person-chip" @click="block.fields[field.key].value.splice(personIndex,1)" :aria-label="t('remove_e96390')+' '+dataPersonName(id)" x-text="(personIndex+1)+'. '+dataPersonName(id)+' ×'"></button></template></div><button type="button" class="text" @click="openRulePeople(block.fields[field.key])" x-text="t('add_person_c126e2')"></button></div></template>
|
||||
</div></template>
|
||||
</div></template><button type="button" class="text binding-tweak" @click="editBinding(block.fields[field.key] ||= literal(''),field.type,field.label)" :aria-label="t('bind_891f4e')+field.label" x-text="t('binding_picker_button')" :title="t('value_source_c0ae15')"></button>
|
||||
</div></template></div>
|
||||
<template x-if="block.kind==='escalation'"><div><template x-for="(step,stepIndex) in block.steps" :key="stepIndex"><div class="generation-step"><span class="flow-arrow"><span x-text="t('symbol_fc4866')"></span></span><div class="inline-binding inline-delay"><template x-if="step.delay.kind==='literal'"><input type="number" min="0" :aria-label="t('delay_in_minutes_e18c95')" :value="step.delay.value" @input="step.delay.value=$event.target.value===''?null:Number($event.target.value)"></template><button type="button" class="binding-button" x-show="step.delay.kind!=='literal'" @click="editBinding(step.delay,'number',t('delay_in_minutes_e18c95'))" x-text="bindingSummary(step.delay)"></button><span x-text="t('min_f4e8a8')"></span><button type="button" class="binding-popup-trigger" :aria-label="t('bind_891f4e')+t('delay_in_minutes_e18c95')" @click="editBinding(step.delay,'number',t('delay_in_minutes_e18c95'))" x-text="t('binding_picker_button')"></button></div><select :aria-label="t('escalation_condition_5b4c13')" x-model="step.condition"><option value="if-not-acked" x-text="t('if_not_acknowledged_5fc93a')"></option><option value="if-not-closed" x-text="t('if_not_closed_4369ad')"></option></select><select :aria-label="t('recipient_type_32b1f5')" :value="step.user?'person':'schedule'" @change="step.user=$event.target.value==='person'"><option value="schedule" x-text="t('schedule_0a8ada')"></option><option value="person" x-text="t('person_8c41ae')"></option></select><button type="button" class="binding-button grow" @click="editBinding(step.recipient,step.user?'person':'schedule',t('escalation_recipient_9dfd31'))" x-text="bindingSummary(step.recipient)"></button><button type="button" class="text danger" @click="block.steps.splice(stepIndex,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><button type="button" class="text" @click="addGenStep(block)"><span x-text="t('escalation_step_72b706')"></span></button></div></template>
|
||||
<template x-if="block.kind==='route'"><div><p class="muted" x-show="!block.conditions.length"><span x-text="t('matches_any_alert_add_conditions_to_target_this_proj_299abf')"></span></p><template x-for="(condition,conditionIndex) in block.conditions" :key="conditionIndex"><div class="generation-step"><span class="logic-key" x-text="conditionIndex?t('and_c2f934'):t('if_7be63e')"></span><select :aria-label="t('routing_field_a63fdc')" x-model="condition.field"><option value="tags" x-text="t('tags_848eed')"></option><option value="message" x-text="t('message_68f414')"></option><option value="priority" x-text="t('priority_886cbf')"></option><option value="description" x-text="t('description_55f8eb')"></option><option value="source" x-text="t('source_6da13a')"></option></select><select :aria-label="t('routing_operation_01689c')" x-model="condition.operation"><option value="contains" x-text="t('contains_33e15d')"></option><option value="equals" x-text="t('equals_09b6a6')"></option><option value="starts-with" x-text="t('starts_with_2a61f5')"></option><option value="matches" x-text="t('matches_ee2dbd')"></option></select><button type="button" class="binding-button grow" @click="editBinding(condition.value,'text',t('routing_condition_value_f76e2d'))" x-text="bindingSummary(condition.value)"></button><label class="check"><input type="checkbox" x-model="condition.negate"><span x-text="t('not_ca1dd3')"></span></label><button type="button" class="text danger" @click="block.conditions.splice(conditionIndex,1)"><span x-text="t('symbol_67fba2')"></span></button></div></template><button type="button" class="text" @click="addGenCondition(block)"><span x-text="t('and_condition_63ce48')"></span></button></div></template>
|
||||
</section></template>
|
||||
</section>
|
||||
<details class="recipe-section rule-result" open>
|
||||
<summary><strong x-text="t('rule_result_title')"></strong><small :class="{'danger':generationError || generationResult?.issues.length}" x-text="ruleResultStatus()" aria-live="polite"></small></summary>
|
||||
<div class="rule-result-list" x-show="!generationLoading && ruleResultDocument()">
|
||||
<template x-for="entry in ruleResultResources()" :key="entry.id"><div class="rule-result-row">
|
||||
<svg class="resource-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path :d="resourceIcon(entry.kind)"></path></svg>
|
||||
<div><div class="rule-result-name"><span class="badge" x-text="label(entry.kind)"></span><strong x-text="entry.resource.name"></strong></div><small x-text="resourceBrief(entry.resource,entry.kind,ruleResultDocument())"></small></div>
|
||||
</div></template></div>
|
||||
</details>
|
||||
</main></div></div><template x-if="rulePeopleTarget"><section class="binding-editor"><div class="section-line"><input type="search" :aria-label="t('search_people_or_email')" :placeholder="t('search_people_or_email')" x-model="rulePeopleQuery"><button type="button" @click="closeRuleEditor()" x-text="t('back_c32ae9')"></button></div><div class="people-row"><template x-for="person in people.filter(p=>[p.display_name,p.email,p.username].some(v=>v?.toLowerCase().includes(rulePeopleQuery.toLowerCase())) && !rulePeopleTarget.value.includes(p.id))"><button type="button" @click="addRulePerson(person.id)" x-text="person.display_name"></button></template><button type="button" x-show="/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rulePeopleQuery)" @click="addRulePerson(rulePeopleQuery.trim())" x-text="rulePeopleQuery"></button></div></section></template>
|
||||
<!-- BINDING_POPOVER -->
|
||||
<div x-show="!rulePeopleTarget"><!-- GENERATION_PREVIEW --></div>
|
||||
|
||||
</div></template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<section id="generation" class="setup-section" x-show="view==='configuration'">
|
||||
<template x-if="generationDraft"><div>
|
||||
<div class="setup-intro"><h2 x-text="t('setup_sources')"></h2><p class="muted" x-text="t('setup_sources_hint')"></p></div>
|
||||
<div class="two-columns generation-columns">
|
||||
<div class="panel generation-panel"><div class="section-line"><h2><span x-text="t('workspace_variables_2c8617')"></span></h2><button @click="editVariable()" x-show="can('edit_schedules')"><span x-text="t('variable_596d48')"></span></button></div><p class="muted"><span x-text="t('projects_people_and_shared_values_0cd0a8')"></span></p>
|
||||
<div class="workspace-variable-list" role="region" :aria-label="t('workspace_variables_2c8617')" tabindex="0"><template x-for="variable in generationDraft.variables" :key="variable.id"><div class="variable-card"><div class="grow"><strong x-text="variable.name"></strong><small><span x-text="label(variable.node.kind)"></span> <span x-text="t('symbol_1fdf0d')"></span> <span x-text="nodeSummary(variable.node)"></span></small></div><button @click="editVariable(variable)" x-show="can('edit_schedules')" :aria-label="t('edit_variable_22ec4a')+variable.name"><span x-text="t('edit_530164')"></span></button><button class="text danger" @click="removeGenerationItem('variables',variable.id)" x-show="can('edit_schedules')" :aria-label="t('remove_variable_bdd11d')+variable.name"><span x-text="t('remove_e96390')"></span></button></div></template>
|
||||
</div><p class="muted" x-show="!generationDraft.variables.length"><span x-text="t('no_variables_yet_start_with_a_list_of_projects_and_a_84c8f7')"></span></p></div>
|
||||
<div class="panel generation-panel"><div class="section-line"><h2><span x-text="t('team_generation_rules_f882ba')"></span></h2><button @click="openGenerationMap()" x-show="can('edit_schedules')" x-text="t('map_open')"></button><button @click="editGenerationRule()" x-show="can('edit_schedules')"><span x-text="t('generation_rule_610cb3')"></span></button></div><p class="muted"><span x-text="t('turn_shared_values_into_schedules_automatically_66a530')"></span></p>
|
||||
<div class="generation-rule-list" role="region" :aria-label="t('team_generation_rules_f882ba')" tabindex="0"><template x-for="(rule,ruleIndex) in generationDraft.rules" :key="rule.id"><div class="variable-card"><div class="grow"><strong x-text="rule.name"></strong><small x-text="ruleOverview(rule)"></small></div><button :aria-label="t('move_rule_up_a8ab03')" @click="moveGenerationRule(ruleIndex,-1)" :disabled="ruleIndex===0" x-show="can('edit_schedules')"><span x-text="t('symbol_9555ff')"></span></button><button :aria-label="t('move_rule_down_2c58ed')" @click="moveGenerationRule(ruleIndex,1)" :disabled="ruleIndex===generationDraft.rules.length-1" x-show="can('edit_schedules')"><span x-text="t('symbol_fc4866')"></span></button><button @click="editGenerationRule(rule)" x-show="can('edit_schedules')" :aria-label="t('edit_rule_d0838a')+rule.name"><span x-text="t('edit_530164')"></span></button><button class="text danger" @click="removeGenerationItem('rules',rule.id)" x-show="can('edit_schedules')" :aria-label="t('remove_rule_2dd5ee')+rule.name"><span x-text="t('remove_e96390')"></span></button></div></template>
|
||||
</div><p class="muted" x-show="!generationDraft.rules.length"><span x-text="t('one_rule_can_produce_all_your_project_schedules_bb9e45')"></span></p>
|
||||
<label class="generated-routing-placement" x-show="orderedRoutes.some(r=>!generated(r.id)) && generationDraft.rules.some(r=>r.enabled && r.blocks.some(b=>b.kind==='route'))"><span x-text="t('generated_routing_block_position_e14d64')"></span><select :aria-label="t('generated_routing_block_position_e14d64')" :value="generationDraft.route_position" @change="generationDraft.route_position=Number($event.target.value)"><option value="0" :selected="generationDraft.route_position===0" x-text="t('first_before_manual_routing_rules_b44e75')"></option><template x-for="(route,index) in orderedRoutes.filter(r=>!generated(r.id))" :key="route.id"><option :value="index+1" :selected="generationDraft.route_position===index+1" x-text="t('generated_routing_after_named',{name:route.name})"></option></template></select></label></div>
|
||||
<section id="availability" class="setup-section setup-availability" x-show="view==='configuration'">
|
||||
|
||||
<div class="setup-utilities">
|
||||
<section class="setup-widget absence-widget"><div class="widget-heading"><span x-text="t('absences_0f9ec2')"></span><span class="badge" x-text="doc.absences.length"></span></div><div class="widget-body"><button class="text" x-show="can('edit_schedules') || can('request_overrides') || can('force_overrides')" @click="open('absence')" x-text="t('absence_0e0616')"></button>
|
||||
|
||||
<div class="widget-scroll">
|
||||
<p class="muted" x-show="!doc.absences.length"><span x-text="t('no_absences_planned_224ab7')"></span></p>
|
||||
<template x-for="a in doc.absences" :key="a.id">
|
||||
<div class="list-row">
|
||||
<div>
|
||||
<strong x-text="person(a.user_id)">
|
||||
</strong>
|
||||
<small x-text="formatDate(a.start)+' — '+formatDate(a.end)+t('inclusive_75b495')">
|
||||
</small>
|
||||
</div>
|
||||
<button class="text danger" x-show="can('edit_schedules')" @click="removeItem('absences',a.id)"><span x-text="t('remove_e96390')"></span></button>
|
||||
</div>
|
||||
</template></div></div></section>
|
||||
<section id="public-holidays" class="setup-widget holiday-widget"><div class="widget-heading"><span x-text="t('public_holidays_badf9f')"></span><span class="badge" x-text="doc.holidays.length"></span></div>
|
||||
<div class="widget-body"><div class="actions" x-show="can('edit_schedules')"><button @click="open('holiday-import')" x-text="t('import_csv_eeec98')"></button><button class="text" @click="open('holiday')" x-text="t('holiday_85f1a0')"></button></div><div class="widget-scroll"><p class="muted" x-show="!doc.holidays.length"><span x-text="t('no_holidays_yet_import_dates_for_the_countries_your__c8ed79')"></span></p>
|
||||
<template x-for="group in holidayGroups" :key="group.country">
|
||||
<details class="holiday-group">
|
||||
<summary>
|
||||
<span x-text="countryName(group.country)">
|
||||
</span>
|
||||
<span class="badge" x-text="group.holidays.length" :aria-label="group.holidays.length+t('holidays_9db6c2')">
|
||||
</span>
|
||||
</summary>
|
||||
<template x-for="h in group.holidays" :key="h.date">
|
||||
<div class="list-row">
|
||||
<div class="grow">
|
||||
<strong x-text="h.name">
|
||||
</strong>
|
||||
<small x-text="formatDate(h.date)+t('country_calendar_date_cc65d7')">
|
||||
</small>
|
||||
</div>
|
||||
<button class="text danger" x-show="can('edit_schedules')" @click="removeItem('holidays',h.country+':'+h.date)"><span x-text="t('remove_e96390')"></span></button>
|
||||
</div>
|
||||
</template>
|
||||
</details>
|
||||
</template></div></div></section>
|
||||
</div></section>
|
||||
</div>
|
||||
|
||||
<div class="page-validation" x-data="{validationScope:'generation'}"><!-- VALIDATION_STATUS --></div><div class="generation-review-bar"><!-- GENERATION_PREVIEW --><div class="actions review-actions"><button @click="loadGenerationDraft(true)"><span x-text="t('reload_saved_state_1b1cb9')"></span></button><button class="primary" @click="applyGeneration()" :disabled="busy || !generationCanApply()" x-show="can('edit_schedules')"><span x-text="t('apply_checked_changes_efa2a4')"></span></button></div></div>
|
||||
</div></template>
|
||||
</section>
|
||||
@@ -0,0 +1,8 @@
|
||||
<details class="generation-preview generation-review compact-preview" aria-live="polite">
|
||||
<summary><strong x-text="t(modal?'server_preview_09022a':'setup_review_changes')"></strong><span class="preview-totals" x-show="generationResult"><span class="preview-status status-create" x-text="t('preview_created',{count:generationCount('create')})"></span><span class="preview-status status-update" x-text="t('preview_updated',{count:generationCount('update')})"></span><span class="preview-status status-remove" x-text="t('preview_removed',{count:generationCount('remove')})"></span></span><small x-show="generationLoading" x-text="t('cover_checking')"></small></summary>
|
||||
<div class="compact-preview-body"><template x-if="generationResult"><div>
|
||||
<p class="validation-warning" x-show="generationResult.cancelled_requests" x-text="generationResult.cancelled_requests+t('pending_requests_will_be_cancelled_because_their_sch_ea9b18')"></p>
|
||||
<div class="compact-change-list"><template x-for="change in generationVisibleChanges()" :key="change.id"><div class="compact-change"><span class="badge preview-status" :class="'status-'+change.action" x-text="label(change.action)"></span><div><strong x-text="change.name"></strong><small x-text="label(change.kind)+(change.source?' · '+change.source:'')"></small></div></div></template></div>
|
||||
<div class="preview-list-footer"><small class="preview-status status-unchanged" x-text="t('preview_unchanged',{count:generationCount('unchanged')})"></small><button class="text preview-list-toggle" type="button" x-show="generationChanges().length>generationVisibleChanges().length || generationAll" @click="generationAll=!generationAll" x-text="generationAll?t('show_fewer_resources_3953fb'):t('show_all_382ebd')+generationChanges().length+t('resources_015def')"></button></div>
|
||||
</div></template></div>
|
||||
</details>
|
||||
@@ -0,0 +1,249 @@
|
||||
// Visual data and binding editors. Only typed data is sent to the server; no user code is evaluated.
|
||||
function generationUi(){return {
|
||||
...generationMapUi(),
|
||||
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:''};},
|
||||
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};
|
||||
this.generationResult=null;this.generationAll=false;
|
||||
}
|
||||
if(this.can('edit_schedules'))this.generationChanged();
|
||||
},
|
||||
generationPayload(){
|
||||
if(!this.generationDraft)return null;
|
||||
const payload=copy(this.generationDraft);
|
||||
if(this.modal==='variable'){const at=payload.variables.findIndex(v=>v.id===this.form.id);at<0?payload.variables.push(copy(this.form)):payload.variables.splice(at,1,copy(this.form));}
|
||||
if(this.modal==='generation-rule'){const at=payload.rules.findIndex(v=>v.id===this.form.id);at<0?payload.rules.push(copy(this.form)):payload.rules.splice(at,1,copy(this.form));}
|
||||
const normalize=value=>{if(!value || typeof value!=='object')return;for(const [key,child] of Object.entries(value)){if(['variable','iteration','output'].includes(key)&&child==='')value[key]=null;else normalize(child);}};normalize(payload);return payload;
|
||||
},
|
||||
generationChanged(){this.generationSequence++;this.generationResult=null;this.generationError='';this.generationLoading=true;clearTimeout(this.generationTimer);this.generationTimer=setTimeout(()=>this.previewGeneration(),300);},
|
||||
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)};}
|
||||
catch(e){if(sequence===this.generationSequence)this.generationError=e.message;}
|
||||
finally{if(sequence===this.generationSequence)this.generationLoading=false;}
|
||||
},
|
||||
validationState(scope='form'){
|
||||
let loading=false,checked=false,issues=[];
|
||||
const messages=items=>(items||[]).map(message=>({message}));
|
||||
if(scope==='generation' || ['variable','generation-rule'].includes(this.modal)){loading=this.generationLoading;checked=!!this.generationResult;issues=[...(this.generationResult?.issues||[]),...messages(this.generationError?[this.generationError]:[])];}
|
||||
else if(this.modal==='absence'){loading=this.absenceLoading;checked=!!this.absenceResult;issues=messages([...(this.absenceResult?.plan.errors||[]),...(this.absenceError?[this.absenceError]:[])]);}
|
||||
else if(this.modal==='holiday-import'){loading=this.csvLoading;checked=!!this.csvResult;issues=messages([...(this.csvResult?.preview.errors||[]),...(this.csvError?[this.csvError]:[])]);}
|
||||
else if(this.modal==='request'){loading=this.requestLoading;checked=!!this.requestResult;issues=messages(this.requestError?[this.requestError]:[]);}
|
||||
if(scope==='form' && this.error && !issues.some(i=>i.message===this.error))issues.push({message:this.error});
|
||||
return {loading,checked,issues,label:loading?tr('cover_checking'):issues.length?tr('diagnostics_count',{count:issues.length}):checked?tr('generation_checked'):tr('validation_not_checked')};
|
||||
},
|
||||
issueActions(issue){
|
||||
const payload=this.generationPayload(),actions=[];if(!payload)return actions;
|
||||
const rule=payload.rules.find(r=>r.id===issue.rule_id);
|
||||
if(rule)actions.push({kind:'rule',id:rule.id,block:issue.block_id,name:rule.name});
|
||||
const variables=new Set(issue.variable_id?[issue.variable_id]:[]);
|
||||
const scan=value=>{if(!value || typeof value!=='object')return;if(value.variable)variables.add(value.variable);Object.values(value).forEach(scan);};
|
||||
if(rule){scan(rule.blocks.find(b=>b.id===issue.block_id));scan(rule.iterations);}
|
||||
for(const id of variables){const variable=payload.variables.find(v=>v.id===id);if(variable)actions.push({kind:'variable',id,name:variable.name});}
|
||||
return actions;
|
||||
},
|
||||
async openIssue(action){
|
||||
// Switch editors only after unmounting the old form; preserve the local draft first.
|
||||
const app=Alpine.$data(document.body);
|
||||
app.generationDraft=app.generationPayload();
|
||||
const item=(action.kind==='rule'?app.generationDraft.rules:app.generationDraft.variables).find(v=>v.id===action.id);
|
||||
if(!item)return;
|
||||
app.modal='';app.bindingEditor=null;app.rulePeopleTarget=null;
|
||||
await app.$nextTick();
|
||||
action.kind==='rule'?app.editGenerationRule(item):app.editVariable(item);
|
||||
await app.$nextTick();
|
||||
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||[];},
|
||||
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'));},
|
||||
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');},
|
||||
ruleOverview(rule){
|
||||
const parts=[tr(rule.enabled?'enabled_df174a':'disabled_f4f447')];
|
||||
if(rule.iterations.length)parts.push(tr('rule_iterations_short',{count:rule.iterations.length})+' ('+rule.iterations.map(i=>'{ '+this.bindingAddress(i.source,0,rule.iterations)+' }').join(', ')+')');
|
||||
const kinds=new Map();for(const block of rule.blocks)kinds.set(block.kind,(kinds.get(block.kind)||0)+1);
|
||||
parts.push(tr('rule_blocks_short',{count:rule.blocks.length})+' ('+[...kinds].map(([kind,count])=>(['rotation','priority'].includes(kind)?tr('schedule_type_named',{name:this.label(kind)}):this.label(kind))+(count>1?' ×'+count:'')).join(', ')+')');
|
||||
return parts.join(' · ');
|
||||
},
|
||||
resourceIcon(kind){
|
||||
return ({schedule:'M5 5h14v16H5z M8 3v4 M16 3v4 M5 10h14 M8 14h2 M14 14h2 M8 17h2',rotation:'M20 7v5h-5 M4 17v-5h5 M6 7a7 7 0 0 1 12-1l2 3 M4 15l2 3a7 7 0 0 0 12-1',person:'M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0 M4 21v-2a8 8 0 0 1 16 0v2',clock:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2',fallback:'M5 4v9a4 4 0 0 0 4 4h10 M15 13l4 4-4 4',escalation:'M6 20v-5h6v-5h6V4 M14 8l4-4 4 4',route:'M5 4v12a4 4 0 0 0 4 4h10 M5 8h14 M15 4l4 4-4 4 M15 16l4 4-4 4',condition:'M4 6h16 M7 12h10 M10 18h4',end:'M5 12h14 M12 5l7 7-7 7'})[kind] || 'M12 4v16 M4 12h16';
|
||||
},
|
||||
resourceBrief(resource,kind,document=this.doc){
|
||||
const person=id=>document.people?.find(p=>p.id===id)?.display_name || this.person(id);
|
||||
const scheduleName=id=>document.schedules.find(s=>s.id===id)?.name || this.scheduleName(id);
|
||||
const recipient=r=>r.type==='schedule'?scheduleName(r.id):r.type==='user'?person(r.id):this.describeRecipient(r);
|
||||
if(kind==='schedule'){
|
||||
const people=resource.participants.slice(0,3).map(id=>person(id)).join(', ')+(resource.participants.length>3?'…':'');
|
||||
return resource.type==='rotation'?tr('rotation_brief',{days:resource.shift_days,time:resource.handover.slice(0,5),zone:document.timezone})+' · '+people:this.label(resource.type)+' · '+people+' · '+tr('fallback_brief',{name:scheduleName(resource.fallback)});
|
||||
}
|
||||
if(kind==='escalation')return resource.steps.map(step=>tr('escalation_step_brief',{minutes:step.delay_minutes,recipient:recipient(step.recipient)})).join(' → ');
|
||||
const conditions=resource.conditions.length?resource.conditions.map(c=>this.conditionText(c)).join(' · '):tr('any_alert_4496c3');
|
||||
return conditions+(resource.window?' · '+tr('workspace_working_hours_b8ec6f'):'')+' → '+(document.escalations.find(p=>p.id===resource.escalation_id)?.name || tr('do_not_notify_1a59a6'));
|
||||
},
|
||||
editGenerated(id){this.view='configuration';this.loadGenerationDraft();const owner=this.doc.generation.managed.find(m=>m.id===id);const rule=this.generationDraft.rules.find(r=>r.id===owner?.rule);if(rule)this.editGenerationRule(rule);},
|
||||
editVariable(variable=null){this.loadGenerationDraft();this.open('variable',variable || {id:crypto.randomUUID(),name:'',node:this.newNode('list')});this.bindingEditor=null;this.bulkNode=null;this.dataAdvanced=!variable;this.dataExpanded={[this.form.node.id]:true};this.dataPersonTarget=null;this.generationChanged();},
|
||||
simpleData(node){return node.kind==='list' && node.entries.every(n=>['text','person'].includes(n.kind)) || ['dictionary'].includes(node.kind) && node.entries.every(n=>n.kind==='list' && n.entries.every(p=>p.kind==='person'));},
|
||||
dataPersonName(value){return this.people.find(p=>p.id===value)?.display_name || value || tr('choose_person_f20051');},
|
||||
editDataPeople(node,entry=null){this.dataPersonTarget=node;this.dataPersonEntry=entry;this.dataPersonQuery='';},
|
||||
addDataPerson(value){if(!value.trim())return;if(this.dataPersonEntry){this.dataPersonEntry.value=value.trim();this.dataPersonTarget=null;this.dataPersonEntry=null;}else this.dataPersonTarget.entries.push(this.newNode('person',value.trim()));this.dataPersonQuery='';},
|
||||
addDataGroup(){this.form.node.entries.push(this.newNode('list',null,''));},
|
||||
editGenerationRule(rule=null){this.loadGenerationDraft();this.open('generation-rule',rule || {id:crypto.randomUUID(),name:'',enabled:true,iterations:[],when:null,blocks:[]});for(const block of this.form.blocks)if(block.kind==='escalation')for(const key of ['repeat_count','repeat_wait'])if(!Object.hasOwn(block.fields,key))block.fields[key]=this.literal(0);this.bindingEditor=null;this.ruleAdvanced=false;this.rulePeopleTarget=null;this.generationIssuesOpen=false;this.generationChanged();},
|
||||
|
||||
|
||||
manualResources(type){return (type==='schedule'?this.doc.schedules:this.doc.escalations).filter(resource=>!this.generated(resource.id));},
|
||||
defaultScheduleSource(){const output=this.matchingOutputs('schedule')[0];return output?{kind:'output',output:output.id,path:[]}:this.literal(this.manualResources('schedule')[0]?.id||'');},
|
||||
sourceOptions(type){const options=[];for(const v of this.generationDraft.variables){if(type==='list' && ['dictionary'].includes(v.node.kind)){for(const i of this.form.iterations)options.push({id:'lookup:'+v.id+':'+i.id,name:tr('owners_by_project',{name:v.name}),kind:'lookup',variable:v.id,iteration:i.id});}if(type==='collection'?['list','dictionary'].includes(v.node.kind):type==='list'?v.node.kind==='list':type==='person'?v.node.kind==='person':type==='number'?['number','duration'].includes(v.node.kind):v.node.kind===type)options.push({id:'variable:'+v.id,name:v.name,kind:'variable',variable:v.id});}if(type==='text')for(const i of this.form.iterations)options.push({id:'item:'+i.id,name:tr('each_project_name',{name:i.name}),kind:'item',iteration:i.id});if(['schedule','policy'].includes(type)){for(const b of this.matchingOutputs(type))options.push({id:'output:'+b.id,name:this.generatedResourceLabel(b)+' · '+this.bindingSummary(b.fields.name),kind:'output',output:b.id});for(const r of this.manualResources(type))options.push({id:'saved:'+r.id,name:tr(type==='schedule'?'source_saved_resource':'source_saved_policy',{name:r.name}),kind:'literal',value:r.id});}return options.map(option=>{if(['variable','item','key','lookup'].includes(option.kind)){const binding={...option};if(option.kind==='lookup'){const loop=this.form.iterations.find(i=>i.id===option.iteration),variable=this.generationDraft.variables.find(v=>v.id===loop?.source.variable);binding.key={kind:variable?.node.kind==='list'?'item':'key',iteration:option.iteration};}option.name=this.bindingSummary(binding);}return option;});},
|
||||
simpleSource(binding,type){if(!binding)return '';if(binding.path?.length)return 'advanced';const option=this.sourceOptions(type).find(o=>o.kind===binding.kind && (o.kind==='lookup'?binding.variable===o.variable && binding.key?.iteration===o.iteration && !binding.key.path?.length:o.kind==='literal'?o.value===binding.value:o.kind==='variable'?o.variable===binding.variable:o.kind==='output'?o.output===binding.output:o.iteration===binding.iteration));return option?.id || (binding.kind==='literal'?'literal':'advanced');},
|
||||
selectSimpleSource(binding,id,type){if(id==='advanced'){this.editBinding(binding,type,tr('value_source_c0ae15'));return;}const option=this.sourceOptions(type).find(o=>o.id===id);let next=option?{kind:option.kind,path:[],...(option.variable?{variable:option.variable}:{}),...(option.output?{output:option.output}:{}),...(option.iteration && option.kind!=='lookup'?{iteration:option.iteration}:{}),...(option.kind==='literal'?{value:option.value}:{})}:this.literal(type==='list'?[]:type==='number'?0:type==='boolean'?false:'');if(option?.kind==='lookup'){const loop=this.form.iterations.find(i=>i.id===option.iteration);const variable=this.generationDraft.variables.find(v=>v.id===loop?.source.variable);next.key={kind:variable?.node.kind==='list'?'item':'key',iteration:option.iteration,path:[]};}for(const key of Object.keys(binding))delete binding[key];Object.assign(binding,next);},
|
||||
addRulePerson(value){if(!value || !this.rulePeopleTarget)return;const list=this.rulePeopleTarget.value;if(!list.includes(value))list.push(value);this.rulePeopleQuery='';},
|
||||
sampleNode(node){if(node.kind==='list')return node.entries.map(n=>this.sampleNode(n));if(['dictionary'].includes(node.kind))return Object.fromEntries(node.entries.map(n=>[n.key,this.sampleNode(n)]));return node.value;},
|
||||
sampleBinding(binding,context){if(!binding)return '';const variable=id=>{const v=this.generationDraft.variables.find(v=>v.id===id);return v?this.sampleNode(v.node):undefined;};let value;if(binding.kind==='list')value=binding.items.map(item=>this.sampleBinding(item,context));else if(binding.kind==='literal')value=binding.value;else if(binding.kind==='variable')value=variable(binding.variable);else if(binding.kind==='item')value=context[binding.iteration]?.value;else if(binding.kind==='key')value=context[binding.iteration]?.key;else if(binding.kind==='lookup')value=variable(binding.variable)?.[this.sampleBinding(binding.key,context)];else if(binding.kind==='output'){const block=this.matchingOutputs('all').find(b=>b.id===binding.output);return block?this.sampleBinding(block.fields.name,context):'';}for(const key of binding.path||[])value=value?.[Array.isArray(value)?Number(key)-1:key];if(value===undefined)throw new Error(tr('sample_missing'));return binding.prefix || binding.suffix?(binding.prefix||'')+value+(binding.suffix||''):value;},
|
||||
// Presentation only: build a scoped reference index once, then walk nested values.
|
||||
previewValue(value){
|
||||
const references=new Map();
|
||||
const formatters={
|
||||
person:p=>({type:'person',name:p.display_name,...(p.username?{username:p.username}:{}),...(p.email?{email:p.email}:{}),id:p.id}),
|
||||
schedule:s=>({type:'schedule',name:s.name,kind:s.type,id:s.id}),
|
||||
escalation:p=>({type:'escalation',name:p.name,id:p.id}),
|
||||
route:r=>({type:'route',name:r.name,id:r.id}),
|
||||
};
|
||||
for(const [kind,items] of [['person',this.people],['schedule',this.doc?.schedules||[]],['escalation',this.doc?.escalations||[]],['route',this.doc?.routes||[]]]){
|
||||
for(const item of items){const brief=formatters[kind](item);references.set(item.id,brief);if(kind==='person' && item.email)references.set(item.email,brief);}
|
||||
}
|
||||
const visit=(item,depth=0)=>{
|
||||
if(depth>32)return '…';
|
||||
if(typeof item==='string')return references.get(item)||item;
|
||||
if(Array.isArray(item))return item.map(child=>visit(child,depth+1));
|
||||
if(item && typeof item==='object')return Object.fromEntries(Object.entries(item).map(([key,child])=>[key,visit(child,depth+1)]));
|
||||
return item;
|
||||
};
|
||||
return visit(value);
|
||||
},
|
||||
bindingValuePreview(){
|
||||
const binding=this.bindingEditor?.binding;
|
||||
if(!binding)return {text:'',context:'',error:''};
|
||||
try{
|
||||
const context={},names=[];
|
||||
// Preview one row only; unrelated incomplete loops must not hide literal values.
|
||||
for(const iteration of this.form.iterations||[]){
|
||||
try{const source=this.sampleBinding(iteration.source,context);if(!source || typeof source!=='object')break;const entry=Object.entries(source)[0];if(!entry)break;const [key,value]=entry;context[iteration.id]={key:Array.isArray(source)?'':key,value};names.push(iteration.name+': '+(typeof value==='string'?value:key));}catch{break;}
|
||||
}
|
||||
let value;
|
||||
if(binding.kind==='output'){
|
||||
const block=this.matchingOutputs('all').find(b=>b.id===binding.output);
|
||||
if(!block)throw new Error(tr('sample_missing'));
|
||||
value={type:block.kind,name:this.sampleBinding(block.fields.name,context),block_id:block.id};
|
||||
}else value=this.sampleBinding(binding,context);
|
||||
if(value===undefined)throw new Error(tr('sample_missing'));
|
||||
const text=JSON.stringify(this.previewValue(value),null,2);
|
||||
return {text:text.length>20000?text.slice(0,20000)+'\n…':text,context:binding.kind==='output'?tr('binding_preview_resource'):names.length?tr('binding_preview_row',{row:names.join(' · ')}):tr('binding_preview_hint'),error:''};
|
||||
}catch(error){return {text:'',context:'',error:error.message||tr('sample_missing')};}
|
||||
},
|
||||
ruleResultDocument(){return this.generationResult?.result;},
|
||||
ruleResultResources(){const doc=this.ruleResultDocument();if(!doc)return [];return doc.managed.filter(m=>m.rule===this.form.id).flatMap(m=>{const kind=['rotation','priority'].includes(m.kind)?'schedule':m.kind;const resource=(kind==='schedule'?doc.schedules:kind==='escalation'?doc.escalations:doc.routes).find(r=>r.id===m.id);return resource?[{...m,kind,resource}]:[];});},
|
||||
ruleResultStatus(){if(this.generationLoading)return tr('cover_checking');if(this.generationError || this.generationResult?.issues.length)return tr('rule_result_invalid');if(!this.generationResult)return tr('rule_result_waiting');if(!this.form.enabled)return tr('rule_result_disabled');const count=this.ruleResultResources().length;return count?tr('rule_result_count',{count}):tr('rule_result_empty');},
|
||||
|
||||
saveGenerationModal(){
|
||||
if(!this.form.name.trim()){this.error=tr('enter_a_name_86c810');return;}
|
||||
// An incomplete cross-reference may remain in the local draft, but activation is always blocked.
|
||||
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);},
|
||||
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];},
|
||||
dataContainer(node){return ['list','dictionary'].includes(node.kind);},
|
||||
dataCaption(node){if(node.kind==='dictionary'){const lists=node.entries.filter(n=>n.kind==='list');return lists.map(n=>this.nodeSummary(n)).join(' · ');}return this.nodeSummary(node);},
|
||||
duplicateDataNode(parent,index){const renew=n=>{n.id=crypto.randomUUID();n.entries.forEach(renew);};const n=copy(parent.entries[index]);renew(n);parent.entries.splice(index+1,0,n);this.dataExpanded[parent.id]=true;},
|
||||
addNode(parent){this.dataExpanded[parent.id]=true;const kind=parent.entries[0]?.kind||'text';parent.entries.push(this.newNode(kind));},
|
||||
moveNode(parent,index,delta){const to=index+delta;if(to<0 || to>=parent.entries.length)return;[parent.entries[index],parent.entries[to]]=[parent.entries[to],parent.entries[index]];},
|
||||
nodeSummary(node,depth=0){
|
||||
const entries=node.entries||[];
|
||||
if(node.kind==='list')return depth>=2?entries.length+tr('entries_7c3fe9'):entries.slice(0,4).map(n=>this.nodeSummary(n,depth+1)).join(', ')+(entries.length>4?'…':'');
|
||||
if(node.kind==='dictionary'){
|
||||
const name=['name','title'].map(key=>entries.find(n=>n.key===key && n.kind==='text' && String(n.value??'').trim())).find(Boolean);
|
||||
return name?String(name.value):entries.length+tr('entries_7c3fe9')+(entries.length?' · '+entries.slice(0,3).map(n=>n.key).join(', ')+(entries.length>3?'…':''):'');
|
||||
}
|
||||
return node.kind==='person'?this.dataPersonName(node.value):node.kind==='date'?this.formatDate(node.value):String(node.value??'');
|
||||
},
|
||||
appendBulk(){
|
||||
const lines=this.bulkText.split(/\r?\n/).map(s=>s.trim()).filter(Boolean),target=this.bulkNode;
|
||||
if(!target || !lines.length)return;
|
||||
if(target.kind==='list')target.entries.push(...lines.map(s=>this.newNode(target.entries[0]?.kind||'text',s)));
|
||||
else {
|
||||
const parsed=lines.map((line,i)=>{const cells=line.split('\t');if(cells.length!==2 || !cells[0].trim() || !cells[1].trim())throw new Error(tr('line_2b754a')+(i+1)+tr('paste_a_project_key_a_tab_and_comma_separated_emails_2b81fc'));const n=this.newNode('list',null,cells[0].trim());n.entries=cells[1].split(',').map(s=>this.newNode('person',s.trim()));return n;});target.entries.push(...parsed);
|
||||
}
|
||||
this.bulkNode=null;this.bulkText='';
|
||||
},
|
||||
openBulk(node){this.bulkNode=node;this.bulkText='';},
|
||||
addIteration(){if(this.form.iterations.length>=4)return;const v=this.generationDraft.variables.find(v=>['list','dictionary'].includes(v.node.kind));this.form.iterations.push({id:crypto.randomUUID(),name:tr('item_284e12')+(this.form.iterations.length+1),source:{kind:'variable',variable:v?.id||null,path:[]}});},
|
||||
fieldsFor(kind){
|
||||
const common=[{key:'name',label:tr('name_709a23'),type:'text'}],handover={key:'handover',label:tr('handover_time_c9e315'),type:'time'};
|
||||
if(kind==='rotation')return [...common,{key:'participants',label:tr('participants_in_rotation_order_4edee0'),type:'list'},{key:'anchor',label:tr('anchor_date_e77bd6'),type:'date'},{key:'shift_days',label:tr('shift_length_days_0b28fa'),type:'number'},handover];
|
||||
if(kind==='priority')return [...common,{key:'participants',label:tr('responsible_people_in_priority_order_7205a5'),type:'list'},{key:'fallback',label:tr('fallback_schedule_7b7a21'),type:'schedule'},handover];
|
||||
if(kind==='escalation')return [...common,{key:'repeat_count',label:tr('repeat_count_912e08'),type:'number'},{key:'repeat_wait',label:tr('minutes_between_repeats_f17ab0'),type:'number'}];
|
||||
return [...common,{key:'escalation',label:tr('escalation_policy_bbb0f7'),type:'policy'},{key:'working',label:tr('only_during_working_hours_fa1eb6'),type:'boolean'}];
|
||||
},
|
||||
newBlock(kind){
|
||||
const fields={};for(const field of this.fieldsFor(kind)){const val=field.key==='name'?tr('new_61bd57')+this.label(kind):field.key==='anchor'?this.today():field.key==='handover'?'11:00':field.key==='shift_days'?7:field.type==='number'?0:field.type==='boolean'?false:field.type==='list'?[this.user.id]:field.type==='person'?this.user.id:'';fields[field.key]=this.literal(val);}
|
||||
return {id:crypto.randomUUID(),kind,fields,steps:kind==='escalation'?[{delay:this.literal(0),recipient:this.defaultScheduleSource(),user:false,condition:'if-not-acked'}]:[],conditions:[]};
|
||||
},
|
||||
addBlock(kind){this.form.blocks.push(this.newBlock(kind));this.$nextTick(()=>this.$refs.dialog.querySelector('.generation-block:last-of-type')?.scrollIntoView({block:'center',behavior:'smooth'}));},
|
||||
moveBlock(index,delta){const to=index+delta;if(to<0 || to>=this.form.blocks.length)return;[this.form.blocks[index],this.form.blocks[to]]=[this.form.blocks[to],this.form.blocks[index]];},
|
||||
addGenStep(block){block.steps.push({delay:this.literal(10),recipient:this.defaultScheduleSource(),user:false,condition:'if-not-acked'});},
|
||||
addGenCondition(block){block.conditions.push({field:'tags',operation:'contains',value:this.literal(''),negate:false});},
|
||||
templatePath(base,key){return /^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(key)?base+'.'+key:base+'['+JSON.stringify(key)+']';},
|
||||
bindingAddress(b,depth=0,iterations=this.form.iterations||[]){
|
||||
if(!b || depth>16)return '?';
|
||||
const variable=this.generationDraft?.variables.find(v=>v.id===b.variable)?.name;
|
||||
const iteration=iterations.find(i=>i.id===b.iteration)?.name;
|
||||
let address='?';
|
||||
if(b.kind==='literal')address=JSON.stringify(b.value??null);
|
||||
if(b.kind==='variable' || b.kind==='lookup')address=variable?this.templatePath('var',variable):'var[?]';
|
||||
if(b.kind==='lookup')address+='['+this.bindingAddress(b.key,depth+1,iterations)+']';
|
||||
if(['item','key'].includes(b.kind)){const name=iteration?(/^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(iteration)?iteration:'iteration['+JSON.stringify(iteration)+']'):'iteration[?]';address=name+(b.kind==='key'?'.key':'.value');}
|
||||
if(b.kind==='list')address='['+(b.items||[]).map(item=>this.bindingAddress(item,depth+1,iterations)).join(', ')+']';
|
||||
for(const key of b.path||[])address=/^[1-9][0-9]*$/.test(key)?address+'['+key+']':this.templatePath(address,key);
|
||||
return address;
|
||||
},
|
||||
bindingSummary(b,type='',depth=0){
|
||||
if(!b || depth>16)return tr('choose_a_value_e0b399');
|
||||
if(b.kind==='output'){const resource=this.matchingOutputs('all').find(o=>o.id===b.output);return this.generatedResourceLabel(resource)+' · '+(resource?this.bindingSummary(resource.fields.name,'text',depth+1):tr('choose_an_output_747632'));}
|
||||
let text;
|
||||
if(b.kind==='literal' && !b.path?.length){if(type==='date')text=this.formatDate(b.value);else if(Array.isArray(b.value))text=b.value.map(v=>this.people.some(p=>p.id===v)?this.person(v):v).join(', ');else text=this.people.some(p=>p.id===b.value)?this.person(b.value):String(b.value??tr('choose_a_value_e0b399'));}
|
||||
else text='{ '+this.bindingAddress(b)+' }';
|
||||
return (b.prefix||'')+text+(b.suffix||'');
|
||||
},
|
||||
editBinding(binding,type,label){this.rememberEditorOrigin();this.bindingEditor={binding,type,label};this.$nextTick(()=>{const popup=document.querySelector('.binding-popover');if(!popup || !this.bindingEditor)return;popup.showPopover();this.positionBindingPopover();popup.querySelector('select')?.focus({preventScroll:true});});},
|
||||
positionBindingPopover(){const popup=document.querySelector('.binding-popover');if(!popup || !this.bindingEditor)return;const rect=this.editorOrigin?.getBoundingClientRect()||{left:16,right:16,top:80,bottom:100},width=Math.min(390,innerWidth-24),height=Math.min(560,innerHeight-32);popup.style.width=width+'px';popup.style.maxHeight=height+'px';const right=rect.right+10,left=rect.left-width-10;popup.style.left=Math.max(12,Math.min(right+width<=innerWidth-12?right:left>=12?left:innerWidth-width-12,innerWidth-width-12))+'px';popup.style.top=Math.max(12,Math.min(rect.top,innerHeight-Math.min(popup.scrollHeight,height)-16))+'px';},
|
||||
rememberEditorOrigin(){this.editorScroll=this.$refs.dialog.scrollTop;this.editorOrigin=document.activeElement;},
|
||||
closeRuleEditor(restoreFocus=true){
|
||||
if(this.bindingEditor){const popup=document.querySelector('.binding-popover');if(popup?.matches(':popover-open'))popup.hidePopover();this.bindingEditor=null;if(restoreFocus)this.editorOrigin?.focus({preventScroll:true});return;}
|
||||
const dialog=this.$refs.dialog,scroll=this.editorScroll,origin=this.editorOrigin,formId=this.form.id;
|
||||
this.bindingEditor=null;this.rulePeopleTarget=null;
|
||||
// Alpine restores x-show sections on the next frame; scrolling before layout clamps to the shorter editor.
|
||||
this.$nextTick(()=>requestAnimationFrame(()=>requestAnimationFrame(()=>{
|
||||
if(!dialog?.isConnected || this.modal!=='generation-rule' || this.form.id!==formId || this.bindingEditor || this.rulePeopleTarget)return;
|
||||
if(origin?.isConnected)origin.focus({preventScroll:true});
|
||||
dialog.scrollTop=scroll;
|
||||
})));
|
||||
},
|
||||
openRulePeople(binding){this.rememberEditorOrigin();this.rulePeopleTarget=binding;this.rulePeopleQuery='';this.$nextTick(()=>{const dialog=this.$refs.dialog;if(!dialog || !this.rulePeopleTarget)return;dialog.scrollTop=0;dialog.querySelector('.binding-editor input')?.focus({preventScroll:true});});},
|
||||
bindingRows(){const rows=[];const walk=(binding,type,label,level=0,parent=null,index=0)=>{if(!binding || level>15)return;rows.push({binding,type,label,parent,index,depth:rows.length});if(binding.kind==='lookup')walk(binding.key,'text',tr('dictionary_key_source_b4a40b'),level+1);if(binding.kind==='list')binding.items.forEach((item,index)=>walk(item,'person',String(index+1),level+1,binding,index));};walk(this.bindingEditor?.binding,this.bindingEditor?.type,tr('value_source_c0ae15'));return rows;},
|
||||
changeBindingKind(binding,kind){binding.kind=kind;binding.path=[];if(kind==='lookup' && !binding.key)binding.key={kind:'item',iteration:this.form.iterations[0]?.id||null,path:[]};if(kind==='literal' && binding.value===undefined)binding.value='';},
|
||||
generatedResourceLabel(block){return tr(block?.kind==='escalation'?'source_generated_policy':['rotation','priority'].includes(block?.kind)?'source_generated_schedule':'source_generated_resource');},
|
||||
matchingOutputs(type){return [...(this.form.blocks||[]),...(this.generationDraft?.rules||[]).filter(r=>r.id!==this.form.id && r.enabled && !r.iterations.length).flatMap(r=>r.blocks)].filter(b=>type==='policy'?b.kind==='escalation':type==='schedule'?['rotation','priority'].includes(b.kind):true);},
|
||||
toggleGenCondition(enabled){this.form.when=enabled?{left:this.literal(''),operation:'equals',right:this.literal('')}:null;},
|
||||
};}
|
||||
@@ -0,0 +1,35 @@
|
||||
// The complete, versioned catalog is embedded before this script by the server.
|
||||
// Only textContent/Alpine x-text is used: parameters are never interpreted as HTML.
|
||||
const I18n = (() => {
|
||||
const supported=['en','ru','fr'];
|
||||
const detect=(languages)=>{for(const raw of languages || []){const base=String(raw).toLowerCase().split(/[-_]/)[0];if(supported.includes(base))return base;}return 'en';};
|
||||
const browser=detect(navigator.languages?.length?navigator.languages:[navigator.language]);
|
||||
const locale=()=>globalThis.Alpine?.store('locale')?.language || browser;
|
||||
const translate=(key,params={},language=locale())=>{
|
||||
const entry=MEGURI_MESSAGES[key];
|
||||
if(!entry)throw new Error('Missing localization key: '+key);
|
||||
const pattern=entry[language] || entry.en;
|
||||
return pattern.replace(/\{([\w]+)\}/g,(match,name)=>Object.hasOwn(params,name)?String(params[name]):match);
|
||||
};
|
||||
const exact=new Map();
|
||||
for(const [key,entry]of Object.entries(MEGURI_MESSAGES))for(const text of Object.values(entry))if(text)exact.set(text,key);
|
||||
const patterns=[];
|
||||
for(const [key,entry]of Object.entries(MEGURI_MESSAGES)){
|
||||
if(!entry.en.includes('{'))continue;
|
||||
const names=[];let from=0,expression='^';
|
||||
for(const match of entry.en.matchAll(/\{(\w+)\}/g)){expression+=entry.en.slice(from,match.index).replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'([\\s\\S]*?)';names.push(match[1]);from=match.index+match[0].length;}
|
||||
if(names.length){expression+=entry.en.slice(from).replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'$';patterns.push({key,names,re:new RegExp(expression)});}
|
||||
}
|
||||
const diagnostic=(text)=>{
|
||||
if(text==null)return '';
|
||||
text=String(text);const key=exact.get(text);if(key)return translate(key);
|
||||
for(const item of patterns){const match=item.re.exec(text);if(match)return translate(item.key,Object.fromEntries(item.names.map((name,i)=>[name,match[i+1]])));}
|
||||
return text; // User values and third-party diagnostics are opaque, never guessed or rewritten.
|
||||
};
|
||||
document.addEventListener('alpine:init',()=>{
|
||||
Alpine.store('locale',{language:browser});
|
||||
Alpine.effect(()=>{const language=locale();document.documentElement.lang=language;const title=document.querySelector('[data-title-key]');if(title)document.title=translate(title.dataset.titleKey);});
|
||||
});
|
||||
return Object.freeze({supported,browser,detect,locale,translate,diagnostic});
|
||||
})();
|
||||
function tr(key,params){return I18n.translate(key,params);}
|
||||
@@ -0,0 +1,930 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title data-title-key="meguri_on_call_together_f7894c"></title>
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
<script defer src="/assets/i18n.js"></script>
|
||||
<script defer src="/assets/cytoscape.js"></script>
|
||||
<script defer src="/assets/generation-map.js"></script>
|
||||
<script defer src="/assets/generation.js"></script>
|
||||
<script defer src="/assets/app.js">
|
||||
</script>
|
||||
<script defer src="/assets/alpine.js">
|
||||
</script>
|
||||
</head>
|
||||
<body x-data="meguri" :class="{'demo-mode':demo}">
|
||||
<div class="demo-banner" x-ref="demoBanner" x-cloak x-show="demo" role="status" :aria-label="t('demo_data_retention_e272aa')"><div><strong><span x-text="t('demo_temporary_data_8112dd')"></span></strong><span x-text="demoLifecycle?t('teams_workspaces_332acb')+ttlText(demoLifecycle.retention.workspaces)+t('requests_0e1805')+ttlText(demoLifecycle.retention.requests)+t('visitor_accounts_f4ac15')+ttlText(demoLifecycle.retention.accounts):''"></span><span><span x-text="t('restart_resets_demo_data_examples_refresh_automatica_0082d1')"></span></span></div><p class="demo-protection" x-show="demoLifecycle?.isolated"><strong><span x-text="t('protected_database_isolated_demo_active_2e4ba8')"></span></strong> <span x-text="t('the_configured_database_was_not_verified_as_demo_and_c1a54e')"></span></p></div>
|
||||
<datalist id="timezones">
|
||||
<template x-for="zone in zones">
|
||||
<option :value="zone">
|
||||
</option>
|
||||
</template>
|
||||
</datalist>
|
||||
<div class="loading" x-show="!ready"><span x-text="t('loading_meguri_856b9a')"></span></div>
|
||||
<div class="toast" role="status" x-cloak x-show="toast" x-text="l(toast)">
|
||||
</div>
|
||||
<div class="error" role="alert" x-cloak x-show="error">
|
||||
<span x-text="l(error)">
|
||||
</span>
|
||||
<button @click="error=''" :aria-label="t('dismiss_error_347aaa')"><span x-text="t('symbol_67fba2')"></span></button>
|
||||
</div>
|
||||
<main class="login" x-cloak x-show="ready && !user">
|
||||
<div class="login-story">
|
||||
<a class="brand" href="/"><span x-text="t('symbol_4d82f1')"></span> <span><span x-text="t('meguri_d160e8')"></span></span>
|
||||
</a>
|
||||
<div>
|
||||
<span class="eyebrow"><span x-text="t('on_call_in_good_hands_f1cf2d')"></span></span>
|
||||
<h1><span x-text="t('a_clear_rhythm_c58fae')"></span><br><span x-text="t('a_shared_responsibility_2c5e48')"></span></h1>
|
||||
<p><span x-text="t('one_calendar_for_your_team_thoughtful_rotations_simp_52aff3')"></span></p>
|
||||
</div>
|
||||
<span class="quiet"><span x-text="t('plan_here_stay_in_sync_everywhere_70e528')"></span></span>
|
||||
</div>
|
||||
<form class="login-form" @submit.prevent="signIn">
|
||||
<span class="eyebrow"><span x-text="t('your_team_s_home_for_on_call_66a895')"></span></span>
|
||||
<h2 x-text="registering ? t('create_your_account_4046b5') : t('welcome_back_b80783')"></h2>
|
||||
<p class="muted"><span x-text="t('make_space_for_a_smoother_handover_44adaf')"></span></p>
|
||||
<label><span x-text="t('username_84c290')"></span><input autocomplete="username" x-model="loginForm.username" required maxlength="64">
|
||||
</label>
|
||||
<label x-show="registering"><span x-text="t('display_name_c7874a')"></span><input x-model="loginForm.display_name" :required="registering">
|
||||
</label>
|
||||
<label><span x-text="t('password_8be3c9')"></span><input type="password" :autocomplete="registering ? 'new-password' : 'current-password'" x-model="loginForm.password" required :minlength="registering ? 12 : 1">
|
||||
</label>
|
||||
<button class="primary" :disabled="busy" x-text="registering ? t('create_account_aaf374') : t('sign_in_ada2e9')">
|
||||
</button>
|
||||
<button type="button" class="text" x-show="allowSignup" @click="registering=!registering" x-text="registering ? t('already_registered_sign_in_0df05c') : t('new_here_create_an_account_2b2c3f')">
|
||||
</button>
|
||||
<div class="demo-note" x-show="demo && !registering"><strong><span x-text="t('try_the_demo_38cfaf')"></span></strong><dl><div><dt><span x-text="t('alice_522b27')"></span></dt><dd><span x-text="t('lead_both_workspaces_variables_rules_ed9a19')"></span></dd></div><div><dt><span x-text="t('bob_48181a')"></span></dt><dd><span x-text="t('engineering_weekly_on_call_swaps_de49e8')"></span></dd></div><div><dt><span x-text="t('carol_28b92b')"></span></dt><dd><span x-text="t('support_daily_shifts_approvals_c9a0d8')"></span></dd></div><div><dt x-text="t('demo_extra_users')"></dt><dd x-text="t('demo_extra_users_hint')"></dd></div></dl><span><span x-text="t('password_for_all_2f1a67')"></span> <code><span x-text="t('meguri_demo_only_0c7b5d')"></span></code></span></div>
|
||||
</form>
|
||||
</main>
|
||||
<div class="shell" :inert="!!modal" x-cloak x-show="ready && user">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/"><span x-text="t('symbol_4d82f1')"></span> <span><span x-text="t('meguri_d160e8')"></span></span>
|
||||
</a>
|
||||
<span class="eyebrow workspace-label"><span x-text="t('workspace_703988')"></span></span>
|
||||
<select class="workspace-select" :aria-label="t('team_218887')" x-model="selected" :disabled="busy" @change="changeWorkspace()">
|
||||
<option value="" x-show="!directoryState.workspaces.length" x-text="t('choose_a_team_1853ee')"></option>
|
||||
<template x-for="t in directoryState.workspaces" :key="t.id">
|
||||
<option :value="t.id" :selected="selected===(t.id)" x-text="t.name">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<nav :aria-label="t('main_navigation_efd197')">
|
||||
<template x-for="item in nav.filter(n=>['calendar','requests','activity'].includes(n.id))" :key="item.id">
|
||||
<a :href="menuHref(item.id)" :aria-current="view===item.id?'page':null" :class="{active:view===item.id}" @click="menuClick($event,item.id)">
|
||||
<span aria-hidden="true" class="nav-icon" x-text="item.icon">
|
||||
</span>
|
||||
<span x-text="item.name">
|
||||
</span>
|
||||
<span class="counter" x-show="item.id==='requests' && pending.length" x-text="pending.length">
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
<button class="setup-toggle" :aria-label="t('setup_cdd7bb')" :class="{active:setupView}" @click="setupOpen=!setupOpen" :aria-expanded="setupOpen"><span class="nav-icon"><span x-text="t('symbol_be22a3')"></span></span><span><span x-text="t('setup_cdd7bb')"></span></span><span class="grow"></span><span x-text="setupOpen?'−':'+'"></span></button>
|
||||
<div class="setup-nav" x-show="setupOpen"><template x-for="item in nav.filter(n=>['configuration','settings'].includes(n.id))" :key="item.id"><a :href="menuHref(item.id)" :aria-current="view===item.id?'page':null" :class="{active:view===item.id}" @click="menuClick($event,item.id)" x-text="item.name"></a></template></div>
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
|
||||
<button class="account-button" @click="open('account')">
|
||||
<span class="avatar teal" x-text="user?.display_name?.slice(0,1)">
|
||||
</span>
|
||||
<span>
|
||||
<strong x-text="user?.display_name">
|
||||
</strong>
|
||||
<small x-text="'@'+user?.username">
|
||||
</small>
|
||||
</span>
|
||||
<span><span x-text="t('symbol_be22a3')"></span></span>
|
||||
</button>
|
||||
<button class="text signout" @click="signOut"><span x-text="t('sign_out_dc1649')"></span></button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<select class="mobile-workspace-select" :aria-label="t('choose_team_9c6970')" x-model="selected" :disabled="busy" @change="changeWorkspace()">
|
||||
<template x-for="t in directoryState.workspaces" :key="t.id">
|
||||
<option :value="t.id" :selected="selected===(t.id)" x-text="t.name">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<span>
|
||||
|
||||
<strong x-text="doc?.name || t('getting_started_76e7a3')">
|
||||
</strong>
|
||||
</span>
|
||||
<div class="top-actions">
|
||||
<span class="display-zone" x-text="t('your_time_31bdbf')+displayZone"></span>
|
||||
<button class="text" @click="open('workspace')"><span x-text="t('new_workspace_b21b86')"></span></button>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
<div class="content">
|
||||
<template x-if="!workspace">
|
||||
<section class="empty welcome">
|
||||
<span class="empty-symbol"><span x-text="t('symbol_4d82f1')"></span></span>
|
||||
<h1><span x-text="t('your_team_s_next_chapter_starts_here_4e986d')"></span></h1>
|
||||
<p><span x-text="t('create_a_workspace_add_a_team_and_give_your_on_call__af9412')"></span></p>
|
||||
<button class="primary" @click="open('workspace')" x-text="t('create_a_workspace_e94c64')">
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
<template x-if="workspace">
|
||||
<div>
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<div class="eyebrow" x-text="setupView?t('team_setup_504738'):t('your_team_303d2a')"></div>
|
||||
<h1 x-text="title">
|
||||
</h1>
|
||||
|
||||
</div>
|
||||
<div class="actions" x-show="view==='calendar'"><button @click="open('absence')" x-show="can('request_overrides')"><span x-text="t('plan_time_off_33c0fc')"></span></button><button class="primary" x-show="can('request_overrides') || can('force_overrides')" @click="nextOwnDuty()"><span x-text="t('ask_for_cover_53c1cd')"></span></button></div>
|
||||
</div>
|
||||
<div class="sync-bar" x-show="setupView || ['failed','conflict'].includes(detail?.sync?.state)" :class="{'sync-problem':['failed','conflict'].includes(detail?.sync?.state)}">
|
||||
<span class="status-dot" :class="detail?.sync?.state==='synced'?'green':'amber-dot'">
|
||||
</span>
|
||||
<span x-text="detail?.sync?.state==='synced' ? t('schedule_is_in_sync_be394a') : t('publication_fe5f07')+label(detail?.sync?.state || 'pending')">
|
||||
</span>
|
||||
<span class="muted" x-text="doc.integration.provider==='mock'?t('local_preview_provider_aca82c'):t('jira_service_management_071f90')">
|
||||
</span>
|
||||
<span class="sync-error" x-text="l(detail?.sync?.last_error || '')">
|
||||
</span>
|
||||
<button class="text" x-show="can('manage_integration')" @click="retry" :disabled="busy"><span x-text="t('sync_now_2b7d93')"></span></button>
|
||||
</div>
|
||||
<section class="coverage-problems panel" x-show="detail?.coverage?.total">
|
||||
<h2 x-text="t('coverage_attention')"></h2>
|
||||
<p class="muted" x-text="t('coverage_publication_wait')"></p>
|
||||
<div class="coverage-issue-list"><template x-for="(gap,index) in coverageItems" :key="index">
|
||||
<div class="coverage-issue" :class="{'is-pending':gap.awaiting}">
|
||||
<div class="coverage-issue-body"><span class="coverage-state" x-text="t(gap.awaiting?'coverage_waiting':'coverage_missing')"></span>
|
||||
<strong x-text="person(gap.user_id)+' · '+scheduleName(gap.schedule_id)"></strong>
|
||||
<small class="coverage-times"><span x-text="formatTime(gap.start)"></span><span x-text="t('symbol_213edd')"></span><span x-text="formatTime(gap.end)"></span></small>
|
||||
<small x-show="gap.awaiting" x-text="t('coverage_substitute',{names:gap.substitutes.map(id=>person(id)).join(', ')})"></small>
|
||||
<small x-show="gap.awaiting" x-text="t('coverage_waiting_for',{names:gap.approvers.map(id=>person(id)).join(', ')})"></small></div>
|
||||
<button x-show="gap.awaiting" @click="view='requests'" x-text="t('coverage_open_requests')"></button>
|
||||
<button x-show="!gap.awaiting" @click="locateCoverage(gap)" x-text="t('open_affected_day')"></button>
|
||||
</div></template></div></section>
|
||||
<section x-show="view==='calendar'">
|
||||
<div class="daily-overview">
|
||||
<div class="today-card"><span class="eyebrow"><span x-text="t('on_call_now_0ec724')"></span> <span x-text="selectedSchedule?.name"></span></span><div class="person-line"><span class="avatar" :class="color(nowAssignment?.user_id)" x-text="nowAssignment?initials(nowAssignment.user_id):'—'"></span><div><strong x-text="nowAssignment?person(nowAssignment.user_id):t('no_coverage_252d1f')"></strong><small x-text="nowAssignment?t('until_24bf03')+formatTime(nowAssignment.end):t('choose_a_schedule_below_dea8d5')"></small></div></div></div>
|
||||
<div class="today-card"><span class="eyebrow"><span x-text="t('your_next_shift_next_4_weeks_0e4beb')"></span></span><strong x-text="nextDuty?scheduleName(nextDuty.schedule_id):t('no_shifts_in_this_view_e878f4')"></strong><small x-text="nextDuty?formatTime(nextDuty.start)+' → '+formatTime(nextDuty.end):t('enjoy_your_time_off_44297f')"></small></div>
|
||||
</div>
|
||||
<section class="attention-panel" x-show="needsMe.length" :aria-label="t('needs_your_reply_9e26e0')"><div class="section-line"><h2><span x-text="t('needs_your_reply_9e26e0')"></span> <span class="badge" x-text="needsMe.length"></span></h2><button class="text" @click="view='requests'"><span x-text="t('all_requests_63246c')"></span></button></div>
|
||||
<template x-for="r in needsMe" :key="r.id"><article class="reply-row"><span class="avatar" :class="color(r.requested_by)" x-text="initials(r.requested_by)"></span><div class="grow"><strong x-text="requestTitle(r)"></strong><template x-for="leg in r.legs"><small x-text="scheduleName(leg.schedule_id)+' · '+formatTime(leg.start)+' → '+formatTime(leg.end)"></small></template><p class="action-comment" x-show="r.reason" x-text="r.reason"></p></div><div class="actions"><button @click="decide(r,'reject')" :disabled="busy"><span x-text="t('decline_b59cf9')"></span></button><button class="primary" @click="decide(r,'approve')" :disabled="busy"><span x-text="t('accept_bb54db')"></span></button></div></article></template></section>
|
||||
<div class="waiting-line" x-show="myRequests.length"><span x-text="t('waiting_count',{count:myRequests.length})"></span><button class="text" @click="view='requests'"><span x-text="t('view_cf3dbd')"></span></button></div>
|
||||
<div class="panel calendar-panel">
|
||||
<div class="calendar-toolbar">
|
||||
<div>
|
||||
<h2 x-text="monthTitle">
|
||||
</h2>
|
||||
<span class="muted"><span x-text="t('select_a_day_to_arrange_cover_510f60')"></span></span>
|
||||
</div>
|
||||
<div class="toolbar-controls">
|
||||
<div class="schedule-control"><button class="schedule-trigger" @click="schedulePicker=!schedulePicker" :aria-expanded="schedulePicker"><span x-text="selectedSchedule?.name || t('choose_schedule_33049c')"></span> <span x-text="t('symbol_00d74e')"></span></button><div class="schedule-picker" x-show="schedulePicker" @click.outside="schedulePicker=false"><input :aria-label="t('search_schedules_235648')" type="search" x-model="scheduleSearch" :placeholder="t('search_schedules_85f641')"><div class="schedule-results"><template x-for="s in doc.schedules.filter(s=>s.name.toLowerCase().includes(scheduleSearch.toLowerCase()))" :key="s.id"><button @click="chooseSchedule(s.id)" :class="{chosen:s.id===scheduleId}" x-text="s.name"></button></template></div></div><select class="sr-only" tabindex="-1" :aria-label="t('schedule_0a8ada')" x-model="scheduleId" @change="run(()=>loadCalendar())">
|
||||
<template x-for="s in doc.schedules" :key="s.id">
|
||||
<option :value="s.id" :selected="scheduleId===(s.id)" x-text="s.name">
|
||||
</option>
|
||||
</template>
|
||||
</select></div>
|
||||
<div class="segmented">
|
||||
<button :aria-label="t('previous_four_weeks_37f96a')" @click="navigateDays(-28)"><span x-text="t('symbol_0dccf9')"></span></button>
|
||||
<button @click="navigateDays(0)"><span x-text="t('today_24345a')"></span></button>
|
||||
<button :aria-label="t('next_four_weeks_08a1b8')" @click="navigateDays(28)"><span x-text="t('symbol_60ecbc')"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weekdays">
|
||||
<template x-for="day in calendar.days.slice(0,7)">
|
||||
<span x-text="weekday(day)">
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="calendar-grid">
|
||||
<template x-for="day in calendar.days" :key="day.date">
|
||||
<button class="day" :data-duty-date="day.date" :class="{today:day.date===today(),weekend:[0,6].includes(new Date(day.date+'T12:00Z').getUTCDay())}" @click="clickDay(day)" :title="dayInterval(day)" :aria-label="dayInterval(day)+' '+onDay(day).map(a=>person(a.user_id)).join(', ')">
|
||||
<span class="day-date" x-text="dayNumber(day)">
|
||||
</span>
|
||||
<small class="day-clock" x-text="localClock(day.start)">
|
||||
</small>
|
||||
<span class="holiday" x-show="holidaysOn(day)" x-text="holidaysOn(day)" :title="holidaysOn(day)">
|
||||
</span>
|
||||
<template x-for="a in onDay(day)" :key="a.schedule_id+a.start">
|
||||
<span class="assignment" :class="color(a.user_id)" :title="assignmentInterval(a,day)">
|
||||
<span class="mini-dot">
|
||||
</span>
|
||||
<span x-text="a.unavailable?t('uncovered_77617b')+shortName(a.user_id)+t('off_e362e3'):shortName(a.user_id)">
|
||||
</span>
|
||||
<span x-show="a.reason==='Approved replacement'"><span x-text="t('symbol_0380cf')"></span></span>
|
||||
<span x-show="a.unavailable" :title="t('no_available_replacement_publication_is_blocked_144e92')"><span x-text="t('symbol_0ab831')"></span></span>
|
||||
</span>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="calendar-footer">
|
||||
<span><span x-text="t('times_shown_in_1f96a9')"></span> <strong x-text="displayZone">
|
||||
</strong>
|
||||
</span>
|
||||
<span><span x-text="t('approved_change_coverage_needs_attention_fc97bf')"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-line">
|
||||
<h2><span x-text="t('your_team_80b301')"></span></h2>
|
||||
<button class="text" @click="view='settings'"><span x-text="t('manage_team_68a698')"></span></button>
|
||||
</div>
|
||||
<div class="people-row">
|
||||
<template x-for="m in members" :key="m.user?.id">
|
||||
<span class="person-chip">
|
||||
<span class="avatar small" :class="color(m.user?.id)" x-text="initials(m.user?.id)">
|
||||
</span>
|
||||
<span x-text="m.user.display_name">
|
||||
</span>
|
||||
<small x-show="m.user?.id===user?.id"><span x-text="t('you_8af56d')"></span></small>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
<section x-show="view==='requests'">
|
||||
<div class="section-line">
|
||||
<p class="muted"><span x-text="t('replacements_take_effect_after_everyone_involved_con_a63d3a')"></span></p>
|
||||
<button class="primary" @click="nextOwnDuty()" x-show="can('request_overrides') || can('force_overrides')"><span x-text="t('new_request_8a3714')"></span></button>
|
||||
</div>
|
||||
<div class="empty panel" x-show="!inbox.length">
|
||||
<h2><span x-text="t('nothing_waiting_on_a_handover_1a3b46')"></span></h2>
|
||||
<p><span x-text="t('your_team_s_replacements_and_swaps_will_appear_here_ee9501')"></span></p>
|
||||
</div>
|
||||
<template x-for="r in inbox" :key="r.id">
|
||||
<article class="panel request-card">
|
||||
<div class="section-line">
|
||||
<strong x-text="requestTitle(r)">
|
||||
</strong>
|
||||
<span class="badge" :class="r.status" x-text="label(r.status)">
|
||||
</span>
|
||||
</div>
|
||||
<p class="action-comment" x-show="r.reason" x-text="r.reason">
|
||||
</p>
|
||||
<template x-for="leg in r.legs">
|
||||
<p class="leg">
|
||||
<strong x-text="scheduleName(leg.schedule_id)">
|
||||
</strong>
|
||||
<span x-text="person(leg.from_user)+' → '+person(leg.to_user)">
|
||||
</span>
|
||||
<small x-text="formatTime(leg.start)+' — '+formatTime(leg.end)">
|
||||
</small>
|
||||
</p>
|
||||
</template>
|
||||
<small class="muted" x-text="t('created_05e8fd')+formatTime(r.created_at)">
|
||||
</small>
|
||||
<p class="muted" x-show="r.status==='pending'" x-text="t('waiting_for_f91441')+r.required_approvers.filter(id=>!r.approvals.includes(id)).map(id=>person(id)).join(', ')">
|
||||
</p>
|
||||
<p class="muted" x-show="r.forced_by" x-text="t('applied_without_confirmation_f694d2')+(r.force_reason || '')">
|
||||
</p>
|
||||
<div class="actions" x-show="r.status==='pending'">
|
||||
<template x-if="r.required_approvers.includes(user?.id) && !r.approvals.includes(user?.id)">
|
||||
<span>
|
||||
<button class="primary" @click="decide(r,'approve')" :disabled="busy"><span x-text="t('accept_bb54db')"></span></button> <button @click="decide(r,'reject')" :disabled="busy"><span x-text="t('decline_b59cf9')"></span></button>
|
||||
</span>
|
||||
</template>
|
||||
<button x-show="r.requested_by===user?.id" @click="decide(r,'cancel')" :disabled="busy"><span x-text="t('cancel_request_84837a')"></span></button>
|
||||
<button x-show="can('force_overrides')" @click="open('force',{id:r.id,reason:''})"><span x-text="t('apply_without_confirmation_f0350d')"></span></button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
<!-- GENERATION_PAGE -->
|
||||
<!-- RESOURCE_INVENTORY -->
|
||||
|
||||
<section x-show="view==='activity'">
|
||||
<p class="muted" x-show="!can('view_audit')"><span x-text="t('your_role_does_not_include_access_to_audit_history_bb8e51')"></span></p>
|
||||
<div class="panel compact" x-show="can('view_audit')">
|
||||
<template x-for="event in detail.audit || []" :key="event.id">
|
||||
<details class="audit-row">
|
||||
<summary>
|
||||
<span class="audit-dot">
|
||||
</span>
|
||||
<strong x-text="label(event.action)">
|
||||
</strong>
|
||||
<span x-text="event.actor_name || event.actor">
|
||||
</span>
|
||||
<time x-text="formatTime(event.created_at)">
|
||||
</time>
|
||||
</summary>
|
||||
<pre x-text="auditDetails(event.resolved_details || event.details)"></pre>
|
||||
<details class="audit-raw"><summary x-text="t('audit_raw_details')"></summary><pre x-text="JSON.stringify(event.details,null,2)"></pre></details>
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
<section x-show="view==='settings'">
|
||||
<div class="two-columns">
|
||||
<article class="panel rule-card">
|
||||
<span class="eyebrow"><span x-text="t('team_preferences_440fbe')"></span></span>
|
||||
<h2 x-text="doc.name">
|
||||
</h2>
|
||||
<p x-text="t('workspace_schedule_timezone_954c44')+doc.timezone">
|
||||
</p>
|
||||
<p x-text="t('planning_horizon_b249cc')+doc.horizon_days+t('days_e72d54')">
|
||||
</p>
|
||||
<button x-show="can('edit_schedules')" @click="open('preferences')"><span x-text="t('edit_preferences_4cefcb')"></span></button>
|
||||
</article>
|
||||
<article class="panel rule-card">
|
||||
<span class="eyebrow"><span x-text="t('connected_provider_f22440')"></span></span>
|
||||
<h2 x-text="doc.integration.provider==='mock'?t('local_preview_8d4a3f'):t('jira_service_management_071f90')">
|
||||
</h2>
|
||||
<p class="muted"><span x-text="t('approved_changes_are_published_automatically_a715bf')"></span></p>
|
||||
<div class="actions" x-show="can('manage_integration')">
|
||||
<button @click="open('integration')"><span x-text="t('configure_792c81')"></span></button>
|
||||
<button @click="preview"><span x-text="t('download_preview_30507d')"></span></button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="section-line">
|
||||
<h2><span x-text="t('team_access_8fb357')"></span></h2>
|
||||
<button class="primary" x-show="can('manage_members')" @click="open('member')"><span x-text="t('add_teammate_d6bc5a')"></span></button>
|
||||
</div>
|
||||
<div class="panel compact">
|
||||
<template x-for="m in members" :key="m.user?.id">
|
||||
<div class="list-row">
|
||||
<span class="avatar" :class="color(m.user?.id)" x-text="initials(m.user?.id)">
|
||||
</span>
|
||||
<div class="grow">
|
||||
<strong x-text="m.user.display_name">
|
||||
</strong>
|
||||
<small x-text="'@'+m.user.username+' · '+m.permissions.length+t('permissions_607d39')">
|
||||
</small>
|
||||
</div>
|
||||
<button x-show="can('manage_members')" @click="open('member',{username:m.user.username,permissions:m.permissions})"><span x-text="t('edit_access_e381a8')"></span></button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="section-line">
|
||||
<h2><span x-text="t('scheduling_directory_e8fdd4')"></span></h2>
|
||||
<span class="muted"><span x-text="t('country_controls_public_holidays_defaults_to_cyprus_e828a2')"></span></span>
|
||||
</div>
|
||||
<div class="panel compact">
|
||||
<template x-for="p in people" :key="p.id">
|
||||
<div class="list-row">
|
||||
<span class="avatar" :class="color(p.id)" x-text="initials(p.id)">
|
||||
</span>
|
||||
<div class="grow">
|
||||
<strong x-text="p.display_name">
|
||||
</strong>
|
||||
<small x-text="(p.email || (p.username?'@'+p.username:t('no_email_configured_b309ce')))+' · '+countryName(p.country)+' · '+(p.user_id?t('workspace_user_95981a'):t('email_contact_e9e0b3'))">
|
||||
</small>
|
||||
</div>
|
||||
<button x-show="can('edit_schedules')" @click="open('person',p)"><span x-text="t('edit_person_d98516')"></span></button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<article class="panel rule-card spaced">
|
||||
<span class="eyebrow"><span x-text="t('workspace_time_a3e57f')"></span></span>
|
||||
<h2><span x-text="t('working_interval_schedule_timezone_fc825f')"></span></h2>
|
||||
<p x-text="workingSummary()">
|
||||
</p>
|
||||
<p class="muted"><span x-text="t('shared_by_every_team_rotations_use_this_timezone_wit_e06e20')"></span></p>
|
||||
<button x-show="detail?.is_owner" @click="open('working')"><span x-text="t('edit_working_interval_290690')"></span></button>
|
||||
</article>
|
||||
<article class="panel rule-card spaced" x-show="detail?.is_owner">
|
||||
<span class="eyebrow"><span x-text="t('workspace_policy_4437ae')"></span></span>
|
||||
<h2><span x-text="t('changes_made_outside_meguri_98ff25')"></span></h2>
|
||||
<p class="muted"><span x-text="t('applies_to_every_team_in_this_workspace_and_only_to__909c8c')"></span></p>
|
||||
<label><span x-text="t('when_external_configuration_changes_18f056')"></span><select :value="doc?.overwrite_drift?'overwrite':'block'" @change="setDrift($event.target.value==='overwrite')">
|
||||
<option value="block" x-text="t('pause_publication_and_report_a_conflict_default_5b9c4a')"></option>
|
||||
<option value="overwrite" x-text="t('overwrite_external_changes_with_meguri_s_state_36db36')"></option>
|
||||
</select>
|
||||
</label>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<footer class="page-footer"><span x-text="t('meguri_8bdb60')"></span> <span><span x-text="t('good_rotations_start_with_a_shared_plan_2e3f86')"></span></span>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
<div class="modal-backdrop" x-cloak x-show="modal" @keydown.escape.window="picker?picker=null:(bindingEditor || rulePeopleTarget)?closeRuleEditor():modal=''" @click.self="modal=''">
|
||||
<section class="modal" :class="{'wide-modal':['variable','generation-rule','absence'].includes(modal),'constructor-modal':modal==='generation-rule'}" role="dialog" aria-modal="true" aria-labelledby="dialog-title" @keydown.tab="trapDialog($event)" x-ref="dialog">
|
||||
<form @submit.prevent="save()">
|
||||
<div class="modal-heading">
|
||||
<div>
|
||||
<span class="eyebrow" x-show="modal!=='workspace'" x-text="['request','day','force'].includes(modal)?scheduleName(form.schedule_id || scheduleId):doc?.name || 'MEGURI'"></span>
|
||||
<h2 id="dialog-title" x-text="modal==='request'?(form.force_mode?t('apply_without_approval_d72926'):form.from_user===user?.id?t('who_can_cover_for_you_6e894c'):t('cover_person',{name:shortName(form.from_user)})):modal==='day'?t('choose_a_shift_330348'):({variable:t('workspace_variable_7abc57'),'generation-rule':t('generation_rule_d34ccf'),rotation:t('rotation_906fe5'),priority:t('ordered_project_coverage_cf12c3'),coverage:t('project_coverage_536388'),request:t('request_a_change_3d465f'),force:t('apply_without_confirmation_f0350d'),absence:t('planned_absence_bfcea7'),holiday:t('public_holiday_49ec03'),'holiday-import':t('import_public_holidays_8098e3'),'remove-schedule':t('remove_schedule_ef0a8a'),person:t('scheduling_person_1273c6'),working:t('workspace_working_interval_c7d250'),member:t('team_access_8fb357'),workspace:t('new_workspace_b21b86'),account:t('your_account_4ab291'),policy:t('escalation_policy_bbb0f7'),route:t('routing_rule_b00b22'),integration:t('connected_provider_9702c0'),preferences:t('team_preferences_7fcab5')})[modal]">
|
||||
</h2>
|
||||
</div>
|
||||
<button type="button" class="icon-button" @click="modal=''" :aria-label="t('close_dialog_7b2902')"><span x-text="t('symbol_67fba2')"></span></button>
|
||||
</div>
|
||||
<template x-if="picker"><div class="person-picker" role="region" :aria-label="t('choose_a_person_202007')">
|
||||
<div class="section-line">
|
||||
<h3 x-text="pickerEditing?t('person_details_0574fe'):t('choose_a_person_202007')">
|
||||
</h3>
|
||||
<button type="button" @click="picker=null"><span x-text="t('back_c32ae9')"></span></button>
|
||||
</div>
|
||||
<div x-show="!pickerEditing">
|
||||
<label><span x-text="t('search_workspace_users_or_enter_email_b3922e')"></span><input x-ref="personSearch" x-model="personQuery" type="search" :placeholder="t('name_or_person_example_com_c4e3fd')">
|
||||
</label>
|
||||
<div class="picker-results">
|
||||
<template x-for="p in pickerResults" :key="p.id">
|
||||
<button type="button" class="picker-option" @click="pickPerson(p)">
|
||||
<span class="avatar small" :class="color(p.id)" x-text="initials(p.id)">
|
||||
</span>
|
||||
<span>
|
||||
<strong x-text="p.display_name">
|
||||
</strong>
|
||||
<small x-text="(p.email || (p.username?'@'+p.username:t('workspace_user_95981a')))+' · '+countryName(p.country)">
|
||||
</small>
|
||||
</span>
|
||||
<span class="badge" x-text="p.user_id?t('user_9f8a23'):t('contact_b37456')">
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<p class="muted" x-show="!pickerResults.length"><span x-text="t('no_matching_person_in_this_workspace_dcec7a')"></span></p>
|
||||
</div>
|
||||
<div class="email-contact" x-show="personQuery.includes('@') && !people.some(p=>p.email?.toLowerCase()===personQuery.trim().toLowerCase())">
|
||||
<h3><span x-text="t('add_by_email_9368f3')"></span></h3>
|
||||
<p class="muted"><span x-text="t('they_do_not_need_a_meguri_account_adding_a_contact_d_15a721')"></span></p>
|
||||
<label><span x-text="t('name_optional_9c9f03')"></span><input x-model="pickerName" :placeholder="t('displayed_in_the_calendar_923a0e')">
|
||||
</label>
|
||||
<label><span x-text="t('country_d523eb')"></span><select :aria-label="t('country_d523eb')" x-model="pickerCountry">
|
||||
<template x-for="country in countries" :key="country.code">
|
||||
<option :value="country.code" :selected="pickerCountry===country.code" x-text="country.name">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="primary" @click="addEmailPerson()" x-text="t('add_e2b431')+personQuery.trim()">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="pickerEditing">
|
||||
<label><span x-text="t('name_709a23')"></span><input x-model="pickerName">
|
||||
</label>
|
||||
<label><span x-text="t('email_for_jsm_lookup_193cfb')"></span><input type="email" x-model="pickerEmail">
|
||||
</label>
|
||||
<label><span x-text="t('scheduling_country_393322')"></span><select :aria-label="t('scheduling_country_393322')" x-model="pickerCountry">
|
||||
<template x-for="country in countries" :key="country.code">
|
||||
<option :value="country.code" :selected="pickerCountry===country.code" x-text="country.name">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="primary" @click="savePersonDetails()"><span x-text="t('use_these_details_775a68')"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</template><div x-show="!picker">
|
||||
<template x-if="modal==='workspace'">
|
||||
<div>
|
||||
|
||||
<label><span x-text="t('name_709a23')"></span><input x-model="form.name" required maxlength="100">
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='account'">
|
||||
<div>
|
||||
<label><span x-text="t('language')"></span><select x-model="form.language" :aria-label="t('language')"><option value="" x-text="t('language_auto')"></option><template x-for="code in I18n.supported"><option :value="code" :selected="form.language===code" x-text="t('language_'+code)"></option></template></select></label>
|
||||
<label><span x-text="t('display_name_c7874a')"></span><input x-model="form.display_name" required>
|
||||
</label>
|
||||
<label><span x-text="t('country_of_work_88453b')"></span><select :aria-label="t('country_of_work_88453b')" x-model="form.country">
|
||||
<template x-for="country in countries" :key="country.code">
|
||||
<option :value="country.code" :selected="form.country===country.code" x-text="country.name">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<label><span x-text="t('display_timezone_2425c3')"></span><input list="timezones" x-model="form.timezone" :placeholder="t('browser_default_e90df2')+browserZone" :aria-label="t('display_timezone_2425c3')">
|
||||
</label>
|
||||
<button type="button" class="text" @click="form.timezone=''"><span x-text="t('use_browser_timezone_3f54ba')"></span></button>
|
||||
<p class="info-note" x-text="t('dates_and_timestamps_are_displayed_in_77ec74')+(form.timezone || browserZone)+t('this_never_changes_the_workspace_timezone_or_shifts_1de864')">
|
||||
</p>
|
||||
<p class="muted"><span x-text="t('country_changes_update_your_holiday_calendar_in_team_1e4eb8')"></span></p>
|
||||
<label><span x-text="t('current_password_19dff4')"></span><input type="password" autocomplete="current-password" x-model="form.current_password">
|
||||
</label>
|
||||
<label><span x-text="t('new_password_d850ee')"></span><input type="password" autocomplete="new-password" minlength="12" x-model="form.password">
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='person'">
|
||||
<div>
|
||||
<label><span x-text="t('name_709a23')"></span><input x-model="form.display_name" required>
|
||||
</label>
|
||||
<label><span x-text="t('email_for_jsm_lookup_193cfb')"></span><input type="email" x-model="form.email" :required="!form.user_id">
|
||||
</label>
|
||||
<label><span x-text="t('scheduling_country_393322')"></span><select :aria-label="t('scheduling_country_393322')" x-model="form.country">
|
||||
<template x-for="country in countries" :key="country.code">
|
||||
<option :value="country.code" :selected="form.country===country.code" x-text="country.name">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<p class="info-note"><span x-text="t('an_email_contact_can_be_scheduled_without_logging_in_34d74f')"></span></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='working'">
|
||||
<div>
|
||||
<label><span x-text="t('workspace_iana_timezone_bea0e0')"></span><input list="timezones" x-model="form.timezone" required>
|
||||
</label>
|
||||
<div class="days-select">
|
||||
<template x-for="(day,index) in [t('mon_24b2a0'),t('tue_529541'),t('wed_23408b'),t('thu_3593cc'),t('fri_bbd6e3'),t('sat_6b782d'),t('sun_48c98c')]">
|
||||
<label class="check">
|
||||
<input type="checkbox" :value="index+1" x-model="form.weekdays">
|
||||
<span x-text="day">
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<div class="two-columns">
|
||||
<label><span x-text="t('working_day_starts_5418a6')"></span><input type="time" x-model="form.start" required>
|
||||
</label>
|
||||
<label><span x-text="t('working_day_ends_52d5f6')"></span><input type="time" x-model="form.end" required>
|
||||
</label>
|
||||
</div>
|
||||
<p class="info-note"><span x-text="t('this_interval_is_shared_across_the_workspace_the_tim_07e765')"></span></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="['rotation','priority'].includes(modal)">
|
||||
<div>
|
||||
<label><span x-text="t('schedule_name_fb63dc')"></span><input x-model="form.name" required>
|
||||
</label>
|
||||
<label><span x-text="t('handover_time_c9e315')"></span><input :aria-label="t('handover_time_c9e315')" type="time" x-model="form.handover" required>
|
||||
</label>
|
||||
<small x-text="t('schedule_time_in_911b55')+doc.timezone+t('each_shift_ends_when_the_next_begins_14ab16')">
|
||||
</small>
|
||||
<template x-if="['rotation','priority'].includes(modal)">
|
||||
<div>
|
||||
<template x-if="modal==='rotation'"><div class="two-columns">
|
||||
<label><span x-text="t('anchor_date_e77bd6')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.anchor"><input type="text" :aria-label="t('anchor_date_e77bd6')" x-ref="textDate" :value="text" @input="edit($event.target)" :placeholder="t('dd_mm_yyyy_d7e5fe')" inputmode="numeric" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked()" tabindex="-1" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label><span x-text="t('days_per_shift_83ec07')"></span><input type="number" min="1" max="366" x-model="form.shift_days" required>
|
||||
</label>
|
||||
</div></template>
|
||||
<label><span x-text="modal==='priority'?t('responsible_people_in_priority_order_7205a5'):t('participants_in_rotation_order_4edee0')"></span></label>
|
||||
<template x-for="(id,index) in form.participants" :key="id">
|
||||
<div class="list-row">
|
||||
<div class="grow">
|
||||
<strong x-text="person(id)">
|
||||
</strong>
|
||||
<small x-text="countryName(people.find(p=>p.id===id)?.country)">
|
||||
</small>
|
||||
</div>
|
||||
<button type="button" @click="editPerson(id)" :aria-label="t('edit_participant_details_73e491')"><span x-text="t('details_dc3dec')"></span></button>
|
||||
<button type="button" @click="moveParticipant(index,-1)" :aria-label="t('move_earlier_07dd6f')"><span x-text="t('symbol_9555ff')"></span></button>
|
||||
<button type="button" @click="moveParticipant(index,1)" :aria-label="t('move_later_bb4c84')"><span x-text="t('symbol_fc4866')"></span></button>
|
||||
<button type="button" @click="form.participants.splice(index,1)" :aria-label="t('remove_participant_131344')"><span x-text="t('symbol_67fba2')"></span></button>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" class="person-select wide" @click="choosePerson('participants')"><span x-text="t('add_participant_name_or_email_5d89ac')"></span></button>
|
||||
<p class="muted" x-show="modal==='rotation'"><span x-text="t('the_first_participant_starts_on_the_anchor_date_at_t_d87af4')"></span></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='priority'"><div><label><span x-text="t('fallback_schedule_7b7a21')"></span><select x-model="form.fallback" required>
|
||||
<option value="" x-text="t('choose_a_schedule_0636f3')"></option>
|
||||
<template x-for="s in doc.schedules.filter(s=>s.id!==form.id)">
|
||||
<option :value="s.id" :selected="form.fallback===(s.id)" x-text="s.name">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</label></div></template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='day'"><div><p x-show="!form.assignments.length"><span x-text="t('no_assignment_for_this_day_95a9f7')"></span></p><template x-for="a in form.assignments"><button type="button" class="shift-option" @click="openDuty(form.day,a)"><strong x-text="person(a.user_id)"></strong><small x-text="assignmentInterval(a,form.day)"></small><span><span x-text="t('choose_755968')"></span></span></button></template></div></template>
|
||||
<template x-if="modal==='request'"><div class="cover-sheet">
|
||||
<div class="shift-ticket"><div class="person-line"><span class="avatar" :class="color(form.from_user)" x-text="initials(form.from_user)"></span><div><strong x-text="person(form.from_user)"></strong><small x-text="scheduleName(form.schedule_id)"></small></div><span class="badge" x-show="Date.parse(form.end)<Date.now()"><span x-text="t('past_shift_cd54e4')"></span></span></div><div class="shift-times" x-show="!form.custom_range && !form.swap"><div><small><span x-text="t('from_3f6605')"></span></small><strong x-text="Date.parse(form.start)<Date.now() && Date.parse(form.end)>Date.now()?t('now_e3b820'):formatTime(form.start)"></strong></div><span><span x-text="t('symbol_213edd')"></span></span><div><small><span x-text="t('until_96bdb6')"></span></small><strong x-text="formatTime(form.end)"></strong></div></div></div>
|
||||
<template x-if="Date.parse(form.end)>Date.now() && (can('request_overrides') || can('force_overrides'))"><div>
|
||||
<p class="action-comment" x-show="matchingRequest?.reason && !form.custom_range && !form.swap" x-text="matchingRequest?.reason"></p><div class="cover-self" x-show="form.from_user!==user?.id && !form.force_mode"><span class="avatar" :class="color(user?.id)" x-text="initials(user?.id)"></span><strong><span x-text="t('you_ll_cover_this_shift_afcf1e')"></span></strong><span class="badge" x-show="matchingRequest && !form.custom_range && !form.swap"><span x-text="t('requested_by_your_colleague_1320db')"></span></span></div>
|
||||
<div x-show="form.from_user===user?.id || form.force_mode"><label><span x-text="t('who_s_covering_926c44')"></span><input type="search" :aria-label="t('search_colleagues_9c47ad')" x-model="coverQuery" :placeholder="t('search_by_name_or_email_eedfe2')" autocomplete="off"></label><div class="cover-options" role="group" :aria-label="t('covering_colleague_10b47f')"><template x-for="p in coverPeople" :key="p.id"><button type="button" :class="{chosen:form.to_user===p.id}" @click="form.to_user=p.id" :aria-pressed="form.to_user===p.id" :aria-label="p.display_name"><span class="avatar small" :class="color(p.id)" x-text="initials(p.id)"></span><span x-text="p.id===user?.id?t('me_94563e'):p.display_name"></span><span class="selection-tick" x-show="form.to_user===p.id"><span x-text="t('symbol_698a87')"></span></span></button></template><p class="muted" x-show="!coverPeople.length"><span x-text="t('no_matching_teammates_dac71f')"></span></p></div></div>
|
||||
<div class="cover-range"><small x-text="t('cover_timezone',{zone:doc.timezone})"></small><div class="cover-boundary"><label><span x-text="t('cover_start_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.start_day"><input type="text" :aria-label="t('cover_start_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_start_time')"></span><input type="time" :aria-label="t('cover_start_time')" x-model="form.start_time" @input="coverRangeChanged()" required></label></div><div class="cover-boundary"><label><span x-text="t('cover_end_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.end_day"><input type="text" :aria-label="t('cover_end_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_end_time')"></span><input type="time" :aria-label="t('cover_end_time')" x-model="form.end_time" @input="coverRangeChanged()" required></label></div></div><details class="range-options"><summary x-text="t('cover_swap_optional')"></summary><label class="check"><input type="checkbox" x-model="form.swap"><span x-text="t('i_ll_take_a_shift_in_return_b47888')"></span></label><template x-if="form.swap"><div><div class="cover-boundary"><label><span x-text="t('cover_return_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.return_start"><input type="text" :aria-label="t('cover_return_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_return_time')"></span><input type="time" :aria-label="t('cover_return_time')" x-model="form.return_start_time" @input="coverRangeChanged()" required></label></div><div class="cover-boundary"><label><span x-text="t('cover_return_end_date')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.return_end"><input type="text" :aria-label="t('cover_return_end_date')" x-ref="textDate" :value="text" @input="edit($event.target);coverRangeChanged()" :placeholder="t('dd_mm_yyyy_d7e5fe')" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked();coverRangeChanged()" tabindex="-1" aria-hidden="true"></span></label><label><span x-text="t('cover_return_end_time')"></span><input type="time" :aria-label="t('cover_return_end_time')" x-model="form.return_end_time" @input="coverRangeChanged()" required></label></div></div></template></details>
|
||||
<label x-show="!matchingRequest || form.force_mode || form.custom_range || form.swap"><span x-text="t('comment_optional_f28415')"></span><input :aria-label="t('comment_optional_f28415')" x-model="form.reason" maxlength="1000" :placeholder="t('e_g_ops_123_vacation_befe39')"></label>
|
||||
<p class="validation-error" role="status" x-show="requestError" x-text="l(requestError)"></p><p class="muted" x-show="requestLoading"><span x-text="t('checking_availability_24f241')"></span></p>
|
||||
<div class="request-ready" x-show="requestResult && !requestLoading"><template x-for="leg in requestResult?.legs || []"><small x-text="person(leg.from_user)+' → '+person(leg.to_user)+' · '+formatTime(leg.start)+' → '+formatTime(leg.end)"></small></template><span x-text="form.force_mode?t('applies_immediately_no_approval_needed_842779'):matchingRequest?t('your_acceptance_completes_this_request_3713f7'):(form.from_user===user?.id?shortName(form.to_user):shortName(form.from_user))+t('will_confirm_in_meguri_a4a9b6')"></span></div>
|
||||
</div></template>
|
||||
</div></template>
|
||||
<template x-if="modal==='force'">
|
||||
<div>
|
||||
<p class="info-note"><span x-text="t('applies_immediately_your_action_and_optional_comment_50c159')"></span></p>
|
||||
<label><span x-text="t('comment_optional_f28415')"></span><textarea x-model="form.reason" maxlength="1000" :placeholder="t('e_g_ops_123_vacation_befe39')">
|
||||
</textarea>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='absence'">
|
||||
<div>
|
||||
<label><span x-text="t('employee_079711')"></span><button type="button" class="person-select" @click="choosePerson('user_id')">
|
||||
<span x-text="form.user_id?person(form.user_id):t('choose_a_person_or_enter_email_bcface')">
|
||||
</span>
|
||||
<span><span x-text="t('symbol_00d74e')"></span></span>
|
||||
</button>
|
||||
</label>
|
||||
<div class="two-columns">
|
||||
<label><span x-text="t('first_absent_duty_day_b0b47f')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.start"><input type="text" :aria-label="t('first_absent_duty_day_b0b47f')" x-ref="textDate" :value="text" @input="edit($event.target)" :placeholder="t('dd_mm_yyyy_d7e5fe')" inputmode="numeric" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked()" tabindex="-1" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label><span x-text="t('last_absent_duty_day_inclusive_6f2bf4')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.end"><input type="text" :aria-label="t('last_absent_duty_day_inclusive_6f2bf4')" x-ref="textDate" :value="text" @input="edit($event.target)" :placeholder="t('dd_mm_yyyy_d7e5fe')" inputmode="numeric" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked()" tabindex="-1" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="info-note"><span x-text="t('dates_are_inclusive_duty_days_in_the_workspace_timez_b377b5')"></span></p>
|
||||
<div class="section-line"><h3><span x-text="t('replacement_ranges_a8d04e')"></span></h3><button type="button" @click="addAbsenceRange()"><span x-text="t('replacement_range_f216ec')"></span></button></div>
|
||||
<p class="muted" x-show="!form.ranges.length"><span x-text="t('no_explicit_substitutes_yet_the_preview_below_shows__07dfef')"></span></p>
|
||||
<template x-for="(range,index) in form.ranges" :key="range.id"><div class="absence-range">
|
||||
<label><span x-text="t('schedule_0a8ada')"></span><select x-model="range.schedule_id"><option value="" x-text="t('absence_all_schedules')"></option><template x-for="schedule in doc.schedules" :key="schedule.id"><option :value="schedule.id" x-text="schedule.name"></option></template></select></label><small class="absence-zone" x-text="t('absence_interval_zone',{zone:doc.timezone})"></small><div class="two-columns">
|
||||
<label><span x-text="t('range_starts_3632e7')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="range.start"><input type="text" :aria-label="t('range_starts_3632e7')" x-ref="textDate" :value="text" @input="edit($event.target)" :placeholder="t('dd_mm_yyyy_d7e5fe')" inputmode="numeric" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked()" tabindex="-1" aria-hidden="true"></span><input type="time" step="1" x-model="range.start_time" :aria-label="t('start_time')" required></label>
|
||||
<label><span x-text="t('absence_end_exclusive')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="range.end"><input type="text" :aria-label="t('absence_end_exclusive')" x-ref="textDate" :value="text" @input="edit($event.target)" :placeholder="t('dd_mm_yyyy_d7e5fe')" inputmode="numeric" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked()" tabindex="-1" aria-hidden="true"></span><input type="time" step="1" x-model="range.end_time" :aria-label="t('end_time')" required></label>
|
||||
</div>
|
||||
<label><span x-text="t('replacement_c385e3')"></span><button type="button" class="person-select" @click="choosePerson('absenceReplacement',index)"><span x-text="range.replacement?person(range.replacement):t('choose_a_person_or_enter_email_bcface')"></span><span><span x-text="t('symbol_00d74e')"></span></span></button></label>
|
||||
<label x-show="!range.schedule_id"><span x-text="t('project_coverage_536388')"></span><select :value="range.replace_projects?'replace':'automatic'" @change="range.replace_projects=$event.target.value==='replace'"><option value="automatic" x-text="t('promote_secondary_automatically_3bee50')"></option><option value="replace" x-text="t('use_this_replacement_in_affected_projects_too_edf224')"></option></select></label>
|
||||
<button type="button" class="text danger" @click="form.ranges.splice(index,1)"><span x-text="t('remove_range_55c658')"></span></button>
|
||||
</div></template>
|
||||
<label class="check" x-show="can('force_overrides')"><input type="checkbox" x-model="form.force"><span x-text="t('apply_replacements_without_confirmation_7c4a2e')"></span></label>
|
||||
<label x-show="form.ranges.length || form.force"><span x-text="t('comment_optional_f28415')"></span><textarea x-model="form.reason" maxlength="1000" :placeholder="t('vacation_coverage_9d7295')"></textarea></label>
|
||||
<div class="absence-preview" aria-live="polite">
|
||||
<h3><span x-text="t('schedule_impact_0ae487')"></span></h3>
|
||||
<p class="muted" x-show="absenceLoading"><span x-text="t('checking_affected_schedules_109ed9')"></span></p>
|
||||
<p class="validation-error" x-show="absenceError" x-text="l(absenceError)"></p>
|
||||
<template x-if="absenceResult"><div>
|
||||
<p class="preview-count" x-text="absenceResult.plan.schedules+t('affected_schedules_c82c00')+absenceResult.plan.groups.length+t('replacement_requests_c81010')+absenceResult.plan.uncovered+t('uncovered_intervals_b5430f')"></p>
|
||||
<template x-for="error in absenceResult.plan.errors"><p class="validation-error" x-text="l(error)"></p></template>
|
||||
<template x-for="warning in absenceResult.plan.warnings"><p class="validation-warning" x-text="l(warning)"></p></template>
|
||||
<p class="muted" x-show="!absenceResult.plan.rows.length"><span x-text="t('no_scheduled_shifts_change_in_this_interval_98915a')"></span></p>
|
||||
<div class="impact-scroll" x-show="absenceResult.plan.rows.length"><table class="impact-table"><thead><tr><th><span x-text="t('schedule_interval_3cf85c')"></span></th><th><span x-text="t('coverage_80e135')"></span></th><th><span x-text="t('action_97c89a')"></span></th></tr></thead><tbody>
|
||||
<template x-for="row in (showAllAbsence?absenceResult.plan.rows:absenceResult.plan.rows.slice(0,8))" :key="row.schedule_id+row.start"><tr><td class="impact-interval"><strong x-text="row.schedule_name"></strong><small><span x-text="formatTime(row.start)"></span><span x-text="t('symbol_213edd')"></span><span x-text="formatTime(row.end)"></span></small></td><td><span x-text="person(row.before)"></span> <span x-text="t('symbol_213edd')"></span> <strong x-text="row.after?person(row.after):t('uncovered_88710a')"></strong></td><td><button type="button" class="text" x-show="row.status==='uncovered'" @click="coverAbsenceRow(row)" x-text="t('choose_a_substitute_be8f67')"></button><span x-show="row.status!=='uncovered'" x-text="row.status==='replacement'?(form.force?t('apply_now_a356cc'):t('needs_confirmation_722b99')):t('automatic_secondary_fallback_7197c5')"></span></td></tr></template>
|
||||
</tbody></table></div>
|
||||
<button class="text" type="button" x-show="absenceResult.plan.rows.length>8" @click="showAllAbsence=!showAllAbsence" x-text="showAllAbsence?t('show_fewer_intervals_6d52e6'):t('show_all_382ebd')+absenceResult.plan.rows.length+t('intervals_d03402')"></button>
|
||||
<p class="info-note" x-show="absenceResult.plan.groups.length && !form.force"><span x-text="t('absence_takes_effect_when_saved_each_replacement_con_a55d09')"></span></p>
|
||||
<p class="validation-warning" x-show="absenceResult.plan.uncovered"><span x-text="t('some_shifts_have_no_available_replacement_they_are_s_c20d79')"></span></p>
|
||||
</div></template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='holiday'">
|
||||
<div>
|
||||
<label><span x-text="t('country_d523eb')"></span><select :aria-label="t('country_d523eb')" x-model="form.country">
|
||||
<template x-for="country in countries" :key="country.code">
|
||||
<option :value="country.code" :selected="form.country===country.code" x-text="country.name">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<label><span x-text="t('date_in_country_calendar_fbc5e1')"></span><span class="date-field" x-data="dateField" x-modelable="value" x-model="form.date"><input type="text" :aria-label="t('date_in_country_calendar_fbc5e1')" x-ref="textDate" :value="text" @input="edit($event.target)" :placeholder="t('dd_mm_yyyy_d7e5fe')" inputmode="numeric" maxlength="10" required><button type="button" :aria-label="t('open_calendar_41547e')" @click="$refs.calendar.showPicker()"><span x-text="t('symbol_21887f')"></span></button><input class="native-date" type="date" x-ref="calendar" x-model="value" @input="picked()" tabindex="-1" aria-hidden="true"></span>
|
||||
</label>
|
||||
<label><span x-text="t('holiday_name_ceba88')"></span><input x-model="form.name" required maxlength="120">
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='holiday-import'">
|
||||
<div>
|
||||
<p class="muted"><span x-text="t('paste_a_csv_with_113f64')"></span> <code><span x-text="t('country_date_name_a98540')"></span></code> <span x-text="t('headers_use_country_codes_from_the_same_directory_as_abe68d')"></span> <strong><span x-text="t('cy_c44de8')"></span></strong> <span x-text="t('cyprus_and_834749')"></span> <strong><span x-text="t('am_80d305')"></span></strong> <span x-text="t('armenia_existing_dates_will_never_be_overwritten_5250bb')"></span></p>
|
||||
<details>
|
||||
<summary><span x-text="t('country_codes_csv_example_b6cb8a')"></span></summary>
|
||||
<div class="two-columns">
|
||||
<label><span x-text="t('find_a_country_bec93b')"></span><select @change="form.csvCountry=$event.target.value">
|
||||
<option value="" x-text="t('choose_a_country_7843c8')"></option>
|
||||
<template x-for="c in countries">
|
||||
<option :value="c.code" x-text="c.name+' · '+c.code">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted" x-text="form.csvCountry || t('cy_cyprus_am_armenia_8c0034')">
|
||||
</p>
|
||||
</div>
|
||||
<pre><span x-text="t('country_date_name_cy_2026_10_01_independence_day_am__be8930')"></span></pre>
|
||||
</details>
|
||||
<label><span x-text="t('holiday_csv_222225')"></span><textarea class="csv-input" :aria-label="t('holiday_csv_222225')" x-model="csvText" @input="csvChanged()" :placeholder="t('country_date_name_cy_2026_12_25_christmas_day_b4543f')" spellcheck="false">
|
||||
</textarea>
|
||||
</label>
|
||||
<div aria-live="polite">
|
||||
<p class="muted" x-show="csvLoading"><span x-text="t('comparing_with_saved_holidays_ad992c')"></span></p>
|
||||
<p class="danger-text" x-show="csvError" x-text="l(csvError)">
|
||||
</p>
|
||||
<template x-if="csvResult">
|
||||
<div>
|
||||
<div class="import-summary">
|
||||
<strong x-text="t('holiday_count',{count:csvResult.preview.additions.length})">
|
||||
</strong>
|
||||
<span x-text="csvResult.preview.existing+t('already_saved_2ba31a')">
|
||||
</span>
|
||||
<span x-text="csvResult.preview.duplicates+t('repeated_rows_60187e')">
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted"><span x-text="t('nothing_is_saved_until_you_choose_import_996b73')"></span></p>
|
||||
<template x-for="message in csvResult.preview.errors">
|
||||
<p class="validation-error" x-text="l(message)">
|
||||
</p>
|
||||
</template>
|
||||
<template x-for="message in csvResult.preview.conflicts">
|
||||
<p class="validation-warning" x-text="l(message)">
|
||||
</p>
|
||||
</template>
|
||||
<div class="import-table" x-show="csvResult.preview.additions.length">
|
||||
<div class="import-table-head">
|
||||
<span><span x-text="t('country_d523eb')"></span></span>
|
||||
<span><span x-text="t('date_eb9a4b')"></span></span>
|
||||
<span><span x-text="t('will_be_added_5399ac')"></span></span>
|
||||
</div>
|
||||
<template x-for="h in csvResult.preview.additions" :key="h.country+h.date">
|
||||
<div>
|
||||
<span x-text="countryName(h.country)">
|
||||
</span>
|
||||
<time x-text="formatDate(h.date)">
|
||||
</time>
|
||||
<span x-text="h.name">
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='member'">
|
||||
<div>
|
||||
<label><span x-text="t('registered_username_e29aa9')"></span><input x-model="form.username" required>
|
||||
</label>
|
||||
<p class="muted"><span x-text="t('your_teammate_needs_an_account_before_you_can_add_th_80fa32')"></span></p>
|
||||
<template x-for="grant in grantOptions">
|
||||
<label class="check">
|
||||
<input type="checkbox" :value="grant.id" x-model="form.permissions" :disabled="grant.id==='view'">
|
||||
<span x-text="grant.name">
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='preferences'">
|
||||
<div>
|
||||
<label><span x-text="t('team_name_9b11ed')"></span><input x-model="form.name" required>
|
||||
</label>
|
||||
<label><span x-text="t('planning_horizon_days_fc8284')"></span><input type="number" min="7" max="366" x-model="form.horizon_days" required>
|
||||
</label>
|
||||
<p class="info-note" x-text="t('schedule_timezone_003979')+doc.timezone+t('change_it_in_workspace_working_interval_settings_eac_1779f8')">
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='remove-schedule' && removal"><div class="removal-preview">
|
||||
<p><span x-text="t('remove_e96390')"></span> <strong x-text="removal.plan.schedule.name"></strong> <span x-text="t('from_the_current_configuration_past_assignments_and__e2a3f7')"></span></p>
|
||||
<p class="validation-error" x-show="removal.plan.active_replacements.length"><span x-text="t('this_schedule_has_current_or_future_approved_replace_6bfa3a')"></span></p>
|
||||
<div class="dependency-list" x-show="removal.plan.escalation_steps.length"><h3><span x-text="t('used_by_escalation_steps_3e2542')"></span></h3><template x-for="step in removal.plan.escalation_steps" :key="step.policy_id+':'+step.step_number"><div class="list-row"><strong class="grow" x-text="step.policy_name"></strong><span class="badge" x-text="t('step_37d11c')+step.step_number+' · '+step.delay_minutes+t('min_f4e8a8')"></span></div></template></div>
|
||||
<div class="dependency-list" x-show="removal.plan.coverage_fallbacks.length"><h3><span x-text="t('used_as_a_coverage_fallback_8955da')"></span></h3><template x-for="s in removal.plan.coverage_fallbacks" :key="s.id"><p x-text="s.name"></p></template></div>
|
||||
<label><span x-text="t('existing_references_748fbb')"></span><select :aria-label="t('existing_references_748fbb')" x-model="form.redirect_to"><option value="" x-text="t('remove_the_affected_escalation_steps_8586b1')"></option><template x-for="s in doc.schedules.filter(s=>s.id!==form.id)" :key="s.id"><option :value="s.id" :selected="form.redirect_to===s.id" x-text="t('use_8023ca')+s.name+t('instead_cd9ec2')"></option></template></select></label>
|
||||
<p class="info-note" x-show="form.redirect_to"><span x-text="t('all_escalation_steps_and_coverage_fallbacks_that_ref_8ca984')"></span></p>
|
||||
<p class="validation-warning" x-show="!form.redirect_to && removal.plan.coverage_fallbacks.length"><span x-text="t('choose_another_schedule_for_the_coverage_fallbacks_b_61dbff')"></span></p>
|
||||
<div class="validation-warning" x-show="!form.redirect_to && removal.plan.policies_emptied.length"><span x-text="t('these_policies_would_have_no_steps_left_choose_a_rep_2ce922')"></span><template x-for="p in removal.plan.policies_emptied" :key="p.id"><p x-text="p.name"></p></template></div>
|
||||
<p class="info-note" x-show="!form.redirect_to && !removal.plan.escalation_steps.length && !removal.plan.coverage_fallbacks.length"><span x-text="t('no_escalation_or_fallback_references_need_changing_b2018c')"></span></p>
|
||||
<p class="validation-warning" x-show="removal.plan.pending_requests.length" x-text="removal.plan.pending_requests.length+t('pending_replacement_requests_will_be_cancelled_in_fu_a1f178')"></p>
|
||||
<p class="muted"><span x-text="t('the_schedule_and_these_dependency_changes_are_saved__42e786')"></span></p>
|
||||
</div></template>
|
||||
<template x-if="modal==='policy'">
|
||||
<div>
|
||||
<label><span x-text="t('policy_name_101bf6')"></span><input x-model="form.name" required>
|
||||
</label>
|
||||
<template x-for="(step,index) in form.steps">
|
||||
<div class="step-editor">
|
||||
<div class="flow-arrow" x-show="index"><span x-text="t('then_if_the_condition_still_holds_0fa74f')"></span></div>
|
||||
<div class="two-columns">
|
||||
<label><span x-text="t('after_minutes_d33505')"></span><input type="number" min="0" max="1440" x-model="step.delay_minutes" required>
|
||||
</label>
|
||||
<label><span x-text="t('condition_2f4979')"></span><select x-model="step.condition">
|
||||
<option value="if-not-acked" x-text="t('if_not_acknowledged_5fc93a')"></option>
|
||||
<option value="if-not-closed" x-text="t('if_not_closed_4369ad')"></option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label><span x-text="t('notify_6c03ee')"></span><select x-model="step.target">
|
||||
<optgroup label="Schedules">
|
||||
<template x-for="s in doc.schedules">
|
||||
<option :value="'schedule:'+s.id" :selected="step.target===('schedule:'+s.id)" x-text="s.name">
|
||||
</option>
|
||||
</template>
|
||||
</optgroup>
|
||||
<optgroup label="Employees">
|
||||
<template x-for="p in people">
|
||||
<option :value="'user:'+p.id" :selected="step.target===('user:'+p.id)" x-text="p.display_name">
|
||||
</option>
|
||||
</template>
|
||||
</optgroup>
|
||||
<option value="team_all" x-text="t('everyone_on_the_team_235b55')"></option>
|
||||
<option value="team_random" x-text="t('a_random_team_member_8f35f3')"></option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="text danger" @click="form.steps.splice(index,1)"><span x-text="t('remove_step_088d48')"></span></button>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" @click="form.steps.push({delay_minutes:30,condition:'if-not-acked',target:'team_all'})"><span x-text="t('escalation_step_72b706')"></span></button>
|
||||
<div class="two-columns spaced">
|
||||
<label><span x-text="t('repeat_count_912e08')"></span><input type="number" min="0" max="20" x-model="form.repeat.count">
|
||||
</label>
|
||||
<label><span x-text="t('wait_between_repeats_0_disables_2e1378')"></span><input type="number" min="0" max="1440" x-model="form.repeat.wait_minutes">
|
||||
</label>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" x-model="form.repeat.reset_recipient_states"><span x-text="t('reset_recipient_states_on_repeat_8152c6')"></span></label>
|
||||
<label class="check">
|
||||
<input type="checkbox" x-model="form.repeat.close_alert_after_all"><span x-text="t('close_alert_after_all_repeats_2bec22')"></span></label>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='route'">
|
||||
<div>
|
||||
<div class="two-columns">
|
||||
<label><span x-text="t('rule_name_9e84d7')"></span><input x-model="form.name" required>
|
||||
</label>
|
||||
<label><span x-text="t('position_cf1c85')"></span><select :aria-label="t('position_cf1c85')" x-model.number="form.position" required>
|
||||
<template x-for="position in (doc.routes.length+(doc.routes.some(r=>r.id===form.id)?0:1))"><option :value="position" :selected="form.position===position" x-text="position===1?t('1_first_6d7898'):position"></option></template>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label><span x-text="t('escalation_policy_bbb0f7')"></span><select x-model="form.escalation_id">
|
||||
<option value="" x-text="t('do_not_notify_1a59a6')"></option>
|
||||
<template x-for="p in doc.escalations">
|
||||
<option :value="p.id" :selected="form.escalation_id===(p.id)" x-text="p.name">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<div class="logic-editor">
|
||||
<div class="logic-header">
|
||||
<span class="logic-key"><span x-text="t('if_7be63e')"></span></span>
|
||||
<strong><span x-text="t('all_conditions_match_e5187c')"></span></strong>
|
||||
<span class="badge" x-show="!form.conditions.length"><span x-text="t('any_alert_4496c3')"></span></span>
|
||||
</div>
|
||||
<template x-for="(c,index) in form.conditions">
|
||||
<div class="condition-editor">
|
||||
<span class="logic-and" x-text="index?t('and_c2f934'):t('when_e3eafa')">
|
||||
</span>
|
||||
<select :aria-label="t('field_c326a4')" x-model="c.field">
|
||||
<option value="tags" x-text="t('tags_848eed')"></option>
|
||||
<option value="priority" x-text="t('priority_886cbf')"></option>
|
||||
<option value="message" x-text="t('message_68f414')"></option>
|
||||
<option value="description" x-text="t('description_55f8eb')"></option>
|
||||
</select>
|
||||
<select :aria-label="t('operation_430d32')" x-model="c.operation">
|
||||
<option value="contains" x-text="t('contains_33e15d')"></option>
|
||||
<option value="equals" x-text="t('equals_09b6a6')"></option>
|
||||
<option value="starts-with" x-text="t('starts_with_2a61f5')"></option>
|
||||
</select>
|
||||
<input :aria-label="t('value_8dce17')" x-model="c.value" required>
|
||||
<label class="check">
|
||||
<input type="checkbox" x-model="c.negate"><span x-text="t('not_ca1dd3')"></span></label>
|
||||
<button type="button" @click="form.conditions.splice(index,1)" :aria-label="t('remove_condition_858fb1')"><span x-text="t('symbol_67fba2')"></span></button>
|
||||
</div>
|
||||
</template>
|
||||
<button type="button" class="text" @click="form.conditions.push({field:'tags',operation:'contains',value:'',negate:false})"><span x-text="t('and_condition_63ce48')"></span></button>
|
||||
</div>
|
||||
<label class="check spaced">
|
||||
<input type="checkbox" x-model="form.working"><span x-text="t('only_during_working_hours_fa1eb6')"></span></label>
|
||||
<p class="info-note" x-show="form.working">
|
||||
<strong x-text="workingSummary()">
|
||||
</strong>
|
||||
<br><span x-text="t('uses_the_shared_workspace_interval_change_it_in_team_01fcc4')"></span></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="modal==='integration'">
|
||||
<div>
|
||||
<label><span x-text="t('provider_7ceee3')"></span><select x-model="form.provider">
|
||||
<option value="mock" x-text="t('local_preview_no_external_writes_0c302e')"></option>
|
||||
<option value="jsm" :disabled="demo" x-text="t('jira_service_management_071f90')"></option>
|
||||
</select>
|
||||
</label>
|
||||
<div x-show="form.provider==='jsm'">
|
||||
<label><span x-text="t('atlassian_cloud_id_829908')"></span><input x-model="form.cloud_id" :required="form.provider==='jsm'">
|
||||
</label>
|
||||
<label><span x-text="t('jsm_team_id_70f05e')"></span><input x-model="form.team_id" :required="form.provider==='jsm'">
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" x-model="form.advanced_escalations"><span x-text="t('premium_enterprise_advanced_escalations_available_483732')"></span></label>
|
||||
<label><span x-text="t('atlassian_accounts_a9aeb6')"></span></label>
|
||||
<p class="muted"><span x-text="t('emails_resolve_automatically_to_existing_active_atla_e073f6')"></span></p>
|
||||
<template x-for="p in people">
|
||||
<label>
|
||||
<span x-text="p.display_name+' · '+(p.email || t('no_email_configured_b309ce'))">
|
||||
</span>
|
||||
<input x-model="form.users[p.id]" :placeholder="t('account_id_optional_when_email_is_available_d135c3')">
|
||||
</label>
|
||||
</template>
|
||||
<p class="info-note"><span x-text="t('ask_your_deployment_administrator_to_configure_crede_782891')"></span></p>
|
||||
<code class="secret-key" x-text="secretPrefix()+'_EMAIL / _TOKEN'">
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- GENERATION_MODALS -->
|
||||
<div class="modal-footer cover-footer" x-show="modal==='request' && Date.parse(form.end)>Date.now() && (can('request_overrides') || can('force_overrides'))"><div class="form-validation" x-data="{validationScope:'form'}"><!-- VALIDATION_STATUS --></div>
|
||||
<button type="button" @click="modal=''"><span x-text="t('cancel_77dfd2')"></span></button>
|
||||
<button type="button" class="force-action" x-show="can('force_overrides') && !form.force_mode" @click="form.force_mode=true"><span x-text="t('apply_without_approval_d72926')"></span></button>
|
||||
<button type="button" x-show="form.force_mode && can('request_overrides')" @click="form.force_mode=false;form.to_user=form.from_user===user?.id?'':user?.id"><span x-text="t('request_approval_instead_b84265')"></span></button>
|
||||
<button type="button" class="primary" @click="sendQuickRequest()" :disabled="busy || !canSendRequest" x-show="can('request_overrides') || form.force_mode" x-text="form.force_mode?t('apply_now_a356cc'):matchingRequest && !form.custom_range && !form.swap?t('accept_cover_d9a527'):form.from_user===user?.id?t('send_request_a89d64'):t('offer_to_cover_054c95')"></button>
|
||||
</div>
|
||||
<div class="modal-footer" x-show="!['request','day'].includes(modal)">
|
||||
<div class="form-validation" x-data="{validationScope:'form'}"><!-- VALIDATION_STATUS --></div>
|
||||
<button type="button" x-show="modal==='account'" @click="modal='';signOut()"><span x-text="t('sign_out_dc1649')"></span></button>
|
||||
<button type="button" @click="modal=''"><span x-text="t('cancel_77dfd2')"></span></button>
|
||||
|
||||
<button type="button" class="primary" x-show="rulePeopleTarget" @click="closeRuleEditor()" x-text="t('done_e9b450')"></button>
|
||||
<button x-show="!rulePeopleTarget" class="primary" :class="{'remove-confirm':modal==='remove-schedule'}" :disabled="busy || (modal==='holiday-import' && !canImport) || (modal==='absence' && !canSaveAbsence) || (modal==='remove-schedule' && !canRemoveSchedule)" x-text="busy?t('saving_56a228'):modal==='remove-schedule'?(form.redirect_to?t('remove_redirect_references_f1a241'):removal?.plan.escalation_steps.length?t('remove_schedule_affected_steps_7348af'):t('remove_schedule_ef0a8a')):modal==='holiday-import'?t('import_new_holidays_b36db6'):modal==='request'?t('send_for_confirmation_f3df0f'):modal==='force'?t('apply_replacement_9b0845'):['variable','generation-rule'].includes(modal)?t('use_in_draft_a8f78c'):t('save_changes_179359')">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<!-- GENERATION_MAP -->
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
||||
<div class="resource-widget">
|
||||
<template x-if="resourceKind==='schedule'"><div class="schedule-widget">
|
||||
<div class="widget-meta"><span class="widget-type"><svg class="resource-icon" viewBox="0 0 24 24" aria-hidden="true"><path :d="resourceIcon(resource.type==='rotation'?'rotation':'schedule')"></path></svg><span x-text="label(resource.type)"></span></span><span x-show="resource.type==='rotation'" x-text="resource.type==='rotation'?t('resource_rotation_cycle',{days:resource.shift_days,date:formatDate(resource.anchor)}):''"></span><span x-text="resource.handover.slice(0,5)+' · '+doc.timezone"></span></div>
|
||||
<div class="widget-chain" :class="{'rotation-chain':resource.type==='rotation'}">
|
||||
<template x-for="(id,index) in resource.participants" :key="index"><div class="widget-node person-node"><span class="widget-order" x-text="index+1"></span><span x-text="person(id)"></span><small x-show="resource.type==='priority' && index===0" x-text="t('primary_a9a96e')"></small></div></template>
|
||||
<template x-if="resource.type==='priority'"><div class="widget-node fallback-node"><svg class="resource-icon" viewBox="0 0 24 24" aria-hidden="true"><path :d="resourceIcon('fallback')"></path></svg><div><small x-text="t('resource_fallback_when')"></small><strong x-text="scheduleName(resource.fallback)"></strong></div></div></template>
|
||||
</div>
|
||||
</div></template>
|
||||
<template x-if="resourceKind==='escalation'"><div class="escalation-widget">
|
||||
<div class="widget-chain">
|
||||
<div class="widget-node trigger-node"><svg class="resource-icon" viewBox="0 0 24 24" aria-hidden="true"><path :d="resourceIcon('escalation')"></path></svg><span x-text="t('resource_detail_trigger')"></span></div>
|
||||
<template x-for="(step,index) in resource.steps" :key="index"><div class="widget-node escalation-node"><span class="widget-order" x-text="index+1"></span><div><small x-text="step.delay_minutes===0?t('immediately_21b60a'):step.delay_minutes+t('min_after_alert_8adeb2')"></small><strong x-text="describeRecipient(step.recipient)"></strong><small x-text="t(step.condition==='if-not-acked'?'if_unacknowledged_407d03':'if_still_open_c34949')"></small></div></div></template>
|
||||
</div>
|
||||
<div class="widget-meta widget-end"><svg class="resource-icon" viewBox="0 0 24 24" aria-hidden="true"><path :d="resourceIcon('rotation')"></path></svg><span x-text="resource.repeat.count && resource.repeat.wait_minutes?t('repeat_654579')+resource.repeat.count+t('times_wait_e1b163')+resource.repeat.wait_minutes+t('min_f4e8a8'):t('end_of_policy_no_repeats_c2ac4f')"></span></div>
|
||||
</div></template>
|
||||
<template x-if="resourceKind==='route'"><div class="routing-widget widget-chain">
|
||||
<div class="widget-node routing-conditions"><svg class="resource-icon" viewBox="0 0 24 24" aria-hidden="true"><path :d="resourceIcon('condition')"></path></svg><div><small x-text="t('resource_all_conditions')"></small><div class="widget-condition-list"><span x-show="!resource.conditions.length" x-text="t('any_alert_4496c3')"></span><template x-for="(condition,index) in resource.conditions" :key="index"><span class="widget-condition" x-text="conditionText(condition)"></span></template><span x-show="resource.window" class="widget-condition" x-text="resource.window?workingSummary():''"></span></div></div></div>
|
||||
<div class="widget-node route-destination"><svg class="resource-icon" viewBox="0 0 24 24" aria-hidden="true"><path :d="resourceIcon('route')"></path></svg><div><small x-text="t('resource_detail_destination')"></small><strong x-text="doc.escalations.find(p=>p.id===resource.escalation_id)?.name || t('do_not_notify_1a59a6')"></strong></div></div>
|
||||
</div></template>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
<section id="schedules" class="setup-section resource-inventory" x-show="view==='configuration'">
|
||||
<div class="section-line"><h2><span x-text="t('setup_resources')"></span><span class="section-count" x-text="doc.schedules.length+doc.escalations.length+doc.routes.length"></span></h2>
|
||||
<div class="actions resource-create" x-show="can('edit_schedules')">
|
||||
<details class="schedule-create"><summary role="button" x-text="t('create_schedule')"></summary><div class="schedule-create-options"><button @click="open('rotation');$el.closest('details').open=false" x-text="t('rotation_b83874')"></button><button @click="open('priority');$el.closest('details').open=false" x-text="t('project_coverage_6d54f7')"></button></div></details>
|
||||
<button @click="open('policy')" x-text="t('create_escalation')"></button><button @click="open('route')" x-text="t('create_routing')"></button>
|
||||
</div></div>
|
||||
<div class="resource-table" x-ref="routingList"><template x-for="isGenerated in [false,true]"><div class="resource-group" :class="{'generated-group':isGenerated}" x-show="[...doc.schedules,...doc.escalations,...doc.routes].some(r=>generated(r.id)===isGenerated)">
|
||||
<h3 class="resource-group-label" x-text="isGenerated?t('generated_resources'):t('manual_resources')"></h3>
|
||||
<template x-for="s in doc.schedules.filter(r=>generated(r.id)===isGenerated)" :key="s.id"><details class="resource-disclosure resource-row " :class="isGenerated?'generated-resource':'manual-resource'" data-resource-kind="schedules" >
|
||||
<summary class="resource-summary"><span class="resource-kind"><svg class="resource-icon" viewBox="0 0 24 24" aria-hidden="true"><path :d="resourceIcon('schedule')"></path></svg><span x-text="t('resource_schedule')"></span></span><div class="resource-title"><strong class="resource-name" x-text="s.name"></strong><small class="resource-brief" x-text="resourceBrief(s,'schedule')" :title="resourceBrief(s,'schedule')"></small></div><span class="generated-mark" x-show="isGenerated" :title="t('generated_resource')" :aria-label="t('generated_resource')" x-text="t('generated_symbol')"></span></summary>
|
||||
<div class="resource-body"><div class="resource-source" x-show="isGenerated"><button class="source-rule-link" :disabled="!can('edit_schedules')" @click="editGenerated(s.id)" x-text="t('generated_by_rule',{name:sourceRuleName(s.id)})"></button></div><div x-data="{resource:s,resourceKind:'schedule'}" x-effect="resource=s"><!-- RESOURCE_DETAILS --></div>
|
||||
<div class="actions resource-actions" x-show="can('edit_schedules') && !isGenerated">
|
||||
<button x-show="!generated(s.id)" @click="open(s.type,s)"><span x-text="t('edit_530164')"></span></button>
|
||||
<button class="text danger" x-show="!generated(s.id)" @click="removeItem('schedules',s.id)"><span x-text="t('remove_e96390')"></span></button>
|
||||
</div>
|
||||
</div></details></template>
|
||||
<template x-for="p in doc.escalations.filter(r=>generated(r.id)===isGenerated)" :key="p.id"><details class="resource-disclosure resource-row " :class="isGenerated?'generated-resource':'manual-resource'" data-resource-kind="escalations" >
|
||||
<summary class="resource-summary"><span class="resource-kind"><svg class="resource-icon" viewBox="0 0 24 24" aria-hidden="true"><path :d="resourceIcon('escalation')"></path></svg><span x-text="t('resource_escalation')"></span></span><div class="resource-title"><strong class="resource-name" x-text="p.name"></strong><small class="resource-brief" x-text="resourceBrief(p,'escalation')" :title="resourceBrief(p,'escalation')"></small></div><span class="generated-mark" x-show="isGenerated" :title="t('generated_resource')" :aria-label="t('generated_resource')" x-text="t('generated_symbol')"></span></summary>
|
||||
<div class="resource-body"><div class="resource-source" x-show="isGenerated"><button class="source-rule-link" :disabled="!can('edit_schedules')" @click="editGenerated(p.id)" x-text="t('generated_by_rule',{name:sourceRuleName(p.id)})"></button></div><div x-data="{resource:p,resourceKind:'escalation'}" x-effect="resource=p"><!-- RESOURCE_DETAILS --></div>
|
||||
<div class="actions resource-actions" x-show="can('edit_schedules') && !isGenerated">
|
||||
<button x-show="!generated(p.id)" @click="open('policy',p)"><span x-text="t('edit_530164')"></span></button>
|
||||
<button class="text danger" x-show="!generated(p.id)" @click="removeItem('escalations',p.id)"><span x-text="t('remove_e96390')"></span></button>
|
||||
</div>
|
||||
</div></details></template>
|
||||
<div class="routing-list" :aria-label="t('routing_rules_in_evaluation_order_582c46')"><template x-for="r in orderedRoutes.filter(r=>generated(r.id)===isGenerated)" :key="r.id"><details class="resource-disclosure resource-row routing-card" :class="{'generated-resource':isGenerated,'manual-resource':!isGenerated,'route-dragging':draggedRoute===r.id,'drop-before':routeDropTarget===r.id && routeDropSide==='before','drop-after':routeDropTarget===r.id && routeDropSide==='after'}" data-resource-kind="routes" :data-route-id="r.id" @dragover.prevent="overRoute($event,r.id)" @drop.prevent.stop="dropRoute($event,r.id)">
|
||||
<summary class="resource-summary"><span class="resource-kind"><svg class="resource-icon" viewBox="0 0 24 24" aria-hidden="true"><path :d="resourceIcon('route')"></path></svg><span x-text="t('resource_routing')"></span></span><div class="resource-title"><strong class="resource-name"><span class="route-position" x-text="orderedRoutes.findIndex(item=>item.id===r.id)+1"></span><span x-text="r.name"></span></strong><small class="resource-brief" x-text="resourceBrief(r,'route')" :title="resourceBrief(r,'route')"></small></div><span class="generated-mark" x-show="isGenerated" :title="t('generated_resource')" :aria-label="t('generated_resource')" x-text="t('generated_symbol')"></span></summary>
|
||||
<div class="resource-body"><div class="resource-source" x-show="isGenerated"><button class="source-rule-link" :disabled="!can('edit_schedules')" @click="editGenerated(r.id)" x-text="t('generated_by_rule',{name:sourceRuleName(r.id)})"></button></div><div x-data="{resource:r,resourceKind:'route'}" x-effect="resource=r"><!-- RESOURCE_DETAILS --></div>
|
||||
<div class="route-heading" x-show="can('edit_schedules') && !generated(r.id)">
|
||||
<button type="button" class="drag-handle" x-show="can('edit_schedules') && !generated(r.id)" :draggable="!busy" :disabled="busy" @dragstart="startRouteDrag($event,r.id)" @dragend="clearRouteDrag()" :aria-label="t('drag_72143a')+r.name+t('to_reorder_64a607')" :title="t('drag_to_reorder_e7541f')"><span x-text="t('symbol_40b24b')"></span></button>
|
||||
<span class="minute route-position" x-text="orderedRoutes.findIndex(item=>item.id===r.id)+1" :aria-label="t('position_713830')+(orderedRoutes.findIndex(item=>item.id===r.id)+1)">
|
||||
</span>
|
||||
|
||||
<div class="route-moves" x-show="can('edit_schedules') && !generated(r.id)">
|
||||
<button type="button" class="move-route" @click="moveRoute(r.id,-1)" :disabled="busy || orderedRoutes.findIndex(item=>item.id===r.id)===0" :aria-label="t('move_e402ac')+r.name+t('up_459409')" :title="t('move_up_b4f57c')"><span x-text="t('symbol_9555ff')"></span></button>
|
||||
<button type="button" class="move-route" @click="moveRoute(r.id,1)" :disabled="busy || orderedRoutes.findIndex(item=>item.id===r.id)===orderedRoutes.length-1" :aria-label="t('move_e402ac')+r.name+t('down_edbf4b')" :title="t('move_down_260ff8')"><span x-text="t('symbol_fc4866')"></span></button>
|
||||
</div>
|
||||
<button x-show="can('edit_schedules') && !generated(r.id)" @click="open('route',r)"><span x-text="t('edit_530164')"></span></button>
|
||||
<button class="text danger" x-show="can('edit_schedules') && !generated(r.id)" @click="removeItem('routes',r.id)"><span x-text="t('remove_e96390')"></span></button>
|
||||
</div>
|
||||
|
||||
</div></details></template></div>
|
||||
</div></template></div>
|
||||
</section>
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="validation-control" x-data="{detailsOpen:false,check:{issues:[],label:'',loading:false,checked:false}}" x-effect="check=validationState(validationScope)">
|
||||
<button type="button" class="validation-toggle" :class="{'has-errors':check.issues.length,'is-checked':check.checked && !check.issues.length,'is-checking':check.loading}" @click="detailsOpen=!detailsOpen" :aria-expanded="detailsOpen" :aria-label="t('validation_details')"><span aria-live="polite" x-text="check.label"></span><span x-text="detailsOpen?t('generation_hide_errors'):t('generation_show_errors')"></span></button>
|
||||
<template x-if="detailsOpen"><div class="validation-details">
|
||||
<p class="muted" x-show="!check.issues.length" x-text="check.loading?t('cover_checking'):check.checked?t('validation_no_errors'):t('validation_on_save')"></p>
|
||||
<template x-for="(issue,index) in check.issues" :key="index"><div class="validation-issue"><small x-show="issue.path" x-text="l(issue.path||'')"></small><strong x-text="l(issue.message)"></strong><small x-show="issue.secondary" x-text="t('validation_secondary')"></small><div class="actions"><template x-for="action in issueActions(issue)" :key="action.kind+action.id"><button type="button" @click="detailsOpen=false;openIssue(action)" x-text="t(action.kind==='rule'?'validation_edit_rule':'validation_edit_variable',{name:action.name})"></button></template><a x-show="issue.team_id && issue.team_id!==workspace?.id" :href="'?view=configuration&workspace='+issue.team_id" target="_blank" rel="noopener" x-text="t('validation_other_team')"></a></div></div></template>
|
||||
</div></template></div>
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# MIT License
|
||||
|
||||
Copyright © 2019-2021 Caleb Porzio and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
Alpine.js 3.14.9, vendored from the npm package's `dist/cdn.min.js`.
|
||||
Source: https://github.com/alpinejs/alpine/tree/v3.14.9
|
||||
License: `ALPINE-LICENSE.md` (MIT).
|
||||
|
||||
The application serves this asset locally and has no runtime CDN dependency. Standard Alpine requires the documented CSP `unsafe-eval` allowance. A future move to Alpine's CSP build needs a browser regression run because expressions differ.
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;e<W.length;e++)W[e](),ot=e;W.length=0,ot=-1,it=!1}var T,N,$,at,st=!0;function Gt(e){st=!1,e(),st=!0}function Jt(e){T=e.reactive,$=e.release,N=t=>e.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o<e.length;o++)if(!e[o].target._x_ignoreMutationObserver&&(e[o].type==="childList"&&(e[o].removedNodes.forEach(s=>{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,tLine truncated
|
||||
|
||||
${r?'Expression: "'+r+`"
|
||||
|
||||
`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(z([n,...e]),i);Ne(r,o)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=z([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>re(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>re(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `<body>` is available. Did you forget to add `defer` in Alpine's `<script>` tag?"),J(document,"alpine:init"),J(document,"alpine:initializing"),ue(),er(t=>S(t,D)),te(t=>P(t)),Ae((t,r)=>{pe(t,r).forEach(n=>n())});let e=t=>!Y(t.parentElement,!0);Array.from(document.querySelectorAll(br().join(","))).filter(e).forEach(t=>{S(t)}),J(document,"alpine:initialized"),setTimeout(()=>{Vn()})}var vt=[],xr=[];function yr(){return vt.map(e=>e())}function br(){return vt.concat(xr).map(e=>e())}function Le(e){vt.push(e)}function $e(e){xr.push(e)}function Y(e,t=!1){return j(e,r=>{if((t?br():yr()).some(i=>r.matches(i)))return!0})}function j(e,t){if(e){if(t(e))return e;if(e._x_teleportBack&&(e=e._x_teleportBack),!!e.parentElement)return j(e.parentElement,t)}}function wrLine truncated
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2016-2025, The Cytoscape Consortium.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the “Software”), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2016-2025, The Cytoscape Consortium.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the “Software”), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
* of the Software, and to permit persons to whom the Software is furnished to do
|
||||
* so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).cytoscape=t()}(this,(function(){"use strict";function e(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=Array(t);n<t;n++)r[n]=e[n];return r}function t(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function n(e,t,n){return t&&function(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(e,s(r.key),r)}}(e.prototype,t),Object.defineProperty(e,"prototype",{writable:!1}),e}function r(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=u(e))||t){n&&(e=n);var r=0,a=function(){};return{s:a,n:function(){return r>=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:a}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,o=!0,s=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return o=e.done,e},e:function(e){s=!0,i=e},f:function(){try{o||null==n.return||n.return()}finally{if(s)throw i}}}}function a(e,t,n){return(t=s(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function i(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,a,i,o,s=[],l=!0,u=!1;try{if(i=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;l=!1}else for(;!(l=(r=i.call(n)).done)&&(s.push(r.value),s.length!==t);l=!0);}catch(e){u=!0,a=e}finally{try{if(!l&&null!=n.return&&(o=n.return(),Object(o)!==o))return}finally{if(u)throw a}}return s}}(e,t)||u(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function o(t){return function(t){if(Array.isArray(t))return e(t)}(t)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(t)||u(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function s(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!=typeof r)return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"==typeof t?t:t+""}function l(e){return l="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},l(e)}function u(t,n){if(t){if("string"==typeof t)return e(t,n);var r={}.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?e(t,n):void 0}}var c="undefined"==typeof window?null:window,d=c?c.navigator:null;c&&c.document;var h,f,p,v,g,y,m,b,x,w,E,k,T,C,P,S,B,D,_,A,M,R,I,N,L,z,O,V,F=l(""),X=l({}),j=l((function(){})),Y="undefined"==typeof HTMLElement?"undefined":l(HTMLElement),q=function(e){return e&&e.instanceString&&U(e.instanceString)?e.instanceString():null},W=function(e){return null!=e&&l(e)==F},U=function(e){return null!=e&&l(e)===j},H=function(e){return!$(e)&&(Array.isArray?Array.isArray(e):null!=e&&e instanceof Array)},K=function(e){return null!=e&&l(e)===X&&!H(e)&&e.constructor===Object},G=function(e){return null!=e&&l(e)===l(1)&&!isNaN(e)},Z=function(e){return"undefined"===Y?void 0:null!=e&&e instanceof HTMLElement},$=function(e){return Q(e)||J(e)},Q=function(e){return"collection"===q(e)&&e._private.single},J=function(e){return"collection"===q(e)&&!e._private.single},ee=function(e){return"core"===q(e)},te=function(e){return"stylesheet"===q(e)},ne=function(e){return null==e||!(""!==e&&!e.match(/^\s+$/))},re=function(e){return function(e){return null!=e&&l(e)===X}(e)&&U(e.then)},ae=function(e,t){t||(t=function(){if(1===arguments.length)return arguments[0];if(0===arguments.length)return"undefined";for(var e=[],t=0;t<arguments.length;t++)e.push(arguments[t]);return e.join("$")});var n=function(){var r,a=arguments,i=t.apply(this,a),o=n.cache;return(r=o[i])||(r=o[i]=e.apply(this,a)),r};return n.cache={},n},ie=ae((function(e){return e.replace(/([A-Z])/g,(function(e){return"-"+e.toLowerCase()}))})),oe=ae((function(e){return e.replace(/(-\w)/g,(function(e){return e[1].toUpperCase()}))})),se=ae((function(e,t){return e+t[0].toUpperCase()+t.substring(1)}),(function(e,t){return e+"$"+t})),le=function(e){return ne(e)?e:e.charAt(0).toUpperCase()+e.substring(1)},ue=function(eLine truncated
|
||||
/*!
|
||||
Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable
|
||||
Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com)
|
||||
Licensed under The MIT License (http://opensource.org/licenses/MIT)
|
||||
*/
|
||||
var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1.0.7",this.state=0,this.fulfillValue=void 0,this.rejectReason=void 0,this.onFulfilled=[],this.onRejected=[],this.proxy={then:this.then.bind(this)},"function"==typeof e&&e.call(this,this.fulfill.bind(this),this.reject.bind(this))};Xr.prototype={fulfill:function(e){return jr(this,1,"fulfillValue",e)},reject:function(e){return jr(this,2,"rejectReason",e)},then:function(e,t){var n=this,r=new Xr;return n.onFulfilled.push(Wr(e,r,"fulfill")),n.onRejected.push(Wr(t,r,"reject")),Yr(n),r.proxy}};var jr=function(e,t,n,r){return 0===e.state&&(e.state=t,e[n]=r,Yr(e)),e},Yr=function(e){1===e.state?qr(e,"onFulfilled",e.fulfillValue):2===e.state&&qr(e,"onRejected",e.rejectReason)},qr=function(e,t,n){if(0!==e[t].length){var r=e[t];e[t]=[];var a=function(){for(var e=0;e<r.length;e++)r[e](n)};"function"==typeof setImmediate?setImmediate(a):setTimeout(a,0)}},Wr=function(e,t,n){return function(r){if("function"!=typeof e)t[n].call(t,r);else{var a;try{a=e(r)}catch(e){return void t.reject(e)}Ur(t,a)}}},Ur=function(e,t){if(e!==t&&e.proxy!==t){var n;if("object"===l(t)&&null!==t||"function"==typeof t)try{n=t.then}catch(t){return void e.reject(t)}if("function"!=typeof n)e.fulfill(t);else{var r=!1;try{n.call(t,(function(n){r||(r=!0,n===t?e.reject(new TypeError("circular thenable chain")):Ur(e,n))}),(function(t){r||(r=!0,e.reject(t))}))}catch(t){r||e.reject(t)}}}else e.reject(new TypeError("cannot resolve promise with itself"))};Xr.all=function(e){return new Xr((function(t,n){for(var r=new Array(e.length),a=0,i=function(n,i){r[n]=i,++a===e.length&&t(r)},o=0;o<e.length;o++)!function(t){var r=e[t];null!=r&&null!=r.then?r.then((function(e){i(t,e)}),(function(e){n(e)})):i(t,r)}(o)}))},Xr.resolve=function(e){return new Xr((function(t,n){t(e)}))},Xr.reject=function(e){return new Xr((function(t,n){n(e)}))};var Hr="undefined"!=typeof Promise?Promise:Xr,Kr=function(e,t,n){var r=ee(e),a=!r,i=this._private=ge({duration:1e3},t,n);if(i.target=e,i.style=i.style||i.css,i.started=!1,i.playing=!1,i.hooked=!1,i.applying=!1,i.progress=0,i.completes=[],i.frames=[],i.complete&&U(i.complete)&&i.completes.push(i.complete),a){var o=e.position();i.startPosition=i.startPosition||{x:o.x,y:o.y},i.startStyle=i.startStyle||e.cy().style().getAnimationStartStyle(e,i.style)}if(r){var s=e.pan();i.startPan={x:s.x,y:s.y},i.startZoom=e.zoom()}this.length=1,this[0]=this},Gr=Kr.prototype;ge(Gr,{instanceString:function(){return"animation"},hook:function(){var e=this._private;if(!e.hooked){var t=e.target._private.animation;(e.queue?t.queue:t.current).push(this),$(e.target)&&e.target.cy().addToAnimationPool(e.target),e.hooked=!0}return this},play:function(){var e=this._private;return 1===e.progress&&(e.progress=0),e.playing=!0,e.started=!1,e.stopped=!1,this.hook(),this},playing:function(){return this._private.playing},apply:function(){var e=this._private;return e.applying=!0,e.started=!1,e.stopped=!1,this.hook(),this},applying:function(){return this._private.applying},pause:function(){var e=this._private;return e.playing=!1,e.started=!1,this},stop:function(){var e=this._private;return e.playing=!1,e.started=!1,e.stopped=!0,this},rewind:function(){return this.progress(0)},fastforward:function(){return this.progress(1)},time:function(e){var t=this._private;return void 0===e?t.progress*t.duration:this.progress(e/t.duration)},progress:function(e){var t=this._private,n=t.playing;return void 0===e?t.progress:(n&&this.pause(),t.progress=e,t.started=!1,n&&this.play(),this)},completed:function(){return 1===this._private.progress},reverse:function(){var e=this._private,t=e.playing;t&&this.pause(),e.progress=1-e.progress,e.started=!1;var n=function(t,n){var r=e[t];null!=r&&(e[t]=e[n],e[n]=r)};if(n("zoom","startZoom"),n("pan","startPan"),n("position","startPosition"),e.style)for(var r=0;r<e.style.length;r++){var a=e.style[r],i=a.name,o=e.startStyle[i];e.startStyle[i]=a,e.style[r]=o}return t&&this.play(),this},promise:function(e){var t,n=this._private;if("frame"===e)t=n.frames;else t=n.completes;return new Hr((function(e,n){t.push((function(){e()}))}))}}),Gr.complete=Gr.completed,Gr.run=Gr.play,Gr.running=Gr.playing;var Zr,$r,Qr,Jr,ea,ta,na,ra,aa,ia,oa,sa,la,ua,ca,da,ha,fa,pa,va,ga,ya,ma,ba,xa,wa,Ea,ka,Ta,Ca,Pa,Sa,Ba,Da,_a,Aa,Ma,Ra,Ia,Na,La,za,Oa,Va,Fa,Xa,ja,Ya,qa,Wa,Ua,Ha,Ka,Ga,Za,$a,Qa,Ja,ei,ti,ni,ri,ai,ii,oi,si,li,ui,ci,di,hi,fi,pi,vi,gi,yi,mi,bi,xi,wi,Ei,ki,Ti,Ci,Pi,Si,Bi={animated:function(){return function(){var e=this,t=void 0!==e.length?e:[e];if(!(this._private.cy||this).styleEnabled())return!1;var n=t[0];return n?n._private.animation.current.length>0:void 0}},clearQueue:function(){return function(){var e=this,t=void 0!==e.length?e:[e];if(!(this._private.cy||this).styleEnabled())return this;for(var n=0;n<t.length;n++){t[n]._private.animation.queue=[]}return this}},delay:function(){return function(e,t){return(this._private.cy||this).styleEnabled()?this.animate({delay:e,duration:e,complete:t}):this}},delaLine truncated
|
||||
/*! Bezier curve function generator. Copyright Gaetan Renaudeau. MIT License: http://en.wikipedia.org/wiki/MIT_License */function Xl(e,t,n,r){var a=.1,i="undefined"!=typeof Float32Array;if(4!==arguments.length)return!1;for(var o=0;o<4;++o)if("number"!=typeof arguments[o]||isNaN(arguments[o])||!isFinite(arguments[o]))return!1;e=Math.min(e,1),n=Math.min(n,1),e=Math.max(e,0),n=Math.max(n,0);var s=i?new Float32Array(11):new Array(11);function l(e,t){return 1-3*t+3*e}function u(e,t){return 3*t-6*e}function c(e){return 3*e}function d(e,t,n){return((l(t,n)*e+u(t,n))*e+c(t))*e}function h(e,t,n){return 3*l(t,n)*e*e+2*u(t,n)*e+c(t)}function f(t){for(var r=0,i=1;10!==i&&s[i]<=t;++i)r+=a;--i;var o=r+(t-s[i])/(s[i+1]-s[i])*a,l=h(o,e,n);return l>=.001?function(t,r){for(var a=0;a<4;++a){var i=h(r,e,n);if(0===i)return r;r-=(d(r,e,n)-t)/i}return r}(t,o):0===l?o:function(t,r,a){var i,o,s=0;do{(i=d(o=r+(a-r)/2,e,n)-t)>0?a=o:r=o}while(Math.abs(i)>1e-7&&++s<10);return o}(t,r,r+a)}var p=!1;function v(){p=!0,e===t&&n===r||function(){for(var t=0;t<11;++t)s[t]=d(t*a,e,n)}()}var g=function(a){return p||v(),e===t&&n===r?a:0===a?0:1===a?1:d(f(a),t,r)};g.getControlPoints=function(){return[{x:e,y:t},{x:n,y:r}]};var y="generateBezier("+[e,t,n,r]+")";return g.toString=function(){return y},g}
|
||||
/*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License */var jl=function(){function e(e){return-e.tension*e.x-e.friction*e.v}function t(t,n,r){var a={x:t.x+r.dx*n,v:t.v+r.dv*n,tension:t.tension,friction:t.friction};return{dx:a.v,dv:e(a)}}function n(n,r){var a={dx:n.v,dv:e(n)},i=t(n,.5*r,a),o=t(n,.5*r,i),s=t(n,r,o),l=1/6*(a.dx+2*(i.dx+o.dx)+s.dx),u=1/6*(a.dv+2*(i.dv+o.dv)+s.dv);return n.x=n.x+l*r,n.v=n.v+u*r,n}return function e(t,r,a){var i,o,s,l={x:-1,v:0,tension:null,friction:null},u=[0],c=0,d=1e-4;for(t=parseFloat(t)||500,r=parseFloat(r)||20,a=a||null,l.tension=t,l.friction=r,o=(i=null!==a)?(c=e(t,r))/a*.016:.016;s=n(s||l,o),u.push(1+s.x),c+=16,Math.abs(s.x)>d&&Math.abs(s.v)>d;);return i?function(e){return u[e*(u.length-1)|0]}:c}}(),Yl=function(e,t,n,r){var a=Xl(e,t,n,r);return function(e,t,n){return e+(t-e)*a(n)}},ql={linear:function(e,t,n){return e+(t-e)*n},ease:Yl(.25,.1,.25,1),"ease-in":Yl(.42,0,1,1),"ease-out":Yl(0,0,.58,1),"ease-in-out":Yl(.42,0,.58,1),"ease-in-sine":Yl(.47,0,.745,.715),"ease-out-sine":Yl(.39,.575,.565,1),"ease-in-out-sine":Yl(.445,.05,.55,.95),"ease-in-quad":Yl(.55,.085,.68,.53),"ease-out-quad":Yl(.25,.46,.45,.94),"ease-in-out-quad":Yl(.455,.03,.515,.955),"ease-in-cubic":Yl(.55,.055,.675,.19),"ease-out-cubic":Yl(.215,.61,.355,1),"ease-in-out-cubic":Yl(.645,.045,.355,1),"ease-in-quart":Yl(.895,.03,.685,.22),"ease-out-quart":Yl(.165,.84,.44,1),"ease-in-out-quart":Yl(.77,0,.175,1),"ease-in-quint":Yl(.755,.05,.855,.06),"ease-out-quint":Yl(.23,1,.32,1),"ease-in-out-quint":Yl(.86,0,.07,1),"ease-in-expo":Yl(.95,.05,.795,.035),"ease-out-expo":Yl(.19,1,.22,1),"ease-in-out-expo":Yl(1,0,0,1),"ease-in-circ":Yl(.6,.04,.98,.335),"ease-out-circ":Yl(.075,.82,.165,1),"ease-in-out-circ":Yl(.785,.135,.15,.86),spring:function(e,t,n){if(0===n)return ql.linear;var r=jl(e,t,n);return function(e,t,n){return e+(t-e)*r(n)}},"cubic-bezier":Yl};function Wl(e,t,n,r,a){if(1===r)return n;if(t===n)return n;var i=a(t,n,r);return null==e||((e.roundValue||e.color)&&(i=Math.round(i)),void 0!==e.min&&(i=Math.max(i,e.min)),void 0!==e.max&&(i=Math.min(i,e.max))),i}function Ul(e,t){return null!=e.pfValue||null!=e.value?null==e.pfValue||null!=t&&"%"===t.type.units?e.value:e.pfValue:e}function Hl(e,t,n,r,a){var i=null!=a?a.type:null;n<0?n=0:n>1&&(n=1);var o=Ul(e,a),s=Ul(t,a);if(G(o)&&G(s))return Wl(i,o,s,n,r);if(H(o)&&H(s)){for(var l=[],u=0;u<s.length;u++){var c=o[u],d=s[u];if(null!=c&&null!=d){var h=Wl(i,c,d,n,r);l.push(h)}else l.push(d)}return l}}function Kl(e,t,n,r){var a=!r,i=e._private,o=t._private,s=o.easing,l=o.startTime,u=(r?e:e.cy()).style();if(!o.easingImpl)if(null==s)o.easingImpl=ql.linear;else{var c,d,h;if(W(s))c=u.parse("transition-timing-function",s).value;else c=s;W(c)?(d=c,h=[]):(d=c[1],h=c.slice(2).map((function(e){return+e}))),h.length>0?("spring"===d&&h.push(o.duration),o.easingImpl=ql[d].apply(null,h)):o.easingImpl=ql[d]}var f,p=o.easingImpl;if(f=0===o.duration?1:(n-l)/o.duration,o.applying&&(f=o.progress),f<0?f=0:f>1&&(f=1),null==o.delay){var v=o.startPosition,g=o.position;if(g&&a&&!e.locked()){var y={};Gl(v.x,g.x)&&(y.x=Hl(v.x,g.x,f,p)),Gl(v.y,g.y)&&(y.y=Hl(v.y,g.y,f,p)),e.position(y)}var m=o.startPan,b=o.pan,x=i.pan,w=null!=b&&r;w&&(Gl(m.x,b.x)&&(x.x=Hl(m.x,b.x,f,p)),Gl(m.y,b.y)&&(x.y=Hl(m.y,b.y,f,p)),e.emit("pan"));var E=o.startZoom,k=o.zoom,T=null!=k&&r;T&&(Gl(E,k)&&(i.zoom=Qt(i.minZoom,Hl(E,k,f,p),i.maxZoom)),e.emit("zoom")),(w||T)&&e.emit("viewport");var C=o.style;if(C&&C.length>0&&a){for(var P=0;P<C.length;P++){var S=C[P],B=S.name,D=S,_=o.startStyle[B],A=Hl(_,D,f,p,u.properties[_.name]);u.overrideBypass(e,B,A)}e.emit("style")}}return o.progress=f,f}function Gl(e,t){return null!=e&&null!=t&&(!(!G(e)||!G(t))||!(!e||!t))}function Zl(e,t,n,r){var a=t._private;a.started=!0,a.startTime=n-a.progress*a.duration}function $l(e,t){var n=t._private.aniEles,r=[];function a(t,n){var a=t._private,i=a.animation.current,o=a.animation.queue,s=!1;if(0===i.length){var l=o.shift();l&&i.push(l)}for(var u=function(e){for(var t=e.length-1;t>=0;t--){(0,e[t])()}e.splice(0,e.length)},c=i.length-1;c>=0;c--){var d=i[c],h=d._private;h.stopped?(i.splice(c,1),h.hooked=!1,h.playing=!1,h.started=!1,u(h.frames)):(h.playing||h.applying)&&(h.playing&&h.applying&&(h.applying=!1),h.started||Zl(0,d,e),Kl(t,d,e,n),h.applying&&(h.applying=!1),u(h.frames),null!=h.step&&h.step(e),d.completed()&&(i.splice(c,1),h.hooked=!1,h.playing=!1,h.started=!1,u(h.completes)),s=!0)}return n||0!==i.length||0!==o.length||r.push(t),s}for(var i=!1,o=0;o<n.length;o++){var s=a(n[o]);i=i||s}var l=a(t,!0);(i||l)&&(n.length>0?t.notify("draw",n):t.notify("draw")),n.unmerge(r),t.emit("step")}var Ql={animate:xo.animate(),animation:xo.animation(),animated:xo.animated(),clearQueue:xo.clearQueue(),delay:xo.delay(),delayAnimation:xo.delayAnimation(),stop:xo.stop(),addToAnimationPool:function(e){this.styleEnabled()&&this._private.aniEles.merge(e)},stopAnimationLoop:function()Line truncated
|
||||
@@ -0,0 +1,78 @@
|
||||
const {openSetup}=require('./support/browser.cjs');
|
||||
const {chromium}=require('playwright');
|
||||
const assert=require('node:assert/strict');
|
||||
const base=process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
|
||||
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({...(process.env.MEGURI_BROWSER_CHANNEL==='chromium'?{}:{channel:process.env.MEGURI_BROWSER_CHANNEL || 'chrome'}),headless:true});
|
||||
const page=await browser.newPage({viewport:{width:1440,height:1100}});page.setDefaultTimeout(10000);
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
const dialog=()=>page.getByRole('dialog');
|
||||
const save=async()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});};
|
||||
const fmt=iso=>iso.split('-').reverse().join('-');
|
||||
try {
|
||||
const unique=Date.now();await page.goto(base);await page.getByRole('button',{name:'New here? Create an account'}).click();
|
||||
await page.getByLabel('Username',{exact:true}).fill('absence-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Absence tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByRole('button',{name:'Create account',exact:true}).click();
|
||||
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save();
|
||||
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Absence '+unique);await save();
|
||||
const dates=await page.evaluate(async()=>{
|
||||
const app=Alpine.$data(document.body);
|
||||
for(const username of ['bob','carol']){await app.api(app.endpoint('/members'),'PUT',{revision:app.workspace.revision,username,permissions:['view','request_overrides']});await app.loadWorkspace();}
|
||||
const bob=app.directory.find(p=>p.username==='bob').id,doc=JSON.parse(JSON.stringify(app.doc)),start=app.addDays(app.today(),2),end=app.addDays(start,13);
|
||||
const rotation=crypto.randomUUID();
|
||||
doc.schedules=[{id:rotation,name:'General',handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:start,shift_days:7},{id:crypto.randomUUID(),name:'Backup',handover:'09:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7}];
|
||||
for(let i=1;i<=9;i++)doc.schedules.push({id:crypto.randomUUID(),name:'Project '+i,handover:'11:00:00',type:'coverage',primary:app.user.id,secondary:bob,fallback:rotation});
|
||||
doc.holidays=[{country:'CY',date:'2027-12-25',name:'Christmas'},{country:'CY',date:'2027-01-01',name:'New Year'},{country:'AM',date:'2027-01-01',name:'New Year'}];
|
||||
await app.api(app.endpoint('/config'),'PUT',{revision:app.workspace.revision,document:doc});await app.loadWorkspace();
|
||||
return {start,end,middle:app.addDays(start,6),next:app.addDays(start,7),revision:app.workspace.revision};
|
||||
});
|
||||
await openSetup(page,'Schedules');
|
||||
|
||||
const cy=page.locator('details.holiday-group').filter({has:page.locator('summary').filter({hasText:'Cyprus'})});
|
||||
assert.equal(await cy.getAttribute('open'),null);assert.equal((await cy.locator('.badge').textContent()).trim(),'2');
|
||||
assert(!(await cy.getByText('Christmas',{exact:true}).isVisible()));await cy.locator('summary').click();await cy.getByText('25-12-2027 · country calendar date',{exact:true}).waitFor();
|
||||
|
||||
await page.getByRole('button',{name:'+ Absence',exact:true}).click();
|
||||
await dialog().getByLabel('First absent duty day',{exact:true}).fill(fmt(dates.start));await dialog().getByLabel('Last absent duty day (inclusive)',{exact:true}).fill(fmt(dates.end));
|
||||
await dialog().getByText(/11 affected schedules · 0 replacement requests · 2 uncovered intervals/).waitFor();
|
||||
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).workspace.revision),dates.revision);
|
||||
const cell=dialog().locator('.impact-interval').first();
|
||||
const nameBox=await cell.locator('strong').boundingBox(), datesBox=await cell.locator('small').boundingBox();
|
||||
assert(datesBox.y>=nameBox.y+nameBox.height+4);
|
||||
await cell.screenshot({path:'test-results/absence-interval.png'});
|
||||
const gap=await page.evaluate(()=>Alpine.$data(document.body).absenceResult.plan.rows.find(r=>r.status==='uncovered'));
|
||||
await dialog().getByRole('button',{name:'Choose a substitute',exact:true}).first().click();
|
||||
await page.getByRole('region',{name:'Choose a person'}).locator('.picker-option').filter({hasText:'Bob Chen'}).click();
|
||||
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.absenceResult&&!a.absenceLoading;});
|
||||
const quick=await page.evaluate(()=>Alpine.$data(document.body).form.ranges[0]);
|
||||
assert.equal(quick.schedule_id,gap.schedule_id);
|
||||
assert.equal(quick.start,gap.start_day);assert.equal(quick.end,await page.evaluate(end=>{const a=Alpine.$data(document.body);return a.dayKey(new Date(end),a.doc.timezone);},gap.end));
|
||||
assert.equal(quick.start_time,'11:00:00');assert.equal(quick.end_time,'11:00:00');
|
||||
await dialog().getByLabel('Start time',{exact:true}).fill('12:00');
|
||||
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.absenceResult&&!a.absenceLoading&&a.workspaceClock(a.absenceResult.plan.groups[0][0].start).startsWith('12:00');});
|
||||
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).absenceResult.plan.groups[0].length),1);
|
||||
await dialog().getByRole('button',{name:'Remove range',exact:true}).click();
|
||||
await dialog().getByText(/11 affected schedules · 0 replacement requests · 2 uncovered intervals/).waitFor();
|
||||
await dialog().getByRole('button',{name:'+ Replacement range',exact:true}).click();
|
||||
const range=i=>dialog().locator('.absence-range').nth(i);
|
||||
await range(0).getByLabel('Range ends (exclusive)',{exact:true}).fill(fmt(dates.next));
|
||||
await range(0).getByLabel('Start time',{exact:true}).fill('09:00');
|
||||
await range(0).locator('.person-select').click();await page.getByRole('region',{name:'Choose a person'}).locator('.picker-option').filter({hasText:'Bob Chen'}).click();
|
||||
await dialog().getByRole('button',{name:'+ Replacement range',exact:true}).click();
|
||||
assert.equal(await range(1).getByLabel('Range starts',{exact:true}).inputValue(),fmt(dates.next));
|
||||
await range(1).locator('.person-select').click();await page.getByRole('region',{name:'Choose a person'}).locator('.picker-option').filter({hasText:'Carol'}).click();
|
||||
await dialog().getByText(/11 affected schedules · 2 replacement requests · 0 uncovered intervals/).waitFor();
|
||||
await dialog().getByRole('button',{name:/Show all .* intervals/}).click();assert((await dialog().locator('.impact-table tbody tr').count())>8);
|
||||
await dialog().getByLabel('Range starts',{exact:true}).first().fill('31-02-2027');
|
||||
await dialog().getByText('Complete the dates and choose a person for each replacement range.',{exact:true}).waitFor();assert(await dialog().getByRole('button',{name:'Save changes',exact:true}).isDisabled());
|
||||
await dialog().getByLabel('Range starts',{exact:true}).first().fill(fmt(dates.start));
|
||||
await dialog().getByLabel('Apply replacements without confirmation').check();await dialog().getByLabel('Comment (optional)',{exact:true}).fill('Agreed vacation coverage');
|
||||
await dialog().getByText(/11 affected schedules · 2 replacement requests · 0 uncovered intervals/).waitFor();
|
||||
await page.screenshot({path:'test-results/absence-plan.png',fullPage:true});
|
||||
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/absence-plan-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await save();
|
||||
const result=await page.evaluate(()=>JSON.parse(JSON.stringify(Alpine.$data(document.body).doc)));
|
||||
assert.equal(result.absences.length,1);assert.equal(result.requests.length,2);assert(result.requests.every(r=>r.status==='applied' && r.legs.length===2));assert.deepEqual(errors,[]);
|
||||
console.log('PASS: collapsed country holidays, DD-MM-YYYY display/input, immediate invalid-date feedback, server-only absence preview, two bulk replacement ranges, automatic project secondary, forced application, mobile summary');
|
||||
} catch(error){console.error(errors);console.error(await page.locator('[role=alert]').textContent());await page.screenshot({path:'test-results/absence-failure.png',fullPage:true});throw error;}
|
||||
finally{await browser.close();}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
@@ -0,0 +1,12 @@
|
||||
const {chromium}=require('playwright'),assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{
|
||||
for(const [user,names] of [['alice',['Acme Engineering','Customer Support','Regional Platform','Commerce Operations']],['bob',['Acme Engineering','Regional Platform']],['carol',['Acme Engineering','Customer Support','Commerce Operations']],['dave',['Acme Engineering']],['erin',['Acme Engineering']]]){
|
||||
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/');await page.getByLabel('Username',{exact:true}).fill(user);await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>window.Alpine&&Alpine.$data(document.body).workspace);
|
||||
const teams=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces);assert.deepEqual(teams.map(t=>t.name).filter(n=>['Acme Engineering','Customer Support','Regional Platform','Commerce Operations'].includes(n)).sort(),names.sort());
|
||||
if(user==='alice'){const schedules=await page.evaluate(()=>Alpine.$data(document.body).doc.schedules);assert.equal(schedules.filter(s=>s.type==='priority').length,6);assert(schedules.filter(s=>s.type==='priority').every(s=>s.participants.length>=2&&s.participants.length<=3));assert.equal(schedules.find(s=>s.type==='rotation').participants.length,5);}
|
||||
if(user==='alice')for(const name of ['Regional Platform','Commerce Operations']){
|
||||
const team=teams.find(t=>t.name===name);await page.goto(`http://127.0.0.1:3000/?view=configuration&workspace=${team.id}`);await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert(await page.locator('.resource-row.generated-resource').count()>=10);await page.getByRole('button',{name:name==='Regional Platform'?'Edit rule Region → enabled services':'Edit rule Product → service tier',exact:true}).click();await page.getByRole('dialog').waitFor();await page.screenshot({path:`test-results/demo-${team.name.startsWith('Regional')?'regional':'tiers'}.png`,fullPage:true});await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
}
|
||||
assert.deepEqual(errors,[]);await page.close();
|
||||
}console.log('PASS four demo workspaces, advanced previews/editors, scoped Alice/Bob/Carol access');
|
||||
}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1 @@
|
||||
const {chromium}=require('playwright'),assert=require('node:assert/strict');(async()=>{const b=await chromium.launch({channel:'chrome',headless:true});try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const dialog=p.getByRole('dialog'),popup=p.locator('.binding-popover');await dialog.getByLabel('Delay in minutes',{exact:true}).fill('7');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').steps[0].delay.value),7);await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();assert.equal(await popup.getByLabel('Fixed number',{exact:true}).inputValue(),'7');await popup.getByLabel('Value source',{exact:true}).selectOption('lookup');await p.waitForTimeout(50);assert.equal(await popup.getByLabel('Value source',{exact:true}).count(),2);const box=await popup.boundingBox();assert(box.y>=0&&box.y+box.height<=1000);await p.screenshot({path:'test-results/inline-binding-context.png'});await popup.getByLabel('Value source',{exact:true}).first().selectOption('literal');await popup.getByLabel('Fixed number',{exact:true}).fill('10');await popup.press('Escape');await popup.waitFor({state:'detached'});assert(await dialog.isVisible());assert.equal(await dialog.getByLabel('Delay in minutes',{exact:true}).inputValue(),'10');await dialog.getByRole('button',{name:'Bind Delay in minutes',exact:true}).click();await popup.waitFor();await dialog.getByLabel('Rule name',{exact:true}).click();await popup.waitFor({state:'detached'});const preview=dialog.locator('.compact-preview');assert.equal(await preview.getAttribute('open'),null);await preview.locator('summary').click();await preview.screenshot({path:'test-results/compact-preview.png'});assert.deepEqual(errors,[]);console.log('PASS inline delay, anchored nested sources, context, Escape/outside dismissal and collapsed preview');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,34 @@
|
||||
// Read-only draft checks: actual values remain visible alongside server type errors.
|
||||
const {chromium}=require('playwright'),assert=require('node:assert/strict');
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({channel:process.env.MEGURI_BROWSER_CHANNEL||'chrome',headless:true});
|
||||
try{
|
||||
const page=await browser.newPage({viewport:{width:1280,height:950}}),errors=[];
|
||||
page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.goto(process.env.MEGURI_URL||'http://127.0.0.1:3000/?view=configuration');
|
||||
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body),b=a.form.blocks.find(b=>b.kind==='escalation');a.editBinding(b.fields.repeat_count ||= a.literal(0),'number','Repeat count');});
|
||||
const preview=page.getByRole('textbox',{name:'Value preview',exact:true});await preview.waitFor();assert.equal(await preview.inputValue(),'0');
|
||||
await page.getByLabel('Value source',{exact:true}).selectOption('variable');
|
||||
const projects=await page.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.find(v=>v.name==='projects').id);
|
||||
await page.getByLabel('Binding variable',{exact:true}).selectOption(projects);
|
||||
await page.waitForFunction(()=>document.querySelector('.binding-value-preview textarea')?.value.includes('Payments'));
|
||||
assert.deepEqual(JSON.parse(await preview.inputValue()),['Payments','Search','Billing','Identity','Data platform','Developer portal']);
|
||||
await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading && a.generationResult?.issues.some(i=>i.message.includes('repeat_count'));});
|
||||
await page.screenshot({path:'test-results/binding-value-preview.png'});
|
||||
await page.getByLabel('Binding variable',{exact:true}).selectOption('');await page.waitForFunction(()=>document.querySelector('.binding-value-preview textarea')?.value==='');
|
||||
await page.getByRole('button',{name:'Done',exact:true}).click();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.editBinding(a.form.blocks[0].fields.participants,'list','Owners');});
|
||||
await page.waitForFunction(()=>document.querySelector('.binding-value-preview textarea')?.value.includes('Dave Wilson'));
|
||||
const people=JSON.parse(await preview.inputValue());assert.equal(people.length,3);assert.equal(people[0].name,'Alice Morgan');assert.equal(people[0].username,'alice');assert.equal(people[1].name,'Bob Chen');
|
||||
const result=await page.evaluate(()=>{const a=Alpine.$data(document.body),id=a.people.find(p=>p.username==='alice').id,unknown=crypto.randomUUID(),value={nested:[id,a.doc.schedules[0].id,unknown],number:7,flag:false};const before=JSON.stringify(value),draft=JSON.stringify(a.generationPayload());return {pretty:a.previewValue(value),unknown,unchanged:before===JSON.stringify(value)&&draft===JSON.stringify(a.generationPayload())};});assert(result.unchanged);assert.equal(result.pretty.nested[0].name,'Alice Morgan');assert.equal(result.pretty.nested[1].type,'schedule');assert.equal(result.pretty.nested[2],result.unknown);assert.equal(result.pretty.number,7);assert.equal(result.pretty.flag,false);
|
||||
assert((await page.locator('.binding-value-preview small').innerText()).includes('Payments'));
|
||||
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/binding-value-preview-mobile.png'});
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);
|
||||
console.log('PASS: live formatted values, server type errors, missing-value clearing, iteration context, mobile');
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
const {openSetup}=require('./support/browser.cjs');
|
||||
// End-to-end coverage for scheduling contacts, country imports, working hours, and display timezones.
|
||||
const {chromium}=require('playwright');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const base=process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
|
||||
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
|
||||
fs.mkdirSync('test-results',{recursive:true});
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({...(process.env.MEGURI_BROWSER_CHANNEL==='chromium'?{}:{channel:process.env.MEGURI_BROWSER_CHANNEL || 'chrome'}),headless:true});
|
||||
const page=await browser.newPage({viewport:{width:1440,height:1100},timezoneId:'America/Los_Angeles'});
|
||||
page.setDefaultTimeout(10000);const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
const dialog=()=>page.getByRole('dialog');
|
||||
const save=async()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});};
|
||||
const state=()=>page.evaluate(()=>{const app=Alpine.$data(document.body);return JSON.parse(JSON.stringify({workspace:app.workspace,user:app.user,endpoint:app.endpoint(),displayZone:app.displayZone}));});
|
||||
try {
|
||||
const label='Calendars '+Date.now();
|
||||
await page.goto(base);
|
||||
await page.getByRole('button',{name:'New here? Create an account'}).click();
|
||||
await page.getByLabel('Username',{exact:true}).fill('calendars-'+Date.now());
|
||||
await page.getByLabel('Display name',{exact:true}).fill('Calendar tester');
|
||||
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
|
||||
await page.getByRole('button',{name:'Create account',exact:true}).click();
|
||||
await page.getByRole('button',{name:'New workspace',exact:true}).waitFor();
|
||||
await page.getByRole('button',{name:'New workspace',exact:true}).click();
|
||||
await dialog().getByLabel('Name',{exact:true}).fill(label);await save();
|
||||
await page.getByRole('button',{name:'New workspace',exact:true}).click();
|
||||
await dialog().getByLabel('Name',{exact:true}).fill(label);await save();
|
||||
await openSetup(page,'Workspace settings');
|
||||
await page.getByRole('button',{name:'+ Add teammate'}).click();await dialog().getByLabel('Registered username').fill('bob');await save();
|
||||
await openSetup(page,'Schedules');
|
||||
await page.locator('.schedule-create > summary').click();
|
||||
await page.getByRole('button',{name:'+ Rotation',exact:true}).click();
|
||||
await dialog().getByLabel('Schedule name').fill('Distributed rotation');
|
||||
await dialog().getByLabel('Handover time').fill('09:00');
|
||||
await dialog().getByLabel('Remove participant').first().click();
|
||||
await dialog().getByRole('button',{name:'+ Add participant · name or email'}).click();
|
||||
const picker=()=>page.getByRole('region',{name:'Choose a person'});
|
||||
await picker().getByLabel('Search workspace users or enter email').fill('Bob');
|
||||
await picker().locator('.picker-option').filter({hasText:'Bob Chen'}).click();
|
||||
await dialog().getByRole('button',{name:'+ Add participant · name or email'}).click();
|
||||
await picker().getByLabel('Search workspace users or enter email').fill('never-logged-in@example.com');
|
||||
assert.equal(await picker().getByLabel('Country',{exact:true}).inputValue(),'CY');
|
||||
await picker().getByLabel('Name (optional)',{exact:true}).fill('Ani in Armenia');
|
||||
await picker().getByLabel('Country',{exact:true}).selectOption('AM');
|
||||
await picker().getByRole('button',{name:'Add never-logged-in@example.com',exact:true}).click();
|
||||
await page.screenshot({path:'test-results/rotation-people.png',fullPage:true});await save();
|
||||
let current=await state();assert.equal(await page.getByLabel('Workspace',{exact:true}).inputValue(),current.workspace.id);const contact=current.workspace.document.people.find(p=>p.email==='never-logged-in@example.com');
|
||||
assert.equal(contact.user_id,null);assert.equal(contact.country,'AM');assert.equal(current.workspace.document.schedules[0].handover,'09:00:00');
|
||||
await page.getByRole('button',{name:'+ Absence',exact:true}).click();
|
||||
await dialog().locator('.person-select').click();
|
||||
await picker().getByLabel('Search workspace users or enter email').fill('absence-only@example.com');
|
||||
await picker().getByRole('button',{name:'Add absence-only@example.com',exact:true}).click();await save();
|
||||
current=await state();assert(current.workspace.document.absences.some(a=>a.user_id===current.workspace.document.people.find(p=>p.email==='absence-only@example.com').id));
|
||||
await page.locator('#public-holidays').evaluate(el=>el.open=true);await page.getByRole('button',{name:'Import CSV',exact:true}).click();
|
||||
const csv='country,date,name\nCY,2027-01-01,New Year\nAM,2027-01-01,"New Year, holiday"';
|
||||
const before=current.workspace.revision;
|
||||
await dialog().getByLabel('Holiday CSV').fill(csv);
|
||||
await dialog().getByText('New holidays: 2',{exact:true}).waitFor();
|
||||
let remote=await page.evaluate(async endpoint=>fetch(endpoint).then(r=>r.json()),current.endpoint);
|
||||
assert.equal(remote.workspace.revision,before,'Preview must not write');assert.equal(remote.workspace.document.holidays.length,0);
|
||||
await dialog().getByLabel('Holiday CSV').fill(csv+'\nXX,2027-01-01,Invalid country');
|
||||
await dialog().locator('.validation-error').waitFor();assert(await dialog().getByRole('button',{name:'Import new holidays'}).isDisabled());
|
||||
await dialog().getByLabel('Holiday CSV').fill(csv);await dialog().getByText('New holidays: 2',{exact:true}).waitFor();
|
||||
await page.screenshot({path:'test-results/holiday-import.png',fullPage:true});
|
||||
await dialog().getByRole('button',{name:'Import new holidays'}).click();await dialog().waitFor({state:'hidden'});
|
||||
await page.locator('.holiday-group summary').filter({hasText:'Cyprus'}).waitFor();await page.locator('.holiday-group summary').filter({hasText:'Armenia'}).waitFor();
|
||||
await page.locator('#public-holidays').evaluate(el=>el.open=true);await page.getByRole('button',{name:'Import CSV',exact:true}).click();
|
||||
await dialog().getByLabel('Holiday CSV').fill(csv);await dialog().getByText('2 already saved',{exact:true}).waitFor();assert(await dialog().getByRole('button',{name:'Import new holidays'}).isDisabled());
|
||||
await dialog().getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await openSetup(page,'Workspace settings');
|
||||
await page.getByRole('button',{name:'Edit working interval'}).click();
|
||||
await dialog().getByLabel('Workspace IANA timezone').fill('Asia/Yerevan');
|
||||
await dialog().getByLabel('Working day starts').fill('10:00');await dialog().getByLabel('Working day ends').fill('18:00');await save();
|
||||
current=await state();assert.equal(current.workspace.document.timezone,'Asia/Yerevan');assert.equal(current.workspace.document.working_interval.start,'10:00:00');
|
||||
await openSetup(page,'Escalations & routing');await page.getByRole('button',{name:'+ Escalation policy',exact:true}).click();
|
||||
await dialog().getByLabel('Policy name').fill('Primary then backup');
|
||||
await dialog().getByRole('button',{name:'+ Escalation step'}).click();await save();
|
||||
await page.getByRole('button',{name:'+ Routing rule',exact:true}).click();
|
||||
await dialog().getByLabel('Rule name').fill('Production work hours');
|
||||
await dialog().getByLabel('Escalation policy').selectOption({label:'Primary then backup'});
|
||||
await dialog().getByRole('button',{name:'+ AND condition'}).click();await dialog().getByLabel('Value',{exact:true}).fill('env:prod');
|
||||
await dialog().getByLabel('Only during working hours').check();
|
||||
await dialog().getByText(/Mon, Tue, Wed, Thu, Fri · 10:00–18:00 · Asia\/Yerevan/).waitFor();
|
||||
assert.equal(await dialog().locator('input[type=time]').count(),0,'Working interval is edited at workspace level');
|
||||
await page.screenshot({path:'test-results/routing-editor.png',fullPage:true});await save();
|
||||
await page.screenshot({path:'test-results/policy-flows.png',fullPage:true});
|
||||
current=await state();const schedule=JSON.stringify(current.workspace.document.schedules),revision=current.workspace.revision;
|
||||
assert.equal(current.displayZone,'America/Los_Angeles','Follow browser timezone by default');
|
||||
await page.locator('.account-button').click();
|
||||
assert.equal(await dialog().getByLabel('Country of work').inputValue(),'CY');
|
||||
assert((await dialog().getByLabel('Country of work').locator('option').count())>=249);
|
||||
await dialog().getByLabel('Display timezone',{exact:true}).fill('Asia/Tokyo');await save();
|
||||
current=await state();assert.equal(current.displayZone,'Asia/Tokyo');assert.equal(current.workspace.revision,revision);assert.equal(JSON.stringify(current.workspace.document.schedules),schedule);
|
||||
await page.getByRole('link', { name: 'Calendar',exact:true}).click();await page.locator('.day-clock').first().filter({hasText:'14:00 GMT+9'}).waitFor();
|
||||
await page.locator('.account-button').click();await dialog().getByRole('button',{name:'Use browser timezone'}).click();await save();
|
||||
current=await state();assert.equal(current.displayZone,'America/Los_Angeles');assert.equal(current.workspace.revision,revision);
|
||||
await openSetup(page,'Schedules');await page.locator('#public-holidays').evaluate(el=>el.open=true);await page.getByRole('button',{name:'Import CSV',exact:true}).click();
|
||||
await dialog().getByLabel('Holiday CSV').fill(csv+'\nCY,2027-12-25,Christmas');await dialog().getByText('New holidays: 1',{exact:true}).waitFor();
|
||||
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/holiday-import-mobile.png',fullPage:true});
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'Mobile import should not overflow');
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: scoped user picker, email rotation/absence, country CSV preview/import/validation/dedup, workspace interval, policy flows, browser/account display timezone, mobile import');
|
||||
} catch(error) {
|
||||
console.error('Browser errors:',errors);console.error('Visible alert:',await page.locator('[role=alert]').textContent());await page.screenshot({path:'test-results/calendars-failure.png',fullPage:true});throw error;
|
||||
} finally {await browser.close();}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
const {openSetup}=require('./support/browser.cjs');
|
||||
// Regression for dependency-aware removal and routing precedence through the real UI/API.
|
||||
const {chromium}=require('playwright');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const base=process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
|
||||
assert(['localhost','127.0.0.1'].includes(new URL(base).hostname));
|
||||
fs.mkdirSync('test-results',{recursive:true});
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({...(process.env.MEGURI_BROWSER_CHANNEL==='chromium'?{}:{channel:process.env.MEGURI_BROWSER_CHANNEL || 'chrome'}),headless:true});
|
||||
const page=await browser.newPage({viewport:{width:1440,height:1100}});
|
||||
page.setDefaultTimeout(10000);const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
const dialog=()=>page.getByRole('dialog');
|
||||
const save=async()=>{await dialog().getByRole('button',{name:'Save changes',exact:true}).click();await dialog().waitFor({state:'hidden'});};
|
||||
const state=()=>page.evaluate(()=>JSON.parse(JSON.stringify(Alpine.$data(document.body).workspace)));
|
||||
const route=name=>page.locator('.routing-card').filter({has:page.getByText(name,{exact:true})});
|
||||
const schedule=name=>page.locator('[data-resource-kind="schedules"]').filter({has:page.getByText(name,{exact:true})});
|
||||
const order=async names=>{
|
||||
await page.waitForFunction(names=>{
|
||||
const app=Alpine.$data(document.body);
|
||||
return !app.busy && JSON.stringify(app.orderedRoutes.map(r=>r.name))===JSON.stringify(names);
|
||||
},names);
|
||||
assert.deepEqual((await page.locator('.routing-card .resource-name > span:last-child').allTextContents()).map(s=>s.trim()),names);
|
||||
assert.deepEqual((await state()).document.routes.map(r=>r.order),names.map((_,i)=>i));
|
||||
};
|
||||
try {
|
||||
const unique=Date.now();
|
||||
await page.goto(base);await page.getByRole('button',{name:'New here? Create an account'}).click();
|
||||
await page.getByLabel('Username',{exact:true}).fill('configuration-'+unique);
|
||||
await page.getByLabel('Display name',{exact:true}).fill('Configuration tester');
|
||||
await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');
|
||||
await page.getByRole('button',{name:'Create account',exact:true}).click();
|
||||
await page.getByRole('button',{name:'New workspace',exact:true}).click();
|
||||
await dialog().getByLabel('Name',{exact:true}).fill('Configuration '+unique);await save();
|
||||
await page.getByRole('button',{name:'New workspace',exact:true}).click();
|
||||
await dialog().getByLabel('Name',{exact:true}).fill('Configuration '+unique);await save();
|
||||
// Seed only this test's new team; all mutations under test use visible controls.
|
||||
await page.evaluate(async()=>{
|
||||
const app=Alpine.$data(document.body),doc=JSON.parse(JSON.stringify(app.doc));
|
||||
doc.schedules=['Payments · primary','Payments · secondary','Alternate'].map(name=>({id:crypto.randomUUID(),name,handover:'11:00:00',type:'rotation',participants:[app.user.id],anchor:'2026-09-21',shift_days:7}));
|
||||
const policy={id:crypto.randomUUID(),name:'Payments escalation',steps:doc.schedules.slice(0,2).map((s,i)=>({delay_minutes:i*10,condition:'if-not-acked',recipient:{type:'schedule',id:s.id}})),repeat:{count:0,wait_minutes:0,reset_recipient_states:false,close_alert_after_all:false}};
|
||||
doc.escalations=[policy];
|
||||
doc.routes=['First','Second','Third'].map((name,i)=>({id:crypto.randomUUID(),name,order:[10,20,99][i],conditions:[{field:'tags',operation:'contains',value:name.toLowerCase(),negate:false}],window:null,escalation_id:policy.id}));
|
||||
await app.api(app.endpoint('/config'),'PUT',{revision:app.workspace.revision,document:doc});await app.loadWorkspace();
|
||||
});
|
||||
await openSetup(page,'Schedules');
|
||||
await schedule('Payments · secondary').locator('summary').click();
|
||||
await schedule('Payments · secondary').getByRole('button',{name:'Remove',exact:true}).click();
|
||||
await dialog().getByText('Payments escalation',{exact:true}).waitFor();
|
||||
await dialog().getByText('Step 2 · 10 min',{exact:true}).waitFor();
|
||||
await page.screenshot({path:'test-results/schedule-removal.png',fullPage:true});
|
||||
await dialog().getByRole('button',{name:'Remove schedule & affected steps',exact:true}).click();
|
||||
await dialog().waitFor({state:'hidden'});
|
||||
let current=await state();assert.equal(current.document.schedules.length,2);assert.equal(current.document.escalations[0].steps.length,1);
|
||||
assert.equal(current.document.escalations[0].steps[0].recipient.id,current.document.schedules[0].id);
|
||||
await schedule('Payments · primary').locator('summary').click();
|
||||
await schedule('Payments · primary').getByRole('button',{name:'Remove',exact:true}).click();
|
||||
await dialog().getByText(/These policies would have no steps left/).waitFor();
|
||||
assert(await dialog().getByRole('button',{name:'Remove schedule & affected steps',exact:true}).isDisabled());
|
||||
await dialog().getByLabel('Existing references',{exact:true}).selectOption({label:'Use Alternate instead'});
|
||||
await dialog().getByRole('button',{name:'Remove & redirect references',exact:true}).click();await dialog().waitFor({state:'hidden'});
|
||||
current=await state();assert.equal(current.document.schedules.length,1);
|
||||
assert.equal(current.document.escalations[0].steps[0].recipient.id,current.document.schedules[0].id);
|
||||
await openSetup(page,'Escalations & routing');
|
||||
while(await page.locator('.routing-card:not([open]) > summary').count())await page.locator('.routing-card:not([open]) > summary').first().click();
|
||||
await page.getByRole('button',{name:'Drag Third to reorder',exact:true}).dragTo(route('First'),{targetPosition:{x:180,y:15}});
|
||||
await order(['Third','First','Second']);
|
||||
await page.reload();await openSetup(page,'Escalations & routing');
|
||||
while(await page.locator('.routing-card:not([open]) > summary').count())await page.locator('.routing-card:not([open]) > summary').first().click();
|
||||
await order(['Third','First','Second']);
|
||||
await page.getByRole('button',{name:'Move Third down',exact:true}).click();await order(['First','Third','Second']);
|
||||
await route('Second').getByRole('button',{name:'Edit',exact:true}).click();
|
||||
assert.equal(await dialog().getByLabel('Position',{exact:true}).inputValue(),'3');
|
||||
await dialog().getByLabel('Position',{exact:true}).selectOption('1');await save();await order(['Second','First','Third']);
|
||||
await page.getByRole('button',{name:'+ Routing rule',exact:true}).click();
|
||||
await dialog().getByLabel('Rule name',{exact:true}).fill('Fourth');
|
||||
await dialog().getByLabel('Position',{exact:true}).selectOption('2');await save();await order(['Second','Fourth','First','Third']);
|
||||
await route('Fourth').locator('summary').click();
|
||||
await route('Fourth').getByRole('button',{name:'Remove',exact:true}).click();await order(['Second','First','Third']);
|
||||
await page.screenshot({path:'test-results/routing-order.png',fullPage:true});
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.getByRole('button',{name:'Move Third up',exact:true}).click();await order(['Second','Third','First']);
|
||||
assert(await page.getByRole('button',{name:'Move Second up',exact:true}).isDisabled());
|
||||
assert(await page.getByRole('button',{name:'Move First down',exact:true}).isDisabled());
|
||||
await page.screenshot({path:'test-results/routing-order-mobile.png',fullPage:true});
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'Routing rules must fit mobile');
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: schedule dependency preview, step removal, last-step protection, redirect, native drag-and-drop, persistent routing order, editor position, insert/remove renumbering, mobile arrow controls');
|
||||
} catch(error) {
|
||||
console.error('Browser errors:',errors);console.error('Visible alert:',await page.locator('[role=alert]').textContent());await page.screenshot({path:'test-results/configuration-failure.png',fullPage:true});throw error;
|
||||
} finally {await browser.close();}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
@@ -0,0 +1,95 @@
|
||||
const {chromium}=require('playwright');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
|
||||
assert(['127.0.0.1','localhost'].includes(new URL(base).hostname));
|
||||
fs.mkdirSync('test-results',{recursive:true});
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({channel:process.env.MEGURI_BROWSER_CHANNEL||'chrome',headless:true});
|
||||
const errors=[];
|
||||
async function login(name){const page=await browser.newPage({viewport:{width:1440,height:1050},timezoneId:'America/New_York'});page.setDefaultTimeout(10000);page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.getByLabel('Username',{exact:true}).fill(name);await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.locator('.day').first().waitFor();return page;}
|
||||
async function refresh(page){await page.reload();await page.locator('.day').first().waitFor();}
|
||||
const dialog=p=>p.getByRole('dialog');
|
||||
const post=(page,path,body)=>page.evaluate(async({path,body})=>{const a=Alpine.$data(document.body);return a.api(path,'POST',body);},{path,body});
|
||||
let alice,bob;
|
||||
try{
|
||||
alice=await login('alice');bob=await login('bob');
|
||||
// Isolate the workflow from the seeded examples and from other browser suites.
|
||||
const ids=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return {alice:a.user.id,bob:a.members.find(m=>m.user.username==='bob').user.id};});
|
||||
const team=await post(alice,'/api/workspaces',{name:'Cover UX '+Date.now()});
|
||||
const endpoint=`/api/workspaces/${team.id}`;
|
||||
const start=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return a.addDays(a.today(),2);});
|
||||
const configured=await alice.evaluate(async({endpoint,ids,start})=>{const a=Alpine.$data(document.body);let r=await a.api(endpoint+'/members','PUT',{revision:1,username:'bob',permissions:['view','request_overrides']});const doc=r.document;doc.schedules=[{id:crypto.randomUUID(),name:'Weekly duty',type:'rotation',participants:[ids.alice,ids.bob],anchor:start,shift_days:7,handover:'11:00:00'}];return a.api(endpoint+'/config','PUT',{revision:r.revision,document:doc});},{endpoint,ids,start});
|
||||
const url=base+'/?workspace='+team.id;
|
||||
await alice.goto(url);await bob.goto(url);await bob.locator('.day').first().waitFor();
|
||||
assert.equal(await bob.getByRole('button',{name:'Schedules',exact:true}).isVisible(),false,'Setup stays out of the daily flow');
|
||||
await bob.locator(`[data-duty-date="${start}"]`).click();
|
||||
assert.equal(await dialog(bob).getByRole('button',{name:'Apply without approval',exact:true}).count(),0);
|
||||
await dialog(bob).getByLabel('Comment (optional)',{exact:true}).fill('OPS-123 · flight');
|
||||
await dialog(bob).getByRole('button',{name:'Offer to cover',exact:true}).click();await dialog(bob).waitFor({state:'hidden'});
|
||||
await refresh(alice);
|
||||
await alice.getByRole('region',{name:'Needs your reply'}).getByText('OPS-123 · flight',{exact:true}).waitFor();
|
||||
await alice.screenshot({path:'test-results/cover-inbox.png',fullPage:true});
|
||||
await alice.getByRole('region',{name:'Needs your reply'}).getByRole('button',{name:'Accept',exact:true}).click();
|
||||
await alice.waitForFunction(start=>{const a=Alpine.$data(document.body);return a.doc.requests.some(r=>r.status==='applied' && r.reason==='OPS-123 · flight');},start);
|
||||
await refresh(bob);assert.match(await bob.locator(`[data-duty-date="${start}"]`).innerText(),/Bob/);
|
||||
|
||||
// A replaced day can be offered back and approved through the same calendar sheet.
|
||||
await alice.locator(`[data-duty-date="${start}"]`).click();
|
||||
await dialog(alice).getByLabel('Comment (optional)',{exact:true}).fill('Correct earlier cover');
|
||||
await dialog(alice).getByRole('button',{name:'Offer to cover',exact:true}).click();
|
||||
await dialog(alice).waitFor({state:'hidden'});
|
||||
await refresh(bob);
|
||||
assert.match(await bob.locator(`[data-duty-date="${start}"]`).innerText(),/Bob/);
|
||||
await bob.getByRole('region',{name:'Needs your reply'}).getByRole('button',{name:'Accept',exact:true}).click();
|
||||
await bob.waitForFunction(()=>Alpine.$data(document.body).doc.requests.some(r=>r.reason==='Correct earlier cover' && r.status==='applied'));
|
||||
await refresh(alice);
|
||||
assert.match(await alice.locator(`[data-duty-date="${start}"]`).innerText(),/Alice/);
|
||||
const prior=await alice.evaluate(()=>Alpine.$data(document.body).doc.requests.find(r=>r.reason==='OPS-123 · flight'));
|
||||
assert.equal(prior.legs.length,1);
|
||||
assert.equal(prior.effective_legs.length,0);
|
||||
|
||||
// Owner asks for cover; the colleague accepts the exact request from the day sheet.
|
||||
const second=await alice.evaluate(start=>Alpine.$data(document.body).addDays(start,1),start);
|
||||
await alice.locator(`[data-duty-date="${second}"]`).click();
|
||||
await dialog(alice).getByLabel('Search colleagues').fill('bob');
|
||||
await dialog(alice).getByRole('button',{name:'Bob Chen',exact:true}).click();
|
||||
await dialog(alice).getByLabel('Comment (optional)',{exact:true}).fill('Vacation');
|
||||
await dialog(alice).getByRole('button',{name:'Send request',exact:true}).click();await dialog(alice).waitFor({state:'hidden'});
|
||||
await refresh(bob);await bob.locator(`[data-duty-date="${second}"]`).click();
|
||||
await dialog(bob).getByText('Vacation',{exact:true}).waitFor();
|
||||
await dialog(bob).getByRole('button',{name:'Accept & cover',exact:true}).click();await dialog(bob).waitFor({state:'hidden'});
|
||||
|
||||
// A lead sees the third action. Empty comments are accepted and force is audited.
|
||||
const third=await alice.evaluate(start=>Alpine.$data(document.body).addDays(start,2),start);
|
||||
await refresh(alice);await alice.locator(`[data-duty-date="${third}"]`).click();
|
||||
await dialog(alice).getByRole('button',{name:'Apply without approval',exact:true}).click();
|
||||
await dialog(alice).getByRole('button',{name:'Bob Chen',exact:true}).click();
|
||||
await dialog(alice).getByRole('button',{name:'Apply now',exact:true}).click();await dialog(alice).waitFor({state:'hidden'});
|
||||
const forced=await alice.evaluate(()=>Alpine.$data(document.body).doc.requests.at(-1));assert.equal(forced.status,'applied');assert.equal(forced.reason,'');assert.equal(forced.forced_by,ids.alice);
|
||||
|
||||
// Exact endpoint UI checks ownership on the server before enabling save.
|
||||
const fourth=await alice.evaluate(start=>Alpine.$data(document.body).addDays(start,3),start);
|
||||
await alice.locator(`[data-duty-date="${fourth}"]`).click();await dialog(alice).getByRole('button',{name:'Bob Chen',exact:true}).click();
|
||||
const invalid=await alice.evaluate(start=>Alpine.$data(document.body).addDays(start,8),start);
|
||||
await dialog(alice).getByLabel('End date',{exact:true}).fill(invalid.split('-').reverse().join('-'));
|
||||
await dialog(alice).locator('.validation-error').filter({hasText:'no longer owns'}).waitFor();assert(await dialog(alice).getByRole('button',{name:'Send request',exact:true}).isDisabled());
|
||||
await dialog(alice).getByLabel('End date',{exact:true}).fill((await alice.evaluate(d=>Alpine.$data(document.body).addDays(d,1),fourth)).split('-').reverse().join('-'));
|
||||
await alice.waitForFunction(()=>Alpine.$data(document.body).canSendRequest);
|
||||
await alice.setViewportSize({width:390,height:844});await alice.screenshot({path:'test-results/cover-mobile.png',fullPage:true});assert(await alice.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
||||
await dialog(alice).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await alice.screenshot({path:'test-results/cover-calendar-mobile.png',fullPage:true});
|
||||
await alice.setViewportSize({width:1440,height:1050});
|
||||
await alice.getByRole('button',{name:'Ask for cover',exact:true}).click();
|
||||
assert.equal(await dialog(alice).getByLabel('Start time',{exact:true}).inputValue(),await alice.evaluate(()=>Alpine.$data(document.body).doc.working_interval.start.slice(0,5)));
|
||||
assert.equal(await alice.evaluate(()=>{const a=Alpine.$data(document.body);return a.form.end_day===a.addDays(a.form.start_day,1);}),true);
|
||||
await dialog(alice).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
const sixth=await alice.evaluate(start=>Alpine.$data(document.body).addDays(start,5),start);
|
||||
await alice.locator('[data-duty-date="'+sixth+'"]').click();await dialog(alice).getByRole('button',{name:'Bob Chen',exact:true}).click();
|
||||
await dialog(alice).getByLabel('Start time',{exact:true}).fill('13:00');await dialog(alice).getByLabel('End date',{exact:true}).fill(sixth.split('-').reverse().join('-'));await dialog(alice).getByLabel('End time',{exact:true}).fill('15:00');
|
||||
await alice.waitForFunction(()=>Alpine.$data(document.body).canSendRequest);await alice.screenshot({path:'test-results/cover-exact-time.png'});
|
||||
await dialog(alice).getByRole('button',{name:'Apply without approval',exact:true}).click();await alice.waitForFunction(()=>Alpine.$data(document.body).canSendRequest);await dialog(alice).getByRole('button',{name:'Apply now',exact:true}).click();await dialog(alice).waitFor({state:'hidden'});
|
||||
const timed=await alice.evaluate(()=>Alpine.$data(document.body).doc.requests.at(-1));assert.equal(Date.parse(timed.legs[0].end)-Date.parse(timed.legs[0].start),7200000);assert.equal(timed.status,'applied');
|
||||
assert.deepEqual(errors,[]);console.log('PASS: volunteer → owner acceptance, own-day request → one-click acceptance, optional action comments, privileged third action, live range errors, display timezone, mobile');
|
||||
}catch(e){if(alice)await alice.screenshot({path:'test-results/cover-failure.png',fullPage:true});console.error(errors);throw e;}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,10 @@
|
||||
const {chromium}=require('playwright');
|
||||
const assert=require('node:assert/strict');
|
||||
(async()=>{const b=await chromium.launch({channel:'chrome',headless:true});try{
|
||||
const p=await b.newPage({viewport:{width:1280,height:950}});await p.goto('http://127.0.0.1:3000');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).workspace);
|
||||
await p.evaluate(()=>{const a=Alpine.$data(document.body),alice=a.user.id,bob=a.people.find(x=>x.display_name==='Bob Chen').id,schedule=a.doc.schedules[0].id; a.doc.requests=[{id:'test',status:'pending',required_approvers:[alice,bob],approvals:[alice],legs:[{schedule_id:schedule,from_user:alice,to_user:bob,start:'2026-10-08T11:00:00+03:00',end:'2026-10-10T08:00:00.000Z'}]}];a.detail.coverage={total:1,issues:[{schedule_id:schedule,user_id:alice,start:'2026-10-08T08:00:00Z',end:'2026-10-11T08:00:00Z'}]};});
|
||||
const box=p.locator('.coverage-problems');await box.waitFor({state:'visible'});assert.equal(await box.locator('.coverage-issue').count(),2);await box.getByText('Waiting for: Bob Chen',{exact:true}).waitFor();assert.equal(await box.getByText('No substitute assigned',{exact:true}).count(),1);assert.equal(await box.getByText('Awaiting approval',{exact:true}).count(),1);
|
||||
const parts=await p.evaluate(()=>Alpine.$data(document.body).coverageItems);assert.equal(parts[0].end,parts[1].start);assert.equal(parts[1].start,'2026-10-10T08:00:00.000Z');assert(!parts[1].awaiting);
|
||||
const strong=await box.locator('strong').first().boundingBox(),time=await box.locator('.coverage-times').first().boundingBox();assert(time.y>=strong.y+strong.height+6);await box.screenshot({path:'test-results/coverage-status.png'});
|
||||
await p.setViewportSize({width:390,height:844});await box.screenshot({path:'test-results/coverage-status-mobile.png'});assert((await box.boundingBox()).width<=390);await box.getByRole('button',{name:'View requests'}).click();assert.equal(await p.evaluate(()=>Alpine.$data(document.body).view),'requests');console.log('PASS pending versus missing coverage, partial intervals, outstanding approvers, request navigation and responsive spacing');
|
||||
}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,2 @@
|
||||
const {chromium}=require('playwright'),assert=require('node:assert/strict');
|
||||
(async()=>{const b=await chromium.launch({channel:'chrome',headless:true});try{const p=await b.newPage({viewport:{width:1280,height:950}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit variable on_call',exact:true}).click();const dialog=p.getByRole('dialog'),status=dialog.locator('.modal-footer:visible .validation-toggle');await status.waitFor();assert.equal(await status.getAttribute('aria-expanded'),'false');const original=await p.evaluate(()=>{const a=Alpine.$data(document.body),n=a.form.node.entries[0],v={...n};n.kind='date';n.value='2026-09-24';return v;});await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult?.issues.some(i=>i.rule_id);});assert.match(await status.innerText(),/Issues to resolve/);assert.equal(await dialog.locator('.generation-errors').count(),0);await status.click();await dialog.getByRole('button',{name:'Edit rule: Weekly Time off',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='generation-rule');assert.equal(await p.evaluate(()=>Alpine.$data(document.body).generationDraft.variables.find(v=>v.name==='on_call').node.entries[0].value),'2026-09-24');await p.waitForFunction(()=>!Alpine.$data(document.body).generationLoading);await status.click();await dialog.getByRole('button',{name:'Edit variable: on_call',exact:true}).click();await p.waitForFunction(()=>Alpine.$data(document.body).modal==='variable');await p.evaluate(original=>Object.assign(Alpine.$data(document.body).form.node.entries[0],original),original);await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult&&!a.generationResult.issues.length;});assert.match(await status.innerText(),/Draft checked/);await p.setViewportSize({width:390,height:844});await p.screenshot({path:'test-results/diagnostics-mobile.png'});assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS persistent diagnostics, primary source links, draft preservation and recovery');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,13 @@
|
||||
const {chromium}=require('playwright');const assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1500,height:1050}});page.setDefaultTimeout(10000);const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Map',exact:true}).click();const d=page.locator('dialog.generation-map');await d.waitFor();await page.waitForFunction(()=>Alpine.$data(document.body).mapNodes.length>0&&!Alpine.$data(document.body).mapBusy);assert.equal(await d.locator('canvas').count()>0,true);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Off hours and unmatched alerts'});await page.waitForFunction(()=>document.querySelector('.map-source-list')?.textContent.includes('Time off'));await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>document.querySelector('.map-input-content')?.innerText.includes('Otherwise'));const inputs=await d.locator('.map-input-content').innerText();assert(inputs.includes('Time off'));assert(inputs.includes('All alerts'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(inputs));assert(!inputs.includes('iteration'));assert(!inputs.includes('null'));assert(await d.locator('.map-input-content .map-reference').count()>0);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('.map-source-card').length===2);const sources=await d.locator('.map-source-list').innerText();assert(sources.includes('projects'));assert(sources.includes('Time off'));assert(sources.includes('responsible'));assert(!sources.includes('Alice'));assert(!sources.includes('different values'));await page.screenshot({path:'test-results/generation-map-inputs.png'});const layout=await page.evaluate(()=>{const a=Alpine.$data(document.body),rules=a.mapNodes.filter(n=>n.step);return {rules,children:a.mapNodes.filter(n=>n.rule&&!n.step)};});assert(layout.rules.every((n,i)=>n.step===i+1 && (!i || n.position.x>layout.rules[i-1].position.x)));for(const n of layout.children){const r=layout.rules.find(r=>r.rule===n.rule);assert.equal(n.position.x,r.position.x);assert(n.position.y>r.position.y);}assert.equal(await d.locator('.map-canvas').evaluate(el=>el._cyreg.cy.edges('[role="order"]').length),layout.rules.length-1);const stack=await d.locator('.map-canvas').evaluate(el=>{const cy=el._cyreg.cy,n=cy.nodes('[kind="batch"]').first();n.position({x:n.position('x')+20,y:n.position('y')+30});const p=n.position(),sheet=cy.getElementById(n.id()+':sheet:2').position();return {x:sheet.x-p.x,y:sheet.y-p.y};});assert.deepEqual(stack,{x:16,y:-16});const canvas=d.locator('.map-canvas'),box=await canvas.boundingBox();const point=await canvas.evaluate(el=>el._cyreg.cy.nodes().filter(n=>n.data('kind')==='variable').first().renderedPosition());await page.mouse.click(box.x+point.x,box.y+point.y);await page.waitForFunction(()=>!!Alpine.$data(document.body).mapCurrent()?.variable);const before=await canvas.evaluate(el=>el._cyreg.cy.pan());await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+70,box.y+65,{steps:6});await page.mouse.up();const after=await canvas.evaluate(el=>el._cyreg.cy.pan());assert.notDeepEqual(after,before);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project · Iteration · 6 passes'});assert.equal(await d.locator('.map-resource').count(),18);const owners=d.locator('.map-resource').filter({has:page.locator('summary > strong').filter({hasText:/^Payments · owners$/})});await owners.locator('summary').click();const ownerFields=owners.locator('.map-resource-fields');await ownerFields.waitFor();const ownerText=await ownerFields.innerText();assert(/Alice Morgan[\s\S]*Bob Chen[\s\S]*Dave Wilson/.test(ownerText));assert(ownerText.includes('Time off'));assert(ownerText.includes('11:00'));assert(!/[0-9a-f]{8}-[0-9a-f-]{27}/i.test(ownerText));assert.equal(await owners.locator('pre').count(),0);await page.screenshot({path:'test-results/generation-map-resource-fields.png'});await d.getByLabel('Iteration pass',{exact:true}).selectOption('0');await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===3);await d.getByRole('button',{name:'Expand outputs',exact:true}).click();assert(await d.getByLabel('Selected node',{exact:true}).locator('option').count()>10);await d.getByRole('button',{name:'Zoom in',exact:true}).click();await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({path:'test-results/generation-map-desktop.png'});await page.setViewportSize({width:390,height:844});await d.getByRole('button',{name:'Fit all',exact:true}).click();await page.screenshot({paLine truncated
|
||||
const revision=await page.evaluate(()=>Alpine.$data(document.body).workspace.revision);
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.generationDraft.rules[1].enabled=false;});
|
||||
await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Project owners during working hours'});await page.waitForFunction(()=>document.querySelectorAll('dialog.generation-map .map-resource').length===0);await d.getByRole('button',{name:'Close dialog',exact:true}).click();
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.generationDraft.rules[1].enabled=true;a.generationDraft.variables.find(v=>v.name==='projects').node.entries[0].entries.find(n=>n.key==='responsible').entries[0].value='not-an-email';});
|
||||
await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);assert(await d.locator('.map-status-error').count());await page.waitForFunction(()=>document.querySelectorAll('dialog.generation-map .map-resource').length===0);await d.getByRole('button',{name:'Close dialog',exact:true}).click();assert.equal(await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).workspace.revision}),revision);
|
||||
await page.getByLabel('Workspace',{exact:true}).selectOption({label:'Regional Platform'});await page.getByRole('button',{name:'Map',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).mapBusy);await d.getByLabel('Selected node',{exact:true}).selectOption({label:'Service · Iteration · 4 passes'});const rows=await page.evaluate(()=>Alpine.$data(document.body).mapRows());const filtered=rows.findIndex(r=>r.status==='filtered');assert(filtered>=0);await d.getByLabel('Iteration pass',{exact:true}).selectOption(String(filtered));await page.waitForFunction(()=>Alpine.$data(document.body).mapResources().length===0);await page.screenshot({path:'test-results/generation-map-filtered.png'});await d.getByRole('button',{name:'Close dialog',exact:true}).click();
|
||||
assert.deepEqual(errors,[]);console.log('PASS generation map, actual iteration outputs, pass filtering, group expansion, zoom, source editor, mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
const {chromium}=require('playwright'),assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1200,height:850}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();for(const width of [1200,390]){await page.setViewportSize({width,height:850});const button=page.locator('.binding-tweak').last();await button.scrollIntoViewIfNeeded();await button.click();const saved=await page.evaluate(()=>Alpine.$data(document.body).editorScroll);assert(saved>0);const popup=page.locator('.binding-popover');await popup.waitFor();const bounds=await popup.boundingBox();assert(bounds.x>=0&&bounds.x+bounds.width<=width&&bounds.y>=0&&bounds.y+bounds.height<=850);assert(await page.locator('.rule-constructor').isVisible());await popup.screenshot({path:'test-results/binding-popover-'+width+'.png'});await page.getByRole('button',{name:'Done',exact:true}).click();await page.waitForFunction(top=>Math.abs(document.querySelector('[role=dialog]').scrollTop-top)<2,saved);assert.equal(await page.locator('select.template-token,input.template-token,button.template-token').count(),0);assert(await page.locator('.template-token').count()>0);}await page.getByLabel('Iteration collection',{exact:true}).scrollIntoViewIfNeeded();await page.screenshot({path:'test-results/standard-template-controls.png'});assert.deepEqual(errors,[]);console.log('PASS standard controls; template notation retained; modal scroll restored desktop/mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,84 @@
|
||||
const {openSetup}=require('./support/browser.cjs');
|
||||
const {chromium}=require('playwright');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs');
|
||||
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
|
||||
assert(['127.0.0.1','localhost'].includes(new URL(base).hostname));
|
||||
fs.mkdirSync('test-results',{recursive:true});
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({...(process.env.MEGURI_BROWSER_CHANNEL==='chromium'?{}:{channel:process.env.MEGURI_BROWSER_CHANNEL||'chrome'}),headless:true});
|
||||
const page=await browser.newPage({viewport:{width:1440,height:1100}});page.setDefaultTimeout(10000);
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
const dialog=()=>page.getByRole('dialog');
|
||||
const save=async(name='Save changes')=>{await dialog().getByRole('button',{name,exact:true}).click();await dialog().waitFor({state:'hidden'});};
|
||||
const checked=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult && !a.generationLoading && a.generationResult.key===JSON.stringify(a.generationPayload());});
|
||||
try {
|
||||
await page.goto(base);await page.locator('.demo-note').waitFor();await page.getByRole('status',{name:'Demo data retention',exact:true}).waitFor();assert.equal(await page.locator('.demo-banner button').count(),0);assert.match(await page.locator('.demo-protection').innerText(),/left untouched/);assert.match(await page.locator('.demo-note').innerText(),/alice[\s\S]*all four workspaces[\s\S]*bob[\s\S]*carol/);
|
||||
await page.screenshot({path:'test-results/demo-login.png',fullPage:true});
|
||||
await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);
|
||||
const demo=await page.evaluate(()=>{const a=Alpine.$data(document.body);return {matrix:a.directoryState.workspaces.find(t=>t.name==='Acme Engineering'),support:a.directoryState.workspaces.find(t=>t.name==='Customer Support')};});
|
||||
assert(demo.matrix && demo.support);assert.notEqual(demo.matrix.id,demo.support.id);
|
||||
await page.getByLabel('Workspace',{exact:true}).selectOption(demo.matrix.id);await openSetup(page,'Variables & rules');await checked();
|
||||
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);
|
||||
const matrix=await page.evaluate(()=>Alpine.$data(document.body).doc);
|
||||
assert.deepEqual(matrix.generation.variables.map(v=>v.name),['projects','on_call']);
|
||||
assert.equal(await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.nodeSummary(a.generationDraft.variables.find(v=>v.name==='projects').node);}), 'Payments, Search, Billing, Identity…');
|
||||
for(const project of matrix.generation.variables[0].node.entries){
|
||||
const name=project.entries.find(n=>n.key==='name').value;
|
||||
const owners=project.entries.find(n=>n.key==='responsible').entries.map(n=>n.value);
|
||||
const schedule=matrix.schedules.find(s=>s.name===name+' · owners');
|
||||
assert.deepEqual(schedule.participants,owners);
|
||||
assert(matrix.routes.some(r=>r.name===name+' · working hours' && r.conditions.some(c=>c.value==='project:'+name)));
|
||||
}
|
||||
assert.equal(matrix.schedules.filter(s=>s.type==='priority').length,6);assert.equal(matrix.schedules.find(s=>s.name==='Time off').shift_days,7);assert.equal(matrix.schedules.find(s=>s.name==='Time off').handover,'11:00:00');
|
||||
assert.equal(matrix.routes.filter(r=>r.window).length,6);assert.equal(matrix.routes.at(-1).window,null);
|
||||
await page.screenshot({path:'test-results/matrix-demo.png',fullPage:true});
|
||||
await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();await checked();await page.screenshot({path:'test-results/matrix-data-editor.png',fullPage:true});
|
||||
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/matrix-data-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.setViewportSize({width:1440,height:1100});await dialog().getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await page.getByLabel('Workspace',{exact:true}).selectOption(demo.support.id);await checked();
|
||||
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).doc.generation.rules[1].name),'Daily queue coverage');
|
||||
await openSetup(page,'Escalations & routing');await page.screenshot({path:'test-results/support-demo.png',fullPage:true});
|
||||
// Distinct members cannot read the other example's team, even with its URL.
|
||||
for(const [username,denied] of [['bob',demo.support],['dave',demo.support]]){
|
||||
const ctx=await browser.newContext();const api=ctx.request;const login=await api.post(base+'/api/login',{headers:{Origin:base},data:{username,password:'meguri-demo-only'}});assert.equal(login.status(),200);const response=await api.get(`${base}/api/workspaces/${denied.id}`);assert.equal(response.status(),404);await ctx.close();
|
||||
}
|
||||
// Mutating UI checks use a separate account, leaving the examples untouched.
|
||||
await page.getByRole('button',{name:'Sign out',exact:true}).click();await page.getByRole('button',{name:'New here? Create an account',exact:true}).click();const unique=Date.now();await page.getByLabel('Username',{exact:true}).fill('matrix-'+unique);await page.getByLabel('Display name',{exact:true}).fill('Matrix tester');await page.getByLabel('Password',{exact:true}).fill('local-browser-test-password');await page.getByRole('button',{name:'Create account',exact:true}).click();
|
||||
await page.getByRole('button',{name:'New workspace',exact:true}).click();await dialog().getByLabel('Name',{exact:true}).fill('Matrix '+unique);await save();
|
||||
await openSetup(page,'Variables & rules');
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body),projects={id:crypto.randomUUID(),name:'projects',node:a.newNode('list')},responsible={id:crypto.randomUUID(),name:'responsible',node:a.newNode('dictionary')};for(const name of ['A','B']){projects.node.entries.push(a.newNode('text',name));const entry=a.newNode('list',null,name);entry.entries=['alice@example.com','bob@example.com'].map(email=>a.newNode('person',email));responsible.node.entries.push(entry);}const iteration={id:crypto.randomUUID(),name:'Project',source:{kind:'variable',variable:projects.id,path:[]}},block=a.newBlock('rotation');block.fields.name={kind:'item',iteration:iteration.id,path:[],suffix:' on call'};block.fields.participants={kind:'lookup',variable:responsible.id,key:{kind:'item',iteration:iteration.id,path:[]},path:[]};a.generationDraft.variables.push(projects,responsible);a.generationDraft.rules.push({id:crypto.randomUUID(),name:'Project rotations',enabled:true,iterations:[iteration],when:null,blocks:[block]});a.generationChanged();});await checked();
|
||||
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationCount('create')),2);
|
||||
const revision=await page.evaluate(()=>Alpine.$data(document.body).workspace.revision);
|
||||
await page.getByRole('button',{name:'Edit variable responsible',exact:true}).click();await dialog().getByLabel('Dictionary key').first().fill('Missing');await checked();await dialog().locator('.modal-footer:visible .validation-toggle').click();assert.match(await dialog().locator('.validation-details').innerText(),/key ‘A’/);
|
||||
assert.equal(await page.evaluate(async()=>{const a=Alpine.$data(document.body);return (await a.api(a.endpoint())).workspace.revision;}),revision);
|
||||
await dialog().getByLabel('Dictionary key').first().fill('A');await checked();await save('Use in draft');await checked();
|
||||
await page.getByRole('button',{name:'Edit rule Project rotations',exact:true}).click();await dialog().getByLabel('Shift length (days) · Fixed value',{exact:true}).fill('0');await checked();
|
||||
await dialog().evaluate(el=>el.scrollTop=0);const status=dialog().locator('.modal-footer:visible .validation-control');assert.match(await status.innerText(),/Issues to resolve/);const box=await status.boundingBox();assert(box && box.y>=0 && box.y+box.height<=1100,'Validation stays in the viewport');
|
||||
if(await status.locator('.validation-toggle').getAttribute('aria-expanded')==='false')await status.locator('.validation-toggle').click();assert.match(await status.locator('.validation-details').innerText(),/shift_days/);
|
||||
await dialog().getByRole('button',{name:'Bind Shift length (days)',exact:true}).click();await dialog().getByLabel('Fixed number',{exact:true}).waitFor();assert.equal(await dialog().getByLabel('Rule name',{exact:true}).isVisible(),true,'The value picker preserves the constructor');
|
||||
await dialog().getByLabel('Fixed number',{exact:true}).fill('1');await dialog().getByRole('button',{name:'Done',exact:true}).click();await dialog().getByLabel('Rule name',{exact:true}).waitFor();await checked();assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);await page.screenshot({path:'test-results/generation-rule-editor.png',fullPage:true});await save('Use in draft');await checked();
|
||||
await page.getByRole('button',{name:'Apply checked changes',exact:true}).click();await page.waitForFunction(rev=>Alpine.$data(document.body).workspace.revision>rev,revision);await checked();
|
||||
const ids=await page.evaluate(()=>Alpine.$data(document.body).doc.schedules.map(s=>s.id));assert.equal(ids.length,2);assert.equal(await page.evaluate(()=>Alpine.$data(document.body).doc.schedules[0].shift_days),1);
|
||||
await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();await dialog().getByLabel('Variable name',{exact:true}).fill('services');await save('Use in draft');await checked();await page.getByRole('button',{name:'Apply checked changes',exact:true}).click();await page.waitForFunction(()=>!Alpine.$data(document.body).busy);await page.reload();await openSetup(page,'Variables & rules');await checked();assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).doc.schedules.map(s=>s.id)),ids);
|
||||
await openSetup(page,'Schedules');assert.equal(await page.locator('[data-resource-kind="schedules"] .source-rule-link').count(),2);
|
||||
// The guided editor resolves the ordinary project/dictionary case without opening a binding editor.
|
||||
await page.getByRole('button',{name:'+ Generation rule',exact:true}).click();await checked();
|
||||
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).form.iterations.length),0);
|
||||
assert.equal(await page.evaluate(()=>Alpine.$data(document.body).form.blocks.length),0);
|
||||
await dialog().getByLabel('Rule name',{exact:true}).fill('Explicit iteration');
|
||||
await dialog().getByRole('button',{name:'+ Iteration',exact:true}).click();
|
||||
await dialog().getByLabel('Add output block',{exact:true}).selectOption('priority');
|
||||
const lookup=await page.evaluate(()=>Alpine.$data(document.body).sourceOptions('list').find(o=>o.kind==='lookup').id);
|
||||
await dialog().getByLabel('Responsible people, in priority order',{exact:true}).selectOption(lookup);
|
||||
assert.equal(await dialog().locator('.binding-editor').count(),0);
|
||||
// Iterated generated instances are not offered as fixed fallback targets; choose an explicit generated rotation.
|
||||
await dialog().getByLabel('Add output block',{exact:true}).selectOption('rotation');
|
||||
const backup=await page.evaluate(()=>Alpine.$data(document.body).form.blocks.at(-1).id);
|
||||
await dialog().getByLabel('Fallback schedule',{exact:true}).selectOption('output:'+backup);await checked();
|
||||
assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);
|
||||
await page.setViewportSize({width:390,height:844});await page.screenshot({path:'test-results/generation-guided-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.setViewportSize({width:1440,height:1100});await dialog().getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await page.locator('[data-resource-kind="schedules"] > summary').first().click();await page.locator('[data-resource-kind="schedules"] .source-rule-link').first().click();assert.equal(await dialog().getByLabel('Rule name',{exact:true}).inputValue(),'Project rotations');await dialog().getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
assert.deepEqual(errors,[]);console.log('PASS: four isolated demo workspaces, minimal login guide, country-aware matrix data, live dictionary errors, no preview writes, binding editor, atomic apply, stable IDs after rename/reload, generated source links, desktop/mobile');
|
||||
}catch(e){await page.screenshot({path:'test-results/generation-failure.png',fullPage:true});console.error('Page errors:',errors);throw e;}
|
||||
finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,25 @@
|
||||
const {chromium}=require('playwright'),assert=require('node:assert/strict'),fs=require('node:fs');
|
||||
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';assert(['127.0.0.1','localhost'].includes(new URL(base).hostname));
|
||||
(async()=>{const browser=await chromium.launch({channel:process.env.MEGURI_BROWSER_CHANNEL||'chrome',headless:true});const errors=[];let page;try{
|
||||
const context=await browser.newContext({locale:'en-US',viewport:{width:1440,height:1000}});page=await context.newPage();page.setDefaultTimeout(12000);page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.goto(base+'/?view=policies');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>Alpine.$data(document.body).workspace);await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
|
||||
const matrix=await page.evaluate(()=>Alpine.$data(document.body).selected);const support=await page.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.id!==Alpine.$data(document.body).selected).id);
|
||||
assert.equal(new URL(page.url()).searchParams.get('view'),'configuration');await page.reload();await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();
|
||||
await page.getByRole('link',{name:'Requests',exact:true}).click();await page.waitForURL(/view=requests/);await page.getByLabel('Workspace',{exact:true}).selectOption(support);await page.waitForFunction(id=>Alpine.$data(document.body).workspace.id===id,support);assert.equal(new URL(page.url()).searchParams.get('workspace'),support);
|
||||
await page.goBack();await page.waitForFunction(id=>Alpine.$data(document.body).workspace.id===id,matrix);assert.equal(await page.evaluate(()=>Alpine.$data(document.body).view),'requests');await page.goBack();await page.getByRole('heading',{name:'Schedule setup',exact:true}).waitFor();await page.goForward();await page.getByRole('heading',{name:'Requests',exact:true}).waitFor();
|
||||
const href=await page.getByRole('link',{name:'Activity',exact:true}).getAttribute('href');const tab=await context.newPage();await tab.goto(base+href);await tab.getByRole('heading',{name:'Activity',exact:true}).waitFor();assert.equal(await tab.evaluate(()=>Alpine.$data(document.body).selected),matrix);await tab.close();
|
||||
// Locale is a saved display preference; use the real account form, then a fresh browser locale.
|
||||
await page.locator('.account-button').click();await page.getByLabel('Language',{exact:true}).selectOption('ru');await page.getByRole('dialog').getByRole('button',{name:'Save changes',exact:true}).click();await page.waitForFunction(()=>document.documentElement.lang==='ru');await page.reload();await page.waitForFunction(()=>document.documentElement.lang==='ru' && Alpine.$data(document.body).workspace);await page.screenshot({path:'test-results/localization-ru.png',fullPage:true});
|
||||
const snapshot=await page.evaluate(async()=>{const a=Alpine.$data(document.body);return {revision:a.workspace.revision,schedules:JSON.stringify(a.doc.schedules),user:a.user};});
|
||||
const fr=await browser.newContext({locale:'fr-CA',storageState:await context.storageState(),viewport:{width:390,height:844}});const fp=await fr.newPage();fp.on('pageerror',e=>errors.push(e.message));await fp.goto(base+'/?view=configuration&workspace='+matrix);await fp.waitForFunction(()=>document.documentElement.lang==='ru' && Alpine.$data(document.body).workspace);
|
||||
await fp.evaluate(async()=>{const a=Alpine.$data(document.body);await a.api('/api/account','PUT',{...a.user,language:null});});await fp.reload();await fp.waitForFunction(()=>document.documentElement.lang==='fr' && Alpine.$data(document.body).workspace);await fp.getByRole('heading',{name:'Configuration des plannings',exact:true}).waitFor();assert.equal(await fp.evaluate(()=>Alpine.$data(document.body).workspace.revision),snapshot.revision);assert.equal(await fp.evaluate(()=>JSON.stringify(Alpine.$data(document.body).doc.schedules)),snapshot.schedules);assert(await fp.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await fp.screenshot({path:'test-results/localization-fr-mobile.png',fullPage:true});
|
||||
assert.equal(await fp.evaluate(()=>I18n.diagnostic('Choose an existing workspace variable')),'Choisissez une variable existante de l’espace');assert.equal(await fp.evaluate(()=>I18n.translate('coverage_to_arrange',{count:'<b>{person}</b>'},'fr')),'Intervalles sans remplacement : <b>{person}</b>');
|
||||
// Invalid preference cannot be persisted.
|
||||
const status=await fp.evaluate(async()=>{const a=Alpine.$data(document.body);return (await fetch('/api/account',{method:'PUT',headers:{'Content-Type':'application/json','X-CSRF-Token':a.csrf},body:JSON.stringify({...a.user,language:'xx'})})).status;});assert.equal(status,422);
|
||||
await fr.close();
|
||||
for(const [locale,expected]of [['ru-RU','ru'],['de-DE','en'],['fr-CA','fr']]){const cx=await browser.newContext({locale});const p=await cx.newPage();await p.goto(base);await p.waitForFunction(()=>Alpine.$data(document.body).ready);assert.equal(await p.locator('html').getAttribute('lang'),expected);await cx.close();}
|
||||
// Parse templates rather than rendered text: built-in copy must be catalog-backed.
|
||||
for(const file of fs.readdirSync('static').filter(f=>f.endsWith('.html'))){const source=fs.readFileSync('static/'+file,'utf8');const literals=await page.evaluate(source=>{const doc=new DOMParser().parseFromString(source,'text/html'),out=[];const visit=node=>{for(const n of node.childNodes){if(n.nodeType===3 && n.textContent.trim())out.push(n.textContent.trim());if(n.nodeType===1){if(n.tagName==='TEMPLATE')visit(n.content);else if(!['SCRIPT','STYLE'].includes(n.tagName))visit(n);}}};visit(doc);return out;},source);assert.deepEqual(literals,[],file+' contains inline text');}
|
||||
const catalog=JSON.parse(fs.readFileSync('static/locales/messages.json','utf8'));const placeholders=s=>[...s.matchAll(/\{(\w+)\}/g)].map(m=>m[1]).sort();for(const [key,entry]of Object.entries(catalog))for(const lang of ['en','ru','fr']){assert(entry[lang],key+': '+lang);assert.deepEqual(placeholders(entry[lang]),placeholders(entry.en),key);}
|
||||
assert.deepEqual(errors,[]);console.log('PASS: menu links, reload, cross-team Back/Forward, new tab, language persistence and browser fallback, translated validation, unchanged schedules, mobile, complete catalog, no inline template copy');
|
||||
}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1 @@
|
||||
const {chromium}=require('playwright'),assert=require('node:assert/strict');(async()=>{const b=await chromium.launch({channel:'chrome',headless:true});try{const p=await b.newPage({viewport:{width:1440,height:1000}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();const d=p.getByRole('dialog');assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'0');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'0');await d.getByLabel('Repeat count · Fixed value',{exact:true}).fill('2');await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).fill('10');await p.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});assert.deepEqual(await p.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);await d.locator('.generation-block').nth(1).screenshot({path:'test-results/repeat-defaults.png'});await d.getByRole('button',{name:'Use in draft',exact:true}).click();await p.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).click();assert.equal(await d.getByLabel('Repeat count · Fixed value',{exact:true}).inputValue(),'2');assert.equal(await d.getByLabel('Minutes between repeats · Fixed value',{exact:true}).inputValue(),'10');await d.getByRole('button',{name:'Cancel',exact:true}).click();const team=await p.evaluate(()=>Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name==='Commerce Operations').id);await p.goto('http://127.0.0.1:3000/?view=configuration&workspace='+team);await p.getByRole('button',{name:'Edit rule Product → service tier',exact:true}).click();assert.equal(await p.evaluate(()=>Alpine.$data(document.body).form.blocks.find(b=>b.kind==='escalation').fields.repeat_count.kind),'lookup');assert.deepEqual(errors,[]);console.log('PASS optional repeat defaults, editing and draft reopening, explicit lookup preservation');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1 @@
|
||||
const {chromium}=require('playwright'),assert=require('node:assert/strict');(async()=>{const b=await chromium.launch({channel:'chrome',headless:true});try{const p=await b.newPage({viewport:{width:1440,height:1050}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:3000/?view=configuration');await p.getByLabel('Username',{exact:true}).fill('alice');await p.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await p.getByRole('button',{name:'Sign in',exact:true}).click();await p.locator('.resource-row').first().waitFor();await p.getByRole('heading',{name:'Schedule setup',exact:true}).click();for(const [kind,index] of [['schedules',0],['schedules',1],['escalations',0],['routes',0]]){const row=p.locator('[data-resource-kind='+kind+']').nth(index);await row.locator('summary').click();await row.locator('.resource-widget').waitFor();assert(await row.locator('.widget-node').count()>0);await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'.png'});await p.setViewportSize({width:390,height:844});await row.screenshot({path:'test-results/resource-'+kind+'-'+index+'-mobile.png'});assert((await row.boundingBox()).width<390);await p.setViewportSize({width:1440,height:1050});await row.locator('summary').click();}assert.deepEqual(errors,[]);console.log('PASS shared resource rows, schedule variants, escalation, routing, desktop and mobile');}finally{await b.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,14 @@
|
||||
const {chromium}=require('playwright'),assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{
|
||||
const page=await browser.newPage({viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Edit rule Project owners during working hours',exact:true}).click();
|
||||
const panel=page.locator('.rule-result');const ready=()=>page.waitForFunction(()=>{const a=Alpine.$data(document.body);return !a.generationLoading&&a.generationResult;});await ready();
|
||||
assert.equal(await panel.locator('.rule-result-row').count(),18);const text=await panel.innerText();assert.match(text,/Payments/);assert.match(text,/Alice Morgan/);assert.match(text,/Time off/);assert.match(text,/Escalation/);assert.match(text,/Routing/);await panel.screenshot({path:'test-results/rule-result.png'});
|
||||
// A rule with escalation outputs only remains visible and resolves a manual target.
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.form.blocks=a.form.blocks.filter(b=>b.kind==='escalation');a.form.blocks[0].steps[0].recipient=a.literal(a.doc.schedules.find(s=>s.name==='Time off').id);});await ready();assert.equal(await panel.locator('.rule-result-row').count(),6);
|
||||
await page.evaluate(()=>{Alpine.$data(document.body).form.enabled=false;});await ready();assert.match(await panel.innerText(),/Rule disabled/);assert.equal(await panel.locator('.rule-result-row').count(),0);
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.form.enabled=true;a.form.blocks[0].steps[0].delay=a.literal('broken');});await ready();assert.match(await panel.innerText(),/Check the configuration errors/);assert.equal(await panel.locator('.rule-result-row').count(),0);
|
||||
await page.evaluate(()=>{const a=Alpine.$data(document.body);a.form.blocks[0].steps[0].delay=a.literal(0);a.form.when={left:a.literal('a'),operation:'equals',right:a.literal('b')};});await ready();assert.match(await panel.innerText(),/No resources/);
|
||||
await page.setViewportSize({width:390,height:844});await panel.screenshot({path:'test-results/rule-result-mobile.png'});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);assert.deepEqual(errors,[]);console.log('PASS server-generated rule results: all types, resolved names, escalation-only, disabled, invalid, filtered and mobile');
|
||||
}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,2 @@
|
||||
const {chromium}=require('playwright'),assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit rule Weekly Time off',exact:true}).waitFor();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.generationResult&&!a.generationLoading;});assert.deepEqual(await page.evaluate(()=>Alpine.$data(document.body).generationResult.issues),[]);assert.equal(await page.locator('.generation-review').getAttribute('open'),null);assert.equal(await page.locator('#public-holidays').getAttribute('open'),null);assert.equal((await page.locator('#public-holidays > .widget-heading .badge').innerText()).trim(),'33');assert.equal(await page.locator('.setup-jumps').count(),0);assert.equal(await page.getByText('Start with projects & responsible example',{exact:true}).count(),0);assert.equal(await page.locator('.resource-row[open]').count(),0);assert(await page.locator('.resource-row.generated-resource').count()>0);assert.equal(await page.locator('.resource-create > button').count(),2);assert(await page.locator('#availability').evaluate(el=>el.getBoundingClientRect().left > document.querySelector('.generation-panel:nth-child(2)').getBoundingClientRect().left));const placement=page.locator('.generated-routing-placement');assert(!await placement.isVisible());await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes.push({id:'placement-test-b',name:'Catch all',order:200,conditions:[]},{id:'placement-test-a',name:'Critical alerts',order:100,conditions:[]});});await placement.waitFor({state:'visible'});assert.deepEqual(await placement.locator('option').allTextContents(),['Before manual rules','After “Critical alerts”','After “Catch all”']);await placement.locator('select').selectOption('1');assert.equal(await page.evaluate(()=>Alpine.$data(document.body).generationDraft.route_position),1);await page.evaluate(()=>{const a=Alpine.$data(document.body);a.doc.routes=a.doc.routes.filter(r=>!r.id.startsWith('placement-test-'));a.generationDraft.route_position=0;});await placement.waitFor({state:'hidden'});const row=page.locator('.resource-row').first();await row.locator('summary').click();await row.locator('.resource-body').waitFor();await row.locator('summary').click();await page.locator('.schedule-create > summary').click();await page.getByRole('button',{name:'+ Rotation',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();const overview=await page.evaluate(()=>{const a=Alpine.$data(document.body);return a.generationDraft.rules.map(r=>a.ruleOverview(r));});assert(overview.some(t=>t.includes('{ var.projects }')&&t.includes('Escalation')));assert(!overview[0].includes('Iterations'));for(const kind of ['schedules','escalations','routes']){const resource=page.locator('[data-resource-kind="'+kind+'"] .resource-summary').first();assert((await resource.locator('.resource-brief').innerText()).length>10);await resource.click();const link=resource.locator('..').locator('.source-rule-link');const name=(await link.innerText()).replace('Generated by: ','');await link.click();await page.waitForFunction(name=>Alpine.$data(document.body).form.name===name,name);await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await resource.click();}await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-desktop.png',fullPage:true});const japan=page.locator('.holiday-group').filter({has:page.locator('summary').filter({hasText:'Japan'})});assert.equal(await japan.getAttribute('open'),null);assert.equal((await japan.locator('.badge').innerText()).trim(),'18');await japan.locator('summary').click();await japan.getByText('Coming of Age Day',{exact:true}).waitFor();await page.locator('#public-holidays').screenshot({path:'test-results/setup-holidays.png'});await page.getByRole('button',{name:'Import CSV',exact:true}).click();await page.getByRole('dialog').waitFor();await page.getByRole('dialog').getByRole('button',{name:'Cancel',exact:true}).click();await page.setViewportSize({width:390,height:844});await page.evaluate(()=>window.scrollTo(0,0));await page.screenshot({path:'test-results/setup-mobile.png',fullPage:true});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS setup layout, collapsed review, 33 country holidays, CSV modal and mobile');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,3 @@
|
||||
const {chromium}=require('playwright');const assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:3000/?view=configuration');await page.getByLabel('Username',{exact:true}).fill('alice');await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.getByRole('button',{name:'Edit variable projects',exact:true}).click();const d=page.getByRole('dialog');assert.equal(await d.locator('.data-row').count(),7);assert.equal(await d.getByLabel('Value type',{exact:true}).filter({visible:true}).count(),0);await page.screenshot({path:'test-results/compact-variables.png'});await d.getByRole('button',{name:'Expand or collapse Payments',exact:true}).click();await d.getByLabel('Text value',{exact:true}).fill('Payments edited');await d.getByRole('button',{name:'Expand or collapse responsible',exact:true}).click();assert.equal(await d.getByLabel('Registered person',{exact:true}).count(),3);const verifyIndent=async()=>{const geometry=await d.locator('.data-row').evaluateAll(es=>es.slice(0,4).map(e=>({depth:Number([...e.classList].find(c=>c.startsWith('data-depth-')).slice(11)),padding:parseFloat(getComputedStyle(e).paddingLeft)})));assert(geometry[0].padding>=12);assert(geometry[1].padding>geometry[0].padding);assert(geometry[2].padding>geometry[1].padding);};await verifyIndent();await d.getByRole('button',{name:'Structure',exact:true}).click();await d.getByLabel('Value type',{exact:true}).first().waitFor({state:'visible'});await d.getByRole('button',{name:'Values',exact:true}).click();assert.equal(await d.getByLabel('Text value',{exact:true}).inputValue(),'Payments edited');await d.getByRole('button',{name:'Duplicate entry',exact:true}).first().click();const ids=await page.evaluate(()=>{const root=Alpine.$data(document.body).form.node,out=[];const walk=n=>{out.push(n.id);n.entries.forEach(walk)};walk(root);return out});assert.equal(ids.length,new Set(ids).size);await page.setViewportSize({width:390,height:844});await verifyIndent();await page.screenshot({path:'test-results/compact-variables-mobile.png'});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.deepEqual(errors,[]);console.log('PASS compact nested editor, retained values, schema controls, fresh duplicate IDs, mobile');}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
const {openSetup}=require('./support/browser.cjs');
|
||||
// Run against an explicitly seeded localhost development instance.
|
||||
const { chromium } = require('playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const base = process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
|
||||
assert(['127.0.0.1','localhost'].includes(new URL(base).hostname), 'Browser tests require a disposable local instance');
|
||||
fs.mkdirSync('test-results', { recursive: true });
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ ...(process.env.MEGURI_BROWSER_CHANNEL==='chromium'?{}:{channel:process.env.MEGURI_BROWSER_CHANNEL || 'chrome'}), headless: true });
|
||||
const errors = [];
|
||||
async function signIn(name) {
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
|
||||
const page = await context.newPage(); page.setDefaultTimeout(10000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(base); await page.getByLabel('Username', { exact: true }).fill(name);
|
||||
await page.getByLabel('Password', { exact: true }).fill('meguri-demo-only');
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await page.getByRole('heading', { name: 'Calendar', exact: true }).waitFor();
|
||||
return page;
|
||||
}
|
||||
async function save(page) {
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Save changes', exact: true }).click();
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden' });
|
||||
}
|
||||
try {
|
||||
const alice = await signIn('alice');
|
||||
await alice.locator('.day').first().waitFor();
|
||||
await alice.screenshot({ path: 'test-results/calendar-desktop.png', fullPage: true });
|
||||
const label = 'Browser ' + Date.now();
|
||||
await alice.getByRole('button', { name: 'New workspace' }).click();
|
||||
await alice.getByRole('dialog').getByLabel('Name', { exact: true }).fill(label); await save(alice);
|
||||
const created = await alice.evaluate(() => { const a=Alpine.$data(document.body); return {record:a.workspace, listing:a.directoryState}; });
|
||||
assert.deepEqual(Object.keys(created.listing), ['workspaces']);
|
||||
assert.equal(created.listing.workspaces.filter(w=>w.name===label).length,1);
|
||||
assert.equal(created.record.document.name,label);
|
||||
assert.equal(created.record.revision,1);
|
||||
assert.equal('tenant_id' in created.record,false);
|
||||
assert.equal((await alice.request.get(base+'/api/tenants')).status(),404);
|
||||
await openSetup(alice,'Workspace settings');
|
||||
await alice.getByRole('button', { name: '+ Add teammate' }).click();
|
||||
await alice.getByRole('dialog').getByLabel('Registered username').fill('bob'); await save(alice);
|
||||
await openSetup(alice,'Schedules');
|
||||
await alice.locator('.schedule-create > summary').click();
|
||||
await alice.getByRole('button', { name: '+ Rotation', exact: true }).click();
|
||||
const start = new Date(Date.now() + 2 * 86400000).toISOString().slice(0,10);
|
||||
const end = new Date(Date.now() + 3 * 86400000).toISOString().slice(0,10);
|
||||
await alice.getByRole('dialog').getByLabel('Schedule name').fill('Weekly rotation');
|
||||
await alice.getByRole('dialog').getByLabel('Anchor date').fill(start.split('-').reverse().join('-')); await save(alice);
|
||||
await alice.getByRole('link', { name: 'Calendar', exact: true }).click();
|
||||
await alice.locator('[data-duty-date="'+start+'"]').click();
|
||||
await alice.getByRole('dialog').getByRole('button', { name: 'Bob Chen', exact: true }).click();
|
||||
await alice.getByRole('dialog').getByLabel('Comment (optional)', {exact:true}).fill('Browser regression: planned appointment');
|
||||
await alice.getByRole('dialog').getByRole('button', {name:'Send request',exact:true}).click();
|
||||
await alice.getByRole('dialog').waitFor({state:'hidden'});
|
||||
const bob = await signIn('bob');
|
||||
const selectedTeam = await bob.evaluate(label => Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name===label).id,label);
|
||||
await bob.getByLabel('Workspace', { exact: true }).selectOption(selectedTeam);
|
||||
await bob.getByRole('link', { name: /Requests/ }).click();
|
||||
await bob.getByRole('button', { name: 'Accept', exact: true }).click();
|
||||
await bob.locator('.badge.applied').waitFor();
|
||||
await bob.screenshot({ path: 'test-results/approved-request.png', fullPage: true });
|
||||
await bob.getByRole('link', { name: 'Calendar', exact: true }).click();
|
||||
await bob.waitForFunction(start => [...document.querySelectorAll('.day')].some(el => el.dataset.dutyDate===start && el.textContent.includes('Bob')), start);
|
||||
const session = await alice.evaluate(() => fetch('/api/session').then(r=>r.json()));
|
||||
const workspace = await alice.evaluate(() => fetch('/api/workspaces').then(r=>r.json()));
|
||||
const team = workspace.workspaces.find(t => t.name === label);
|
||||
const endpoint = `/api/workspaces/${team.id}`;
|
||||
let response = await alice.request.post(base + endpoint + '/sync', { headers: { Origin: base }, data: {} });
|
||||
assert.equal(response.status(), 403, 'Missing CSRF must be rejected');
|
||||
response = await alice.request.post(base + endpoint + '/sync', { headers: { Origin: 'https://other.example', 'X-CSRF-Token': session.csrf }, data: {} });
|
||||
assert.equal(response.status(), 403, 'Cross-origin mutation must be rejected');
|
||||
const carol = await signIn('carol');
|
||||
response = await carol.request.get(base + endpoint); assert.equal(response.status(),404,'Other team members cannot read this team');
|
||||
const stranger = await browser.newContext();response = await stranger.request.get(base + endpoint);assert.equal(response.status(),401);
|
||||
await bob.setViewportSize({ width:390,height:844 });
|
||||
await bob.getByLabel('Choose workspace', {exact:true}).waitFor();
|
||||
await bob.screenshot({ path:'test-results/calendar-mobile.png',fullPage:true });
|
||||
assert(await bob.evaluate(() => document.documentElement.scrollWidth<=innerWidth), 'Mobile layout must not overflow');
|
||||
assert.deepEqual(errors, [], 'No browser runtime errors');
|
||||
console.log('PASS: calendar, workspace creation, membership, rotation, request, other-user approval, workspace isolation, CSRF/origin, mobile layout');
|
||||
} catch (error) {
|
||||
console.error('Browser errors:', errors);
|
||||
for(const context of browser.contexts()) for(const page of context.pages()) {
|
||||
console.error('Visible alert:', await page.locator('[role=alert]').textContent().catch(()=>''));
|
||||
await page.screenshot({ path:'test-results/failure.png',fullPage:true }).catch(()=>{});
|
||||
}
|
||||
throw error;
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode=1; });
|
||||
@@ -0,0 +1,690 @@
|
||||
use chrono::{Duration, NaiveDate, Utc};
|
||||
use meguri::{
|
||||
demo,
|
||||
domain::*,
|
||||
engine,
|
||||
generation::{self, Binding, Rule, Variable},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct Example {
|
||||
doc: WorkspaceDocument,
|
||||
team: Uuid,
|
||||
people: [Uuid; 2],
|
||||
variables: Vec<Variable>,
|
||||
rules: Vec<Rule>,
|
||||
day: NaiveDate,
|
||||
}
|
||||
impl Example {
|
||||
fn new() -> Self {
|
||||
let people = [Uuid::new_v4(), Uuid::new_v4()];
|
||||
let day = "2026-10-19".parse().unwrap();
|
||||
let (variables, rules) = demo::project_matrix(day, people[0], people[1]);
|
||||
let mut doc = WorkspaceDocument::new("Projects".into());
|
||||
doc.timezone = "Asia/Nicosia".into();
|
||||
Self {
|
||||
doc,
|
||||
team: Uuid::new_v4(),
|
||||
people,
|
||||
variables,
|
||||
rules,
|
||||
day,
|
||||
}
|
||||
}
|
||||
fn expand(&self) -> generation::Expansion {
|
||||
generation::expand(
|
||||
&self.doc,
|
||||
self.team,
|
||||
&self.variables,
|
||||
&self.rules,
|
||||
0,
|
||||
&WorkingWindow {
|
||||
timezone: self.doc.timezone.clone(),
|
||||
..WorkingWindow::default()
|
||||
},
|
||||
&self.people.into_iter().collect(),
|
||||
engine::handover(&self.doc, self.day).unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_keeps_project_priority_and_weekly_monday_handover_across_dst() {
|
||||
let e = Example::new();
|
||||
let out = e.expand();
|
||||
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
||||
assert_eq!(out.document.schedules.len(), 4);
|
||||
assert_eq!(out.document.routes.len(), 4);
|
||||
assert!(
|
||||
out.document.routes[..3]
|
||||
.iter()
|
||||
.all(|r| r.window.as_ref().unwrap().timezone == "Asia/Nicosia")
|
||||
);
|
||||
assert!(out.document.routes[3].conditions.is_empty());
|
||||
assert!(out.document.routes[3].window.is_none());
|
||||
let duty = out
|
||||
.document
|
||||
.schedules
|
||||
.iter()
|
||||
.find(|s| s.name == "Time off")
|
||||
.unwrap();
|
||||
let project = out
|
||||
.document
|
||||
.schedules
|
||||
.iter()
|
||||
.find(|s| s.name == "Payments · owners")
|
||||
.unwrap();
|
||||
let from = engine::handover(&out.document, e.day).unwrap();
|
||||
let to = engine::handover(&out.document, e.day + Duration::days(14)).unwrap();
|
||||
let timeline = engine::timeline(&out.document, from, to).unwrap();
|
||||
let shifts: Vec<_> = timeline
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == duty.id)
|
||||
.collect();
|
||||
assert_eq!(shifts.len(), 2);
|
||||
assert_eq!(shifts[0].user_id, e.people[0]);
|
||||
assert_eq!(shifts[1].user_id, e.people[1]);
|
||||
assert_eq!((shifts[0].end - shifts[0].start).num_hours(), 169);
|
||||
assert_eq!(shifts[0].end, shifts[1].start);
|
||||
assert!(
|
||||
timeline
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|a| a.schedule_id == project.id)
|
||||
.all(|a| a.user_id == e.people[0])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_skips_absence_country_holidays_then_uses_effective_fallback() {
|
||||
let e = Example::new();
|
||||
let mut doc = e.expand().document;
|
||||
let project = doc
|
||||
.schedules
|
||||
.iter()
|
||||
.find(|s| s.name == "Payments · owners")
|
||||
.unwrap()
|
||||
.id;
|
||||
let duty = doc
|
||||
.schedules
|
||||
.iter()
|
||||
.find(|s| s.name == "Time off")
|
||||
.unwrap()
|
||||
.id;
|
||||
let third = doc.people[0].id;
|
||||
doc.people.extend(e.people.map(|id| Person {
|
||||
id,
|
||||
user_id: Some(id),
|
||||
email: None,
|
||||
display_name: "Person".into(),
|
||||
country: Some(if id == e.people[0] { "CY" } else { "AM" }.into()),
|
||||
}));
|
||||
doc.holidays.push(Holiday {
|
||||
country: "CY".into(),
|
||||
date: e.day,
|
||||
name: "Holiday".into(),
|
||||
});
|
||||
let from = engine::handover(&doc, e.day).unwrap();
|
||||
let to = engine::handover(&doc, e.day + Duration::days(1)).unwrap();
|
||||
let owner = |doc: &WorkspaceDocument| {
|
||||
engine::timeline(doc, from, to)
|
||||
.unwrap()
|
||||
.assignments
|
||||
.into_iter()
|
||||
.find(|a| a.schedule_id == project)
|
||||
.unwrap()
|
||||
.user_id
|
||||
};
|
||||
assert_eq!(owner(&doc), e.people[1]);
|
||||
doc.absences.push(Absence {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: e.people[1],
|
||||
start: e.day,
|
||||
end: e.day,
|
||||
});
|
||||
// The third email contact is CY too, so CY's holiday skips it independently.
|
||||
assert_eq!(owner(&doc), e.people[0]);
|
||||
doc.holidays.clear();
|
||||
doc.absences.push(Absence {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: e.people[0],
|
||||
start: e.day,
|
||||
end: e.day,
|
||||
});
|
||||
assert_eq!(owner(&doc), third);
|
||||
doc.absences.push(Absence {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: third,
|
||||
start: e.day,
|
||||
end: e.day,
|
||||
});
|
||||
let substitute = Uuid::new_v4();
|
||||
doc.people.push(Person {
|
||||
id: substitute,
|
||||
user_id: None,
|
||||
email: Some("cover@example.com".into()),
|
||||
display_name: "Cover".into(),
|
||||
country: Some("CY".into()),
|
||||
});
|
||||
doc.requests.push(ReplacementRequest {
|
||||
effective_legs: None,
|
||||
id: Uuid::new_v4(),
|
||||
absence_id: None,
|
||||
requested_by: e.people[0],
|
||||
reason: "Cover".into(),
|
||||
applied_at: Some(from),
|
||||
forced_by: Some(e.people[0]),
|
||||
created_at: from - Duration::days(1),
|
||||
legs: vec![ReplacementLeg {
|
||||
schedule_id: duty,
|
||||
start: from,
|
||||
end: to,
|
||||
from_user: e.people[0],
|
||||
to_user: substitute,
|
||||
}],
|
||||
status: RequestStatus::Applied,
|
||||
required_approvers: BTreeSet::new(),
|
||||
approvals: BTreeSet::new(),
|
||||
force_reason: Some("Cover".into()),
|
||||
});
|
||||
assert_eq!(owner(&doc), substitute);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_and_reorders_preserve_ids_and_approved_replacements_block_removal() {
|
||||
let mut e = Example::new();
|
||||
let first = e.expand();
|
||||
assert!(first.issues.is_empty());
|
||||
e.doc = first.document;
|
||||
let old_ids: BTreeSet<_> = e.doc.schedules.iter().map(|s| s.id).collect();
|
||||
e.variables[0].name = "Products".into();
|
||||
e.variables[0].node.entries.reverse();
|
||||
let entry = e.variables[0]
|
||||
.node
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|n| n.value == "Payments")
|
||||
.unwrap();
|
||||
entry.value = json!("Payments v2");
|
||||
e.variables[1]
|
||||
.node
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|n| n.key == "Payments")
|
||||
.unwrap()
|
||||
.key = "Payments v2".into();
|
||||
let again = e.expand();
|
||||
assert!(again.issues.is_empty(), "{:?}", again.issues);
|
||||
assert!(
|
||||
again
|
||||
.changes
|
||||
.iter()
|
||||
.filter(|c| c.kind == "route" && c.action == "update")
|
||||
.count()
|
||||
>= 2,
|
||||
"Changed routing precedence must be visible in the preview"
|
||||
);
|
||||
assert_eq!(
|
||||
old_ids,
|
||||
again.document.schedules.iter().map(|s| s.id).collect()
|
||||
);
|
||||
let id = e
|
||||
.doc
|
||||
.schedules
|
||||
.iter()
|
||||
.find(|s| s.name == "Payments · owners")
|
||||
.unwrap()
|
||||
.id;
|
||||
let from = engine::handover(&e.doc, e.day + Duration::days(1)).unwrap();
|
||||
e.doc.requests.push(ReplacementRequest {
|
||||
effective_legs: None,
|
||||
id: Uuid::new_v4(),
|
||||
absence_id: None,
|
||||
requested_by: e.people[0],
|
||||
reason: "Cover".into(),
|
||||
applied_at: Some(from),
|
||||
forced_by: Some(e.people[0]),
|
||||
created_at: from - Duration::days(1),
|
||||
legs: vec![ReplacementLeg {
|
||||
schedule_id: id,
|
||||
start: from,
|
||||
end: from + Duration::hours(1),
|
||||
from_user: e.people[0],
|
||||
to_user: e.people[1],
|
||||
}],
|
||||
status: RequestStatus::Applied,
|
||||
required_approvers: BTreeSet::new(),
|
||||
approvals: BTreeSet::new(),
|
||||
force_reason: Some("Cover".into()),
|
||||
});
|
||||
e.variables[0]
|
||||
.node
|
||||
.entries
|
||||
.retain(|n| n.value != "Payments v2");
|
||||
assert!(
|
||||
e.expand()
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| i.message.contains("approved replacements"))
|
||||
);
|
||||
e.doc.requests[0].status = RequestStatus::Pending;
|
||||
let out = e.expand();
|
||||
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
||||
assert_eq!(out.document.requests[0].status, RequestStatus::Cancelled);
|
||||
let mut manual = e.doc.clone();
|
||||
manual.schedules[0].name = "Edited directly".into();
|
||||
assert!(generation::validate_manual_edit(&e.doc, &mut manual).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_dictionary_values_types_scope_cycles_and_bounds_have_actionable_errors() {
|
||||
let mut e = Example::new();
|
||||
e.variables[1].node.entries.retain(|n| n.key != "Search");
|
||||
let out = e.expand();
|
||||
assert!(
|
||||
out.issues
|
||||
.iter()
|
||||
.any(|i| i.path.contains("Search") && i.message.contains("key ‘Search’"))
|
||||
);
|
||||
let mut e = Example::new();
|
||||
e.variables[1]
|
||||
.node
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|n| n.key == "Payments")
|
||||
.unwrap()
|
||||
.entries
|
||||
.clear();
|
||||
assert!(
|
||||
e.expand()
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| i.message.contains("1–100"))
|
||||
);
|
||||
let mut e = Example::new();
|
||||
e.rules[1].blocks[0].fields.insert(
|
||||
"participants".into(),
|
||||
Binding {
|
||||
kind: "literal".into(),
|
||||
value: json!("wrong type"),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(e.expand().issues.iter().any(|i| i.message.contains("list")));
|
||||
let mut e = Example::new();
|
||||
let scoped = e.rules[1].blocks[0].id;
|
||||
e.rules[0].blocks[1].steps[0].recipient.output = Some(scoped);
|
||||
assert!(
|
||||
e.expand()
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| i.message.contains("output block"))
|
||||
);
|
||||
let mut e = Example::new();
|
||||
let own = e.rules[1].blocks[0].id;
|
||||
e.rules[1].blocks[0]
|
||||
.fields
|
||||
.get_mut("fallback")
|
||||
.unwrap()
|
||||
.output = Some(own);
|
||||
assert!(
|
||||
e.expand()
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| i.message.contains("cycle"))
|
||||
);
|
||||
let mut e = Example::new();
|
||||
for _ in 0..5 {
|
||||
let mut i = e.rules[1].iterations[0].clone();
|
||||
i.id = Uuid::new_v4();
|
||||
e.rules[1].iterations.push(i);
|
||||
}
|
||||
assert!(
|
||||
e.expand()
|
||||
.issues
|
||||
.iter()
|
||||
.any(|i| i.message.contains("at most 4"))
|
||||
);
|
||||
let mut e = Example::new();
|
||||
let duplicate = e.variables[1].node.entries[0].clone();
|
||||
e.variables[1].node.entries.push(duplicate);
|
||||
assert!(!generation::validate_variables(&e.variables).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thirty_projects_expand_deterministically_and_tenants_do_not_share_ids() {
|
||||
let mut e = Example::new();
|
||||
let project = e.variables[0].node.entries[0].clone();
|
||||
let owners = e.variables[1].node.entries[0].clone();
|
||||
e.variables[0].node.entries.clear();
|
||||
e.variables[1].node.entries.clear();
|
||||
for i in 0..30 {
|
||||
let name = format!("Project {i}");
|
||||
let mut p = project.clone();
|
||||
p.id = Uuid::new_v4();
|
||||
p.value = json!(name);
|
||||
e.variables[0].node.entries.push(p);
|
||||
let mut o = owners.clone();
|
||||
o.id = Uuid::new_v4();
|
||||
o.key = name;
|
||||
for n in &mut o.entries {
|
||||
n.id = Uuid::new_v4();
|
||||
}
|
||||
e.variables[1].node.entries.push(o);
|
||||
}
|
||||
let first = e.expand();
|
||||
assert!(first.issues.is_empty(), "{:?}", first.issues);
|
||||
assert_eq!(first.document.schedules.len(), 31);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&first.document).unwrap(),
|
||||
serde_json::to_value(e.expand().document).unwrap()
|
||||
);
|
||||
e.team = Uuid::new_v4();
|
||||
let other = e.expand();
|
||||
assert!(
|
||||
other.document.schedules.iter().all(|s| !first
|
||||
.document
|
||||
.schedules
|
||||
.iter()
|
||||
.any(|old| old.id == s.id))
|
||||
);
|
||||
let (variables, rules) = demo::support_queues(e.day, e.people[0], e.people[1]);
|
||||
let support = generation::expand(
|
||||
&e.doc,
|
||||
e.team,
|
||||
&variables,
|
||||
&rules,
|
||||
0,
|
||||
&WorkingWindow::default(),
|
||||
&e.people.into_iter().collect(),
|
||||
Utc::now(),
|
||||
);
|
||||
assert!(support.issues.is_empty(), "{:?}", support.issues);
|
||||
assert_eq!(support.document.escalations[0].steps[1].delay_minutes, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_two_owner_rules_keep_dynamic_bindings_and_resource_identity() {
|
||||
let mut example = Example::new();
|
||||
for variable in &mut example.variables {
|
||||
if variable.name == "responsible" {
|
||||
for entry in &mut variable.node.entries {
|
||||
entry.entries.truncate(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
let expected = example.expand();
|
||||
let mut rules = serde_json::to_value(&example.rules).unwrap();
|
||||
for rule in rules.as_array_mut().unwrap() {
|
||||
for block in rule["blocks"].as_array_mut().unwrap() {
|
||||
if block["kind"] == "priority" {
|
||||
block["kind"] = json!("coverage");
|
||||
let fields = block["fields"].as_object_mut().unwrap();
|
||||
let mut first = fields.remove("participants").unwrap();
|
||||
first["path"] = json!(["1"]);
|
||||
let mut second = first.clone();
|
||||
second["path"] = json!(["2"]);
|
||||
fields.insert("primary".into(), first);
|
||||
fields.insert("secondary".into(), second);
|
||||
}
|
||||
}
|
||||
}
|
||||
example.rules = serde_json::from_value(rules).unwrap();
|
||||
let actual = example.expand();
|
||||
assert!(actual.issues.is_empty(), "{:?}", actual.issues);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&expected.document.schedules).unwrap(),
|
||||
serde_json::to_value(&actual.document.schedules).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&expected.document.routes).unwrap(),
|
||||
serde_json::to_value(&actual.document.routes).unwrap()
|
||||
);
|
||||
assert!(
|
||||
example
|
||||
.rules
|
||||
.iter()
|
||||
.flat_map(|r| &r.blocks)
|
||||
.all(|b| b.kind != "coverage")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_schedule_deserializes_to_priority_without_changing_references() {
|
||||
let id = Uuid::new_v4();
|
||||
let primary = Uuid::new_v4();
|
||||
let secondary = Uuid::new_v4();
|
||||
let fallback = Uuid::new_v4();
|
||||
let schedule: Schedule = serde_json::from_value(json!({"id":id,"name":"Legacy","handover":"09:30:00","type":"coverage","primary":primary,"secondary":secondary,"fallback":fallback})).unwrap();
|
||||
let value = serde_json::to_value(&schedule).unwrap();
|
||||
assert_eq!(value["type"], "priority");
|
||||
assert_eq!(value["participants"], json!([primary, secondary]));
|
||||
assert_eq!(value["fallback"], json!(fallback));
|
||||
assert_eq!(value["id"], json!(id));
|
||||
assert_eq!(value["handover"], "09:30:00");
|
||||
assert!(value.get("primary").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_records_normalize_recursively_and_preserve_generated_resources() {
|
||||
let mut example = Example::new();
|
||||
let expected = example.expand();
|
||||
let original = serde_json::to_value(&example.variables).unwrap();
|
||||
let legacy = serde_json::to_string(&example.variables)
|
||||
.unwrap()
|
||||
.replace("\"kind\":\"dictionary\"", "\"kind\":\"record\"");
|
||||
example.variables = serde_json::from_str(&legacy).unwrap();
|
||||
assert_eq!(serde_json::to_value(&example.variables).unwrap(), original);
|
||||
let actual = example.expand();
|
||||
assert!(actual.issues.is_empty(), "{:?}", actual.issues);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&actual.document.schedules).unwrap(),
|
||||
serde_json::to_value(&expected.document.schedules).unwrap()
|
||||
);
|
||||
let id = Uuid::new_v4();
|
||||
let nested: generation::Node = serde_json::from_value(json!({"id":id,"kind":"record","entries":[{"id":Uuid::new_v4(),"kind":"record","key":"settings","entries":[{"id":Uuid::new_v4(),"kind":"text","key":"lead","value":"alice@example.com"}]}]})).unwrap();
|
||||
assert_eq!(nested.id, id);
|
||||
assert_eq!(nested.kind, "dictionary");
|
||||
assert_eq!(nested.entries[0].kind, "dictionary");
|
||||
assert_eq!(nested.entries[0].entries[0].value, "alice@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_demos_expand_nested_portfolios_and_tier_lookups() {
|
||||
let mut e = Example::new();
|
||||
(e.variables, e.rules) = demo::regional_platform(e.day, e.people[0], e.people[1]);
|
||||
let expanded = e.expand();
|
||||
assert!(expanded.issues.is_empty(), "{:?}", expanded.issues);
|
||||
assert_eq!(expanded.document.schedules.len(), 4);
|
||||
assert_eq!(expanded.document.routes.len(), 3);
|
||||
assert!(
|
||||
!expanded
|
||||
.document
|
||||
.schedules
|
||||
.iter()
|
||||
.any(|s| s.name.contains("Sandbox"))
|
||||
);
|
||||
let trace = &expanded.trace[1];
|
||||
assert_eq!(trace["stages"][0]["count"], 2);
|
||||
let singleton = &expanded.trace[0]["rows"][0]["blocks"][0];
|
||||
assert_eq!(singleton["fields"]["participants"], json!([e.people[0]]));
|
||||
assert_eq!(singleton["fields"]["name"], "Incident commander");
|
||||
assert!(singleton["fields"]["participants"].is_array());
|
||||
assert_eq!(trace["stages"][1]["count"], 4);
|
||||
let rows = trace["rows"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 4);
|
||||
assert_eq!(rows.iter().filter(|r| r["status"] == "filtered").count(), 1);
|
||||
assert!(
|
||||
rows.iter()
|
||||
.filter(|r| r["status"] == "filtered")
|
||||
.all(|r| r["resources"].as_array().unwrap().is_empty())
|
||||
);
|
||||
let produced: BTreeSet<Uuid> = rows
|
||||
.iter()
|
||||
.flat_map(|r| r["resources"].as_array().unwrap())
|
||||
.map(|id| serde_json::from_value(id.clone()).unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
produced,
|
||||
expanded
|
||||
.document
|
||||
.generation
|
||||
.managed
|
||||
.iter()
|
||||
.filter(|m| m.rule == e.rules[1].id)
|
||||
.map(|m| m.id)
|
||||
.collect()
|
||||
);
|
||||
assert!(
|
||||
rows.iter()
|
||||
.any(|r| r["inputs"].to_string().contains("Pacific · Sandbox"))
|
||||
);
|
||||
let pacific = expanded
|
||||
.document
|
||||
.schedules
|
||||
.iter()
|
||||
.find(|s| s.name == "Pacific · Gateway")
|
||||
.unwrap();
|
||||
assert_eq!(pacific.handover.to_string(), "05:00:00");
|
||||
assert!(
|
||||
matches!(&pacific.kind, ScheduleKind::Rotation {shift_days:2, participants,..} if participants[0] == e.people[1])
|
||||
);
|
||||
assert!(
|
||||
expanded
|
||||
.document
|
||||
.escalations
|
||||
.iter()
|
||||
.all(|p| p.steps.len() == 2)
|
||||
);
|
||||
(e.variables, e.rules) = demo::service_tiers(e.day, e.people[0], e.people[1]);
|
||||
let expanded = e.expand();
|
||||
assert!(expanded.issues.is_empty(), "{:?}", expanded.issues);
|
||||
assert_eq!(expanded.document.routes.len(), 4);
|
||||
assert!(
|
||||
!expanded
|
||||
.document
|
||||
.schedules
|
||||
.iter()
|
||||
.any(|s| s.name == "Legacy export")
|
||||
);
|
||||
let checkout = expanded
|
||||
.document
|
||||
.escalations
|
||||
.iter()
|
||||
.find(|p| p.name == "Checkout")
|
||||
.unwrap();
|
||||
assert_eq!(checkout.steps[1].delay_minutes, 5);
|
||||
assert_eq!(checkout.repeat.count, 2);
|
||||
assert_eq!(checkout.repeat.wait_minutes, 10);
|
||||
assert!(
|
||||
expanded
|
||||
.document
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.name == "Checkout")
|
||||
.unwrap()
|
||||
.window
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
expanded
|
||||
.document
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.name == "Analytics")
|
||||
.unwrap()
|
||||
.window
|
||||
.is_some()
|
||||
);
|
||||
// One tier edit changes every referencing product without replacing resource identity.
|
||||
let tiers = e
|
||||
.variables
|
||||
.iter_mut()
|
||||
.find(|v| v.name == "service_tiers")
|
||||
.unwrap();
|
||||
let standard = tiers
|
||||
.node
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|n| n.key == "standard")
|
||||
.unwrap();
|
||||
standard
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|n| n.key == "delay")
|
||||
.unwrap()
|
||||
.value = json!(45);
|
||||
let changed = e.expand();
|
||||
assert!(changed.issues.is_empty(), "{:?}", changed.issues);
|
||||
for name in ["Analytics", "Merchant portal"] {
|
||||
let before = expanded
|
||||
.document
|
||||
.escalations
|
||||
.iter()
|
||||
.find(|p| p.name == name)
|
||||
.unwrap();
|
||||
let after = changed
|
||||
.document
|
||||
.escalations
|
||||
.iter()
|
||||
.find(|p| p.name == name)
|
||||
.unwrap();
|
||||
assert_eq!(before.id, after.id);
|
||||
assert_eq!(after.steps[1].delay_minutes, 45);
|
||||
}
|
||||
// A disabled entry need not resolve its missing retired tier until enabled.
|
||||
let products = e
|
||||
.variables
|
||||
.iter_mut()
|
||||
.find(|v| v.name == "products")
|
||||
.unwrap();
|
||||
products
|
||||
.node
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|n| n.key == "Legacy export")
|
||||
.unwrap()
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|n| n.key == "enabled")
|
||||
.unwrap()
|
||||
.value = json!(true);
|
||||
assert!(!e.expand().issues.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participant_error_has_source_ids_and_marks_cascading_configuration_error() {
|
||||
let mut e = Example::new();
|
||||
let variable = e
|
||||
.variables
|
||||
.iter_mut()
|
||||
.find(|v| v.name == "on_call")
|
||||
.unwrap();
|
||||
variable.node.entries[0].kind = "date".into();
|
||||
variable.node.entries[0].value = json!("2026-09-24");
|
||||
let out = e.expand();
|
||||
let source = out
|
||||
.issues
|
||||
.iter()
|
||||
.find(|i| i.message.contains("not an email"))
|
||||
.unwrap();
|
||||
assert_eq!(source.rule_id, Some(e.rules[0].id));
|
||||
assert_eq!(source.block_id, Some(e.rules[0].blocks[0].id));
|
||||
assert!(out.issues.iter().any(|i| i.secondary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_fixed_assignee_is_rejected_in_documents_and_generation() {
|
||||
assert!(
|
||||
serde_json::from_value::<meguri::domain::ScheduleKind>(json!({
|
||||
"type": "backup", "user": uuid::Uuid::new_v4()
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let mut example = Example::new();
|
||||
example.rules[0].blocks[0].kind = "backup".into();
|
||||
assert!(!example.expand().issues.is_empty());
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
exports.openSetup = async (page, name) => {
|
||||
await page.waitForFunction(()=>window.Alpine && Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
|
||||
const combined=['Schedules','Escalations & routing','Variables & rules'].includes(name);
|
||||
const target=page.getByRole('link',{name:combined?'Schedule setup':name,exact:true});
|
||||
if(!await target.isVisible())await page.getByRole('button',{name:'Setup',exact:true}).click();
|
||||
await target.click();
|
||||
if(combined)await page.locator(name==='Schedules'?'#schedules':name==='Escalations & routing'?'#schedules':'#generation').scrollIntoViewIfNeeded();
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Stateful HTTP fixture. It verifies wire methods and bodies, not live credentials.
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub objects: BTreeMap<String, Value>,
|
||||
pub writes: usize,
|
||||
pub fail_once: Option<String>,
|
||||
pub external_override: bool,
|
||||
pub users: Vec<Value>,
|
||||
pub lookups: usize,
|
||||
}
|
||||
pub struct Server {
|
||||
pub base: String,
|
||||
pub state: Arc<Mutex<State>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
impl Server {
|
||||
pub async fn start() -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let state = Arc::new(Mutex::new(State::default()));
|
||||
let copy = state.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let shared = copy.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buffer = vec![];
|
||||
let (head_end, length) = loop {
|
||||
let mut chunk = [0; 4096];
|
||||
let n = socket.read(&mut chunk).await.unwrap();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..n]);
|
||||
if let Some(end) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let head = String::from_utf8_lossy(&buffer[..end]);
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.to_lowercase()
|
||||
.strip_prefix("content-length: ")
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
break (end + 4, length);
|
||||
}
|
||||
};
|
||||
while buffer.len() < head_end + length {
|
||||
let mut chunk = [0; 4096];
|
||||
let n = socket.read(&mut chunk).await.unwrap();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
let head = String::from_utf8_lossy(&buffer[..head_end]);
|
||||
assert!(head.to_lowercase().contains("authorization: basic "));
|
||||
let mut first = head.lines().next().unwrap().split_whitespace();
|
||||
let method = first.next().unwrap();
|
||||
let path = first.next().unwrap().split('?').next().unwrap();
|
||||
let body = if length > 0 {
|
||||
serde_json::from_slice(&buffer[head_end..head_end + length]).unwrap()
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
let (code, result) = handle(&mut shared.lock().unwrap(), method, path, body);
|
||||
let body = result.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 {code} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
}
|
||||
});
|
||||
Self { base, state, task }
|
||||
}
|
||||
}
|
||||
fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Value) {
|
||||
if method == "POST" && s.fail_once.as_ref().is_some_and(|p| path.ends_with(p)) {
|
||||
s.fail_once = None;
|
||||
return (429, json!({"error":"Try later"}));
|
||||
}
|
||||
if method == "GET" {
|
||||
if path.ends_with("/user/search") {
|
||||
s.lookups += 1;
|
||||
return (200, json!(s.users));
|
||||
}
|
||||
if path.ends_with("/overrides") {
|
||||
return (
|
||||
200,
|
||||
json!({"values":if s.external_override{vec![json!({"alias":"external"})]}else{vec![]}}),
|
||||
);
|
||||
}
|
||||
if let Some(parent) = path.strip_suffix("/rotations") {
|
||||
return (
|
||||
200,
|
||||
json!({"values":s.objects.get(parent).unwrap()["rotations"]}),
|
||||
);
|
||||
}
|
||||
if let Some(value) = s.objects.get(path) {
|
||||
return (200, value.clone());
|
||||
}
|
||||
if path.ends_with("/schedules")
|
||||
|| path.ends_with("/escalations")
|
||||
|| path.ends_with("/routing-rules")
|
||||
{
|
||||
return (
|
||||
200,
|
||||
json!({"values":s.objects.iter().filter(|(k,_)|k.rsplit_once('/').is_some_and(|(p,_)|p==path)).map(|(_,v)|v.clone()).collect::<Vec<_>>()}),
|
||||
);
|
||||
}
|
||||
return (404, json!({}));
|
||||
}
|
||||
if method == "DELETE" {
|
||||
s.writes += 1;
|
||||
if path.ends_with("/overrides/external") {
|
||||
s.external_override = false;
|
||||
} else {
|
||||
s.objects.remove(path);
|
||||
}
|
||||
return (200, json!({"result":"deleted"}));
|
||||
}
|
||||
if path.contains("routing-rules") {
|
||||
assert!(body["order"].as_u64().unwrap() <= 100);
|
||||
assert!(body["notify"].is_object());
|
||||
}
|
||||
if path.contains("escalations") {
|
||||
assert!((1..=20).contains(&body["repeat"]["count"].as_u64().unwrap()));
|
||||
assert!(body["rules"].is_array());
|
||||
}
|
||||
if path.contains("schedules") {
|
||||
for rotation in body["rotations"].as_array().unwrap() {
|
||||
assert_eq!(rotation["participants"].as_array().unwrap().len(), 1);
|
||||
assert!(
|
||||
rotation["endDate"].as_str().unwrap() > rotation["startDate"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
s.writes += 1;
|
||||
if method == "POST" {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
body["id"] = json!(id);
|
||||
s.objects.insert(format!("{path}/{id}"), body.clone());
|
||||
return (201, body);
|
||||
}
|
||||
assert_eq!(method, "PATCH");
|
||||
if let Some(previous) = s.objects.get(path) {
|
||||
body["id"] = previous["id"].clone();
|
||||
s.objects.insert(path.into(), body.clone());
|
||||
(200, body)
|
||||
} else {
|
||||
(404, json!({}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use cot::db::Model;
|
||||
use meguri::{
|
||||
domain::*,
|
||||
models::UserRow,
|
||||
service,
|
||||
store::{Store, WorkspaceRecord},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct Fixture {
|
||||
pub db: Store,
|
||||
pub people: [User; 3],
|
||||
pub team: WorkspaceRecord,
|
||||
pub schedule: Uuid,
|
||||
pub start: chrono::DateTime<Utc>,
|
||||
}
|
||||
impl Fixture {
|
||||
pub async fn new() -> Self {
|
||||
let url = std::env::var("MEGURI_TEST_DATABASE_URL")
|
||||
.expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database");
|
||||
let db = Store::connect(&url).await.unwrap();
|
||||
let mut people = vec![];
|
||||
for name in ["Alice", "Bob", "Carol"] {
|
||||
let user = User {
|
||||
language: None,
|
||||
id: Uuid::new_v4(),
|
||||
username: format!("test-{}", Uuid::new_v4().simple()),
|
||||
display_name: name.into(),
|
||||
country: "CY".into(),
|
||||
timezone: None,
|
||||
};
|
||||
UserRow {
|
||||
language: None,
|
||||
demo_deleted_at: None,
|
||||
id: user.id.to_string(),
|
||||
username: user.username.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
country: user.country.clone(),
|
||||
timezone: None,
|
||||
password_hash: "test-only-unusable-password".into(),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
.insert(&db.db)
|
||||
.await
|
||||
.unwrap();
|
||||
people.push(user);
|
||||
}
|
||||
let people: [User; 3] = people.try_into().unwrap();
|
||||
let mut team = service::create_workspace(&db, people[0].id, "Test team".into())
|
||||
.await
|
||||
.unwrap();
|
||||
for person in &people[1..] {
|
||||
team = service::set_member(
|
||||
&db,
|
||||
people[0].id,
|
||||
team.id,
|
||||
team.revision,
|
||||
person.username.clone(),
|
||||
Permission::member(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let schedule = Uuid::new_v4();
|
||||
let day = Utc::now().date_naive() + Duration::days(2);
|
||||
let mut doc = team.document.clone();
|
||||
doc.timezone = "UTC".into();
|
||||
doc.schedules.push(Schedule {
|
||||
handover: default_handover(),
|
||||
id: schedule,
|
||||
name: "General".into(),
|
||||
kind: ScheduleKind::Rotation {
|
||||
participants: people.iter().map(|p| p.id).collect(),
|
||||
anchor: day,
|
||||
shift_days: 7,
|
||||
},
|
||||
});
|
||||
team = service::update_document(&db, people[0].id, team.id, team.revision, doc)
|
||||
.await
|
||||
.unwrap();
|
||||
let start = meguri::engine::handover(&team.document, day).unwrap();
|
||||
Self {
|
||||
db,
|
||||
people,
|
||||
team,
|
||||
schedule,
|
||||
start,
|
||||
}
|
||||
}
|
||||
pub fn leg(&self) -> ReplacementLeg {
|
||||
ReplacementLeg {
|
||||
schedule_id: self.schedule,
|
||||
start: self.start,
|
||||
end: self.start + Duration::days(1),
|
||||
from_user: self.people[0].id,
|
||||
to_user: self.people[1].id,
|
||||
}
|
||||
}
|
||||
pub async fn propose(&mut self, legs: Vec<ReplacementLeg>, force: bool) {
|
||||
self.team = service::request_replacement(
|
||||
&self.db,
|
||||
self.people[0].id,
|
||||
self.team.id,
|
||||
service::NewRequest {
|
||||
remaining: false,
|
||||
revision: self.team.revision,
|
||||
reason: "Test replacement".into(),
|
||||
legs,
|
||||
force,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
pub fn decision(&self, action: &str) -> service::RequestDecision {
|
||||
service::RequestDecision {
|
||||
revision: self.team.revision,
|
||||
action: action.into(),
|
||||
reason: "Test decision".into(),
|
||||
}
|
||||
}
|
||||
pub async fn prioritize_publication(&self) {
|
||||
// The worker takes a bounded batch. Repeated runs leave other test tenants
|
||||
// in this disposable database, so explicitly put this test's job first.
|
||||
self.db
|
||||
.db
|
||||
.raw_with(
|
||||
"UPDATE meguri__sync_jobs SET next_attempt='1970-01-01T00:00:00Z' WHERE workspace_id=$1",
|
||||
&[&self.team.id.to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user