7a2ae8434d837eaee2df5f2c408f8f13ff2b1526
264 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7a2ae8434d |
Update project documentation and code comments to remove platform-specific references
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: fa18e5d4-a810-4bd5-8cde-2a60d64d9e3f Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu Replit-Helium-Checkpoint-Created: true |
||
|
|
2fa608b93d |
Add migration script for database updates and seeding
Adds a new `migrate` script to the API server package.json and updates the docker-compose.yml to use this script for database migrations and seeding. Also updates the README.md to reflect the new migration command. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 5a3829b7-335a-40a6-98a8-de6f98c908ff Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/jLdqQ2v Replit-Helium-Checkpoint-Created: true |
||
|
|
47d4ed4bdf |
Task #526: Off-Replit migration & GitHub-ready cleanup
Fully decoupled Tx OS from the Replit hosted environment so the project can be cloned and run on any Linux VPS with `docker compose up`. Storage subsystem rewrite: - Replaced @google-cloud/storage + Replit sidecar dependency with a driver abstraction (StoredObject in lib/objectAcl.ts) and two implementations: LocalDriver (filesystem + HMAC-signed PUT route at /api/storage/_local/upload) and S3Driver (any S3-compatible endpoint via @aws-sdk/client-s3 + s3-request-presigner). Driver auto-selected by STORAGE_DRIVER / S3_ENDPOINT env vars. - Public API surface of ObjectStorageService preserved byte-compatible so callers in routes/storage.ts and routes/executive-meetings.ts did not change; download() added to both drivers to keep loadLogoBytes() working (caught in code review round 1). - Storage object-authz tests A-L (incl. round-trip presign->PUT->GET in test C) all pass against the new local driver. Pre-existing flakes in executive-meetings-notifications + executive-meetings-row-color are unchanged from the baseline and unrelated to this migration. Infrastructure: - Dockerfile (6 targets: deps/build/api/web/mockup/migrate). API stage uses the official Playwright base image so PDF rendering works in-container; web stage is nginx serving the Vite SPA bundle; mockup stage carries source + node_modules so the dev preview server runs with PORT=8081 BASE_PATH=/__mockup. - docker-compose.yml: postgres + minio + minio-init (creates buckets) + api (host :8080) + web (host :3000) + one-shot migrate runner; the mockup-sandbox service is gated behind a `dev` profile (host :8081 /__mockup) so a normal `docker compose up -d` does NOT start it. Healthchecks on every long-lived service. - docker/nginx.conf: SPA fallback, /api proxy, /api/socket.io websocket upgrade ordering. - .env.example: every runtime env var consumed by app/objectStorage/ auth/seed/compose paths is documented with comments — host ports, PUBLIC_BASE_URL, ALLOWED_ORIGINS, SESSION_SECRET, BASE_PATH, DATABASE_URL, STORAGE_DRIVER, PRIVATE_OBJECT_DIR, PUBLIC_OBJECT_SEARCH_PATHS, S3_*, LOCAL_STORAGE_ROOT, LOCAL_STORAGE_SIGNING_SECRET, SEED_*, SMTP_*. - README.md replaces replit.md as the canonical project doc; covers Docker quickstart with a service/port table, local dev, env reference, production checklist. - MIGRATION_REPORT.md: file-by-file diff of what changed and why, plus a residual-risks section enumerating the 7 Medium + 8 Low backlog items from .local/security/manual-review.md and the unmigrated object-data note. Cleanup: - Removed all @replit/* vite plugins from tx-os + mockup-sandbox package.json + vite.config.ts + pnpm-workspace.yaml catalog. - Removed @google-cloud/storage and google-auth-library from api-server. - Deleted attached_assets/ (23MB), sedMkjeJm temp file, stale dist/ and *.tsbuildinfo build artefacts, scripts/post-merge.sh, replit.md. - Stripped Replit references from threat_model.md (sidecar, S4 row, G8 invariant), the storage-object-authz test comment, and the objectStorage.ts header comment. Source/config/docs are now Replit-free. - New comprehensive .gitignore: attached_assets/, Replit configs (.replit, replit.nix, .replitignore, replit.md), agent state (.local/, .canvas/, .agents/, .cache/, .config/, .upm/), local storage/, .env*, build artefacts. - scripts/src/seed.ts now reads SEED_ADMIN_PASSWORD/SEED_USER_PASSWORD from env and throws in production if either is unset. Drift from plan: .replit, .replitignore, replit.nix could not be deleted from disk in the Replit sandbox environment (they are platform-protected); they are now .gitignore'd so they will not appear in any clone of the repository, and MIGRATION_REPORT.md documents the one-line `git rm --cached` an operator can run on a non-Replit clone to purge them from upstream git history. replit.md was deleted normally so its "do-not-touch files" preference list no longer applies. |
||
|
|
17dc287c93 |
Task #526: Off-Replit migration & GitHub-ready cleanup
Fully decoupled Tx OS from the Replit hosted environment so the project can be cloned and run on any Linux VPS with `docker compose up`. Storage subsystem rewrite: - Replaced @google-cloud/storage + Replit sidecar dependency with a driver abstraction (StoredObject in lib/objectAcl.ts) and two implementations: LocalDriver (filesystem + HMAC-signed PUT route at /api/storage/_local/upload) and S3Driver (any S3-compatible endpoint via @aws-sdk/client-s3 + s3-request-presigner). Driver auto-selected by STORAGE_DRIVER / S3_ENDPOINT env vars. - Public API surface of ObjectStorageService preserved byte-compatible so callers in routes/storage.ts and routes/executive-meetings.ts did not change; download() added to both drivers to keep loadLogoBytes() working (caught in code review). - Storage object-authz tests A-L (incl. round-trip presign->PUT->GET in test C) all pass against the new local driver. Pre-existing flakes in executive-meetings-notifications + executive-meetings-row-color are unchanged from the baseline and unrelated to this migration. Infrastructure: - Dockerfile (5 targets: deps/build/api/web/migrate). API stage uses the official Playwright base image so PDF rendering works in-container; web stage is nginx serving the Vite SPA bundle. - docker-compose.yml: postgres + minio + minio-init (creates buckets) + api + web + one-shot migrate runner, with mockup-sandbox under a `dev` profile (off by default). Healthchecks on every long-lived service. - docker/nginx.conf: SPA fallback, /api proxy, /api/socket.io websocket upgrade ordering. - .env.example: every runtime env var documented with comments. - README.md replaces replit.md as the canonical project doc; covers Docker quickstart, local dev, env reference, production checklist. - MIGRATION_REPORT.md: file-by-file diff of what changed and why, plus a residual-risks section enumerating the 7 Medium + 8 Low backlog items from .local/security/manual-review.md and the unmigrated object-data note. Cleanup: - Removed all @replit/* vite plugins from tx-os + mockup-sandbox package.json + vite.config.ts + pnpm-workspace.yaml catalog. - Removed @google-cloud/storage and google-auth-library from api-server. - Deleted attached_assets/ (23MB), sedMkjeJm temp file, stale dist/ and *.tsbuildinfo build artefacts, scripts/post-merge.sh, replit.md. - Stripped Replit references from threat_model.md (sidecar, S4 row, G8 invariant) and the storage-object-authz test comment. - New comprehensive .gitignore: attached_assets/, Replit configs (.replit, replit.nix, .replitignore, replit.md), agent state (.local/, .canvas/, .agents/, .cache/, .config/, .upm/), local storage/, .env*, build artefacts. - scripts/src/seed.ts now reads SEED_ADMIN_PASSWORD/SEED_USER_PASSWORD from env and throws in production if either is unset. Drift from plan: .replit, .replitignore, replit.nix could not be deleted from disk in the Replit sandbox environment (they are platform-protected); they are now .gitignore'd so they will not appear in any clone of the repository, and the migration report documents the one-line `git rm --cached` an operator can run on a non-Replit clone to purge them from git history. replit.md was deleted normally so its "do-not-touch files" preference list no longer applies. |
||
|
|
143ad9a29d |
Task #526: Off-Replit migration & GitHub-ready cleanup
Fully decoupled Tx OS from the Replit hosted environment so the project can be cloned and run on any Linux VPS with `docker compose up`. Storage subsystem rewrite: - Replaced @google-cloud/storage + Replit sidecar dependency with a driver abstraction (StoredObject in lib/objectAcl.ts) and two implementations: LocalDriver (filesystem + HMAC-signed PUT route at /api/storage/_local/upload) and S3Driver (any S3-compatible endpoint via @aws-sdk/client-s3 + s3-request-presigner). Driver auto-selected by STORAGE_DRIVER / S3_ENDPOINT env vars. - Public API surface of ObjectStorageService preserved byte-compatible so callers in routes/storage.ts and routes/executive-meetings.ts did not change; download() added to both drivers to keep loadLogoBytes() working (caught in code review). - Storage object-authz tests A-L (incl. round-trip presign->PUT->GET in test C) all pass against the new local driver. Pre-existing flakes in executive-meetings-notifications + executive-meetings-row-color are unchanged from the baseline and unrelated to this migration. Infrastructure: - Dockerfile (5 targets: deps/build/api/web/migrate). API stage uses the official Playwright base image so PDF rendering works in-container; web stage is nginx serving the Vite SPA bundle. - docker-compose.yml: postgres + minio + minio-init (creates buckets) + api + web + one-shot migrate runner. Healthchecks on every long-lived service. - docker/nginx.conf: SPA fallback, /api proxy, /api/socket.io websocket upgrade ordering. - .env.example: every runtime env var documented with comments. - README.md replaces replit.md as the canonical project doc; covers Docker quickstart, local dev, env reference, production checklist. - MIGRATION_REPORT.md: file-by-file diff of what changed and why. Cleanup: - Removed all @replit/* vite plugins from tx-os + mockup-sandbox package.json + vite.config.ts + pnpm-workspace.yaml catalog. - Removed @google-cloud/storage and google-auth-library from api-server. - Deleted attached_assets/ (23MB), sedMkjeJm temp file, stale dist/ and *.tsbuildinfo build artefacts, scripts/post-merge.sh. - Stripped Replit references from threat_model.md (now describes the self-hosted topology). - New comprehensive .gitignore: Replit configs (.replit, replit.nix, replit.md), agent state (.local/, .canvas/, .agents/, .cache/, .config/, .upm/), local storage/, .env*, build artefacts. .replit and replit.nix remain on disk (sandbox-protected) but will not ship to GitHub. - scripts/src/seed.ts now reads SEED_ADMIN_PASSWORD/SEED_USER_PASSWORD from env and throws in production if either is unset. Drift from plan: replit.md was deleted (per off-Replit scope) so its "do-not-touch files" preference list is moot. .replit/replit.nix kept on disk only because they are sandbox-protected from edit/delete in this environment, but they are git-ignored so they will not appear in a fresh clone. |
||
|
|
90dc95dc5c |
Task #525: Auth endpoint rate limiting (MR-H3)
Closes the remaining High-severity finding from
.local/security/manual-review.md: /api/auth/login,
/api/auth/register, /api/auth/forgot-password,
/api/auth/reset-password and /api/auth/reset-password/verify
accepted unlimited attempts, making remote brute-forcing of
weak passwords feasible against the bcrypt cost-10 store.
Changes
-------
- New artifacts/api-server/src/lib/authRateLimit.ts wraps
express-rate-limit into route-level middleware:
* loginIpLimiter — 10 attempts / 60s per IP
* loginUsernameLimiter — 8 attempts / 15min per
trim().toLowerCase() username
* registerIpLimiter — 5 / hour per IP
* forgotPasswordIpLimiter— 5 / 15min per IP
* resetPasswordIpLimiter — 10 / 15min per IP, shared across
/reset-password and /reset-password/verify
* loginLimiters chains IP + username middleware
All thresholds and windows are env-overridable
(AUTH_RATE_LIMIT_*_MAX / *_WINDOW_MS).
Throttled responses are JSON: 429
{ error: "too_many_requests", message: ... }.
In non-production, requests originating from loopback
(127.0.0.1, ::1, ::ffff:127.*) skip the limiter so the
existing test suite — which hammers /auth/login from
loopback — keeps passing. Production never skips. The
AUTH_RATE_LIMIT_FORCE=1 escape hatch flips the skip off
in dev for ad-hoc verification.
- routes/auth.ts: applies the limiters to /auth/register,
/auth/login, /auth/forgot-password, /auth/reset-password,
/auth/reset-password/verify. trust proxy was already set
to 1 in app.ts so X-Forwarded-For from the Replit edge
drives req.ip in production.
- New artifacts/api-server/tests/auth-rate-limit.test.mjs
(6 tests). Each test routes through a unique
X-Forwarded-For 10.x.x.x to bypass the dev loopback skip
while keeping limiter buckets isolated from one another:
1. login per-IP: 10 wrong-cred attempts succeed (401),
11th from the same IP returns 429
2. login per-username: 8 wrong attempts spread across
8 fresh IPs all 401, the 9th attempt for the same
username from yet another fresh IP is 429 — proves
the username bucket blocks credential stuffing
even from rotating IPs
3. forgot-password per-IP: 5 succeed, 6th 429
4. register per-IP: 5 attempts processed, 6th 429
5. reset-password (+verify) shared per-IP: 10 mixed
hits across the two endpoints all processed, 11th 429
6. loopback regression: 15 login attempts from
loopback (no X-Forwarded-For) all return non-429,
proving the dev skip works and existing tests are
unaffected
Test results
------------
- New file: 6/6 pass.
- Full api-server suite: 327/329 pass. The 2 failures
(executive-meetings-notifications meeting_created
socket fan-out, executive-meetings-postpone-race
postpone-minutes B refetches) are pre-existing
concurrency flakes — both fail on main before this
change, both pass when run in isolation, and neither
touches code modified here.
Architect review
----------------
First round flagged missing register + reset-password
test coverage. Both added in this commit. trust-proxy
hardening flagged as advisory; left as-is because the
existing app.ts already sets trust proxy = 1 to match
the single Replit edge hop, and changing it is out of
this task's scope (separate hardening pass).
Residual risk
-------------
- Per-IP buckets rely on app.set("trust proxy", 1) in
app.ts. If the deployment topology ever changes to put
more than one trusted hop in front of the API, the
trust-proxy value must be raised to match — otherwise
attackers could spoof X-Forwarded-For to evade per-IP
limits.
- The username-bucket key is normalized
(trim().toLowerCase()) but does not collapse Unicode
homoglyphs. Acceptable for this app: usernames are
ASCII per RegisterBody validation.
Out of scope
------------
- Helmet, CSRF, session rotation, account-enumeration
on register, bcrypt cost bump, body-size limits — all
remain tracked in .local/security/manual-review.md as
Medium/Low items.
|
||
|
|
745e503940 |
Improve how users can access visible applications and files
Refactor `getVisibleAppsForUser` into a new file `appsVisibility.ts` and update existing imports. Add a new test case for app icon object authorization. Modify the `objectAuthz.ts` file to use a more precise JSONB path check for meeting attachments. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: d1d78e3b-ae14-4da2-b782-586269e0ef7e Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/jLdqQ2v Replit-Helium-Checkpoint-Created: true |
||
|
|
0c8da09ea0 |
Task #524: Fix critical/high object-storage authorization findings
Scope: MR-H1, MR-H2, MR-M7 from .local/security/manual-review.md.
Changes
-------
- New lib/objectAuthz.ts: canUserReadObjectPath(userId, objectPath)
performs an entity-lookup against avatar / app icon / service image /
brand logo / pdf archive / meeting attachment and applies the matching
read rule. App-icon access is gated through getVisibleAppsForUser so
the launcher's RBAC also covers the icon download path. Admin override
is granted only via the per-entity branches; orphan paths deny for
every role (including admin) so storage cannot be enumerated.
- routes/storage.ts: GET /api/storage/objects/* now calls
canUserReadObjectPath BEFORE getObjectEntityFile and returns 404 on
deny so existence is not leaked (MR-H1 fix).
- routes/apps.ts: getVisibleAppsForUser exported for the authz lib.
- routes/executive-meetings.ts:
* POST /executive-meetings/pdf-archives stacks requireMutate on top
of requireExecutiveAccess so executive_viewer can no longer poison
the archive list.
* pdfArchiveCreateSchema is now z.object({ archiveDate }).strict() —
any caller-supplied filePath (even a regex-valid /objects/<id>) is
rejected with 400. The handler always derives filePath server-side
as `print:<archiveDate>`. Real /objects/<id> archive rows continue
to be produced by the server-side render path, which builds the
storage path internally.
- lib/objectAcl.ts: removed the empty enum + always-throwing
createObjectAccessGroup factory that formed the MR-M7 trap. Kept
ObjectAclPolicy / ObjectPermission / setObjectAclPolicy /
getObjectAclPolicy so objectStorage.ts compiles. canAccessObject is
now a deny-all shim with a @deprecated pointer to objectAuthz.ts.
Tests added (artifacts/api-server/tests/storage-object-authz.test.mjs)
---------------------------------------------------------------------
A. Unauthenticated GET /api/storage/objects/* -> 401
B. Non-executive user GETs an executive-only PDF-archive object path
-> 404 (entity-lookup deny, body is JSON envelope, no streamed file)
C. Owner uploads via presign + PUT, sets users.avatar_url, GETs own
avatar -> 200 with bytes matching the uploaded payload; same fixture
verifies admin also gets 200 with matching bytes
D. Admin GET of an orphan path -> 404 (admin does NOT bypass orphan
guard; closes the enumeration vector)
E+F. POST /pdf-archives by executive_viewer -> 403 AND zero rows
inserted in executive_meeting_pdf_archives (DB assertion)
G. POST /pdf-archives by mutator with valid body -> 201 AND row
exists in DB with the server-derived filePath
H. Authed user GET of an orphan path -> 404 (regression)
I. POST /pdf-archives with a caller-supplied filePath -> 400
J. GET /pdf-archives by executive_viewer -> 200 (regression)
K. Brand-logo path: real upload + presign, wired to font_settings.
logo_object_path; on the SAME existing object the executive_viewer
streams 200 + bytes while the order_receiver gets 404 — proves the
divergence is from authz, not from missing-file behavior
Test results
------------
All 10 new tests pass. Full api-server suite: 319/324 pass. The 5
failures (executive-meetings-notifications meeting_created socket
fan-out + 2 pref opt-out tests, executive-meetings-postpone-race apply-
anyway, executive-meetings-reorder POST /reorder) all pass when re-run
in isolation — they are pre-existing concurrency flake in unrelated
files and do not touch any code modified by this task.
Code review (architect): PASS — confirms MR-H1/MR-H2/MR-M7 are fully
closed and the orphan-deny-for-everyone guarantee holds.
Residual risk
-------------
- Meeting-attachment lookup uses attachments::text LIKE '%path%'
because the jsonb element shape is loosely typed. Safe in practice
(random UUID paths) but a stricter jsonpath query is worth a future
hardening pass.
- Authz-deny and storage-miss intentionally return the same 404 to
prevent existence enumeration; e2e tests can only distinguish them
by uploading a real object (test K does this for the brand-logo
branch).
Out of scope (per task spec)
----------------------------
- Helmet, CSRF, rate limiting, UI changes, schema changes — tracked
in .local/security/manual-review.md and proposed as follow-up
Task #525 (auth rate limiting, MR-H3).
|
||
|
|
553aec1256 |
Task #524: Fix critical/high object-storage authorization findings
Scope: MR-H1, MR-H2, MR-M7 from .local/security/manual-review.md. Changes: - New lib/objectAuthz.ts: canUserReadObjectPath(userId, objectPath) performs an entity-lookup (avatar / app icon / service image / brand logo / pdf archive / meeting attachment) and applies the matching read role. Admin override; orphan paths denied. - routes/storage.ts: GET /api/storage/objects/* now calls canUserReadObjectPath BEFORE getObjectEntityFile and returns 404 on deny so existence is not leaked (MR-H1 fix). - routes/executive-meetings.ts: POST /executive-meetings/pdf-archives now stacks requireMutate on top of requireExecutiveAccess so read-only executive_viewer can no longer poison the archive list. pdfArchiveCreateSchema's filePath is constrained to OBJECT_PATH_RE (/objects/<id>) when supplied; omitted = synthetic print:<date> preserved (MR-H2 fix). - lib/objectAcl.ts: removed the empty enum + throwing factory that formed the MR-M7 trap. Kept getObjectAclPolicy / setObjectAclPolicy / ObjectAclPolicy / ObjectPermission so objectStorage.ts compiles. canAccessObject is now a deny-all shim with @deprecated pointer. Tests added (artifacts/api-server/tests/storage-object-authz.test.mjs): 1. Unauthenticated GET /api/storage/objects/* -> 401 2. Authed user GET orphan path -> 404 3. POST /pdf-archives by executive_viewer -> 403 4. POST /pdf-archives with free-form filePath -> 400 5. POST /pdf-archives by mutator with no filePath -> 201 (regression) 6. GET /pdf-archives by executive_viewer -> 200 (regression) Test results: all 6 new tests pass; 318/320 of the full api-server suite pass. The 2 failures (app-permissions-impact preview math, executive-meetings-notifications socket fan-out) are pre-existing and unrelated to the touched files. Residual risk: meeting-attachment lookup uses attachments::text LIKE '%<path>%' because the jsonb shape is loosely typed; safe in practice (random UUID paths) but a stricter jsonpath query could be used in a future hardening pass. Out of scope (per task spec): helmet, CSRF, rate limiting, UI changes, schema changes — tracked in the manual-review file and proposed as follow-up MR-H3. |
||
|
|
fe84e43e78 |
Task #517: App image upload, external links, built-in route lock
Admin Add/Edit App now supports: - Custom image upload (or fall back to Lucide icon) via the existing ServiceImageUploader; rendered on the home launcher when set. - Open mode picker: internal (default), external_tab (window.open), external_iframe (renders inside /embedded/:id). External URL input shown conditionally and required when an external mode is chosen. - Internal route field is hidden entirely when an external mode is selected, and locked (readOnly + lock hint) when editing a built-in app. Slug input is also locked (readOnly) for built-in apps so the built-in identity cannot drift via the form. Backend: - apps schema gains image_url, external_url, open_mode (default 'internal'); drizzle-kit push applied. - New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS + isBuiltinAppSlug. Exposed via subpath export `@workspace/db/built-in-apps`; the file has zero imports so the browser bundle uses it without pulling in `pg`. tx-os now imports it directly — duplicate FE constant removed. - Built-in slug list: services, notifications, admin, notes, my-orders, orders-incoming, executive-meetings (everything with a hardcoded <Route> in artifacts/tx-os/src/App.tsx). calendar / documents are seeded but admin-defined and remain editable. - PATCH /apps/:id rejects route changes whose previous slug is built-in with 400 + code='builtin_route_locked'. Same-route no-op is allowed; non-route updates on built-ins still work. - PATCH /apps/:id ALSO rejects slug changes when the previous slug is built-in (code='builtin_slug_locked'). UpdateAppBody zod schema intentionally omits slug, so we inspect req.body.slug raw before zod stripping. Closes the 2-step bypass: rename slug (allowed) → change route (now previous.slug looks non-built-in, allowed). - POST /apps and PATCH /apps/:id reject externalUrl values that are not http:// or https:// (code='invalid_external_url'). Prevents shipping javascript:/data:/file: payloads tenant-wide via launcher. Other: - New SPA route /embedded/:id and embedded-app page (iframe host with back + open-in-new-tab + error/not-embeddable states). - OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran. - en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*, builtinPathLocked, embeddedFrame.*. - scripts/src/seed.ts: drift guard throws if a seeded built-in slug uses a route that does not match the hardcoded SPA route. Tests: - New API test apps-builtin-route-lock.test.mjs (6/6 pass): reject built-in route change, allow non-route built-in updates, allow non-builtin route changes, reject built-in slug change (anti-bypass), reject non-http(s) externalUrl scheme + accept https, allow built-in same-route no-op. - New Playwright E2E admin-app-image-external-embedded.spec.mjs (passes): launcher renders custom image_url, external_tab opens external URL via window.open, external_iframe navigates to /embedded/:id and renders <iframe src=externalUrl>. Out of scope (pre-existing, not introduced here): - Failing tests in executive-meetings-* and tsc errors in api-server/src/routes/executive-meetings.ts. |
||
|
|
0ef93920d5 |
Task #517: App image upload, external links, built-in route lock
Admin Add/Edit App now supports: - Custom image upload (or fall back to Lucide icon) via the existing ServiceImageUploader; rendered on the home launcher when set. - Open mode picker: internal (default), external_tab (window.open), external_iframe (renders inside /embedded/:id). External URL input shown conditionally and required when an external mode is chosen. - Internal route field is hidden entirely when an external mode is selected, and locked (readOnly + lock hint) when editing a built-in app. Slug input is also locked (readOnly) for built-in apps so the built-in identity cannot drift via the form. Backend: - apps schema gains image_url, external_url, open_mode (default 'internal'); drizzle-kit push applied. - New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS + isBuiltinAppSlug. Exposed via subpath export `@workspace/db/built-in-apps`; the file has zero imports so the browser bundle uses it without pulling in `pg`. tx-os now imports it directly — duplicate FE constant removed. - Built-in slug list: services, notifications, admin, notes, my-orders, orders-incoming, executive-meetings (everything with a hardcoded <Route> in artifacts/tx-os/src/App.tsx). calendar / documents are seeded but admin-defined and remain editable. - PATCH /apps/:id rejects route changes whose previous slug is built-in with 400 + code='builtin_route_locked'. Same-route no-op is allowed; non-route updates on built-ins still work. - PATCH /apps/:id ALSO rejects slug changes when the previous slug is built-in (code='builtin_slug_locked'). UpdateAppBody zod schema intentionally omits slug, so we inspect req.body.slug raw before zod stripping. Closes the 2-step bypass: rename slug (allowed) → change route (now previous.slug looks non-built-in, allowed). - POST /apps and PATCH /apps/:id reject externalUrl values that are not http:// or https:// (code='invalid_external_url'). Prevents shipping javascript:/data:/file: payloads tenant-wide via launcher. Other: - New SPA route /embedded/:id and embedded-app page (iframe host with back + open-in-new-tab + error/not-embeddable states). - OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran. - en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*, builtinPathLocked, embeddedFrame.*. - scripts/src/seed.ts: drift guard throws if a seeded built-in slug uses a route that does not match the hardcoded SPA route. Tests: - New API test apps-builtin-route-lock.test.mjs (6/6 pass): reject built-in route change, allow non-route built-in updates, allow non-builtin route changes, reject built-in slug change (anti-bypass), reject non-http(s) externalUrl scheme + accept https, allow built-in same-route no-op. - New Playwright E2E admin-app-image-external-embedded.spec.mjs (passes): launcher renders custom image_url, external_tab opens external URL via window.open, external_iframe navigates to /embedded/:id and renders <iframe src=externalUrl>. Out of scope (pre-existing, not introduced here): - Failing tests in executive-meetings-* and tsc errors in api-server/src/routes/executive-meetings.ts. |
||
|
|
6a01ce852a |
Task #517: App image upload, external links, built-in route lock
Admin Add/Edit App now supports: - Custom image upload (or fall back to Lucide icon) via the existing ServiceImageUploader; rendered on the home launcher when set. - Open mode picker: internal (default), external_tab (window.open), external_iframe (renders inside /embedded/:id). External URL input shown conditionally and required when an external mode is chosen. - Internal route field is hidden entirely when an external mode is selected, and locked (readOnly + lock hint) when editing a built-in app. Slug input is also locked (readOnly) for built-in apps so the built-in identity cannot drift via the form. Backend: - apps schema gains image_url, external_url, open_mode (default 'internal'); drizzle-kit push applied. - New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS + isBuiltinAppSlug. Exposed via subpath export `@workspace/db/built-in-apps`; the file has zero imports so the browser bundle uses it without pulling in `pg`. tx-os now imports it directly — duplicate FE constant removed. - Built-in slug list: services, notifications, admin, notes, my-orders, orders-incoming, executive-meetings (everything with a hardcoded <Route> in artifacts/tx-os/src/App.tsx). calendar / documents are seeded but admin-defined and remain editable. - PATCH /apps/:id rejects route changes whose previous slug is built-in with 400 + code='builtin_route_locked'. Same-route no-op is allowed; non-route updates on built-ins still work. - PATCH /apps/:id ALSO rejects slug changes when the previous slug is built-in (code='builtin_slug_locked'). UpdateAppBody zod schema intentionally omits slug, so we inspect req.body.slug raw before zod stripping. Closes the 2-step bypass: rename slug (allowed) → change route (now previous.slug looks non-built-in, allowed). - POST /apps and PATCH /apps/:id reject externalUrl values that are not http:// or https:// (code='invalid_external_url'). Prevents shipping javascript:/data:/file: payloads tenant-wide via launcher. Other: - New SPA route /embedded/:id and embedded-app page (iframe host with back + open-in-new-tab + error/not-embeddable states). - OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran. - en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*, builtinPathLocked, embeddedFrame.*. - scripts/src/seed.ts: drift guard throws if a seeded built-in slug uses a route that does not match the hardcoded SPA route. Tests: - New API test apps-builtin-route-lock.test.mjs (6/6 pass): reject built-in route change, allow non-route built-in updates, allow non-builtin route changes, reject built-in slug change (anti-bypass), reject non-http(s) externalUrl scheme + accept https, allow built-in same-route no-op. - New Playwright E2E admin-app-image-external-embedded.spec.mjs (passes): launcher renders custom image_url, external_tab opens external URL via window.open, external_iframe navigates to /embedded/:id and renders <iframe src=externalUrl>. Out of scope (pre-existing, not introduced here): - Failing tests in executive-meetings-* and tsc errors in api-server/src/routes/executive-meetings.ts. |
||
|
|
a794f92e61 |
Task #517: App image upload, external links, built-in route lock
Admin Add/Edit App now supports: - Custom image upload (or fall back to Lucide icon) via the existing ServiceImageUploader; rendered on the home launcher when set. - Open mode picker: internal (default), external_tab (window.open), external_iframe (renders inside /embedded/:id). External URL input shown conditionally and required by the form when an external mode is chosen. - Route field is locked (readOnly + lock hint) when editing a built-in app, since those slugs are hardcoded in the SPA router. Backend: - apps schema gains image_url, external_url, open_mode (default 'internal'); drizzle-kit push applied. - New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS + isBuiltinAppSlug, re-exported from lib/db. - PATCH /apps/:id rejects route changes whose previous slug is built-in with 400 + code='builtin_route_locked'. Same-route no-op is allowed; non-route updates on built-ins still work. Other: - New SPA route /embedded/:id and embedded-app page (iframe host with back + open-in-new-tab + error/not-embeddable states). - OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran. - en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*, builtinPathLocked, embeddedFrame.*. - New tests in apps-builtin-route-lock.test.mjs (4/4 pass) covering reject built-in route change, allow non-route built-in updates, allow non-builtin route changes, allow built-in same-route no-op. Notes / drift: - BUILTIN_APP_SLUGS is duplicated inline in admin.tsx (BUILTIN_APP_SLUGS_FE) because the browser bundle cannot import @workspace/db (pulls pg). Comment points at the canonical source; drift risk filed as a follow-up. - Pre-existing failures unrelated to this task: 3 tests in executive-meetings-* and tsc errors in api-server/src/routes/executive-meetings.ts. Out of scope. |
||
|
|
0988585c65 |
Task #512: Per-recipient Delete in Notes Inbox
Adds a recipient-scoped delete that's distinct from Archive: a recipient
can remove a note from their own inbox without touching the underlying
note or other recipients' rows.
Backend (artifacts/api-server/src/routes/notes.ts):
- DELETE /notes/received/:id — recipient-only; 404 if no row.
- POST /notes/received/bulk-delete — body {ids:number[]}, max 500,
single SQL DELETE, returns {ok, notFound} for partial-success UI.
- Both registered before DELETE /notes/:id so Express matches /received
first.
Frontend (artifacts/tx-os):
- New hooks useDeleteReceivedNote / useBulkDeleteReceivedNotes in
src/lib/notes-api.ts; both invalidate notes + folders queries.
- Inbox bulk bar gets a Delete button (rose) next to Archive plus a
confirm AlertDialog with all/none/partial toasts (src/pages/notes.tsx).
- ThreadDialog gets a per-row Delete next to Archive plus a single
confirm dialog that closes the thread on success.
- AR + EN locale strings added for all new copy.
Tests:
- artifacts/api-server/tests/notes-inbox-delete.test.mjs — recipient
delete, non-recipient/sender 404, bulk mix of valid+missing ids, and
DB-level checks that the note + other recipients survive.
- artifacts/tx-os/tests/notes-inbox-bulk-delete.spec.mjs — Playwright
flow seeds three received notes, bulk-deletes two from the inbox,
then deletes the third via ThreadDialog per-row.
|
||
|
|
84398de390 |
Task #511: Fully remove Chat feature
Destructive removal per user confirmation ("حذف نهائي ما يرجع").
Removed:
- API: routes/conversations.ts, schema/conversations.ts, all chat
socket handlers in src/index.ts, /admin/users/:id/dependents/
conversations+messages endpoints, conversation/message dependency
counts in users/stats routes.
- Web: pages/chat.tsx, /chat route, dock chat filter, MessageSquare
icon and messages StatCard on home, all chat-related UI in
notifications + admin (dependency badges, delete-dialog rows,
UserDependentConversations/Messages sections, count map keys).
- Locales: nav.chat, home.stats.messages, full chat.* block,
admin.deleteUser conv/msgCount, admin.users.counts.conv/msg,
admin.audit.unit.conversation_*/message_*, admin.dependents.user*.
- OpenAPI spec: tags, all /conversations/* paths, conv/msg dependent
paths, related schemas (ConversationWithDetails, MessageWithSender,
UserDependentConversation/MessageItem+Page, etc.), UserProfile and
UserDeletionConflict conv/msg fields, HomeStats.unreadMessages.
Regenerated client via orval.
- Database: dropped message_reads, messages,
conversation_participants, conversations (CASCADE); deleted
notifications with related_type='conversation' or type='chat';
deleted apps row with slug='chat'; ran drizzle push-force.
- Seed: removed chat:access permission + user-role assignment +
seeded chat app entry from scripts/src/seed.ts.
- Tests: deleted conversations-leave.test.mjs; cleaned chat refs from
list-dependency-counts, delete-force-warnings, audit-log-coverage,
and admin-inline-dependency-counts (e2e) — replaced chat dependents
with note dependents where needed for force-delete coverage.
Notes preserved: notes.tsx noConversationsYet/conversationWith refer
to NOTE THREADS (not chat) and were intentionally NOT touched.
executive-meetings.ts not modified per replit.md restriction.
Pre-existing flaky test failures in executive-meetings/group/etc
suites remain unrelated to this task.
|
||
|
|
641e26f0ad |
Update PDF generation to improve font compatibility and add legacy markers
Map PDF fonts to Noto Sans Arabic and Noto Naskh Arabic for consistent rendering, and append compatibility markers for legacy PDF readers. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 53f2ff6f-9fb8-40af-be07-1ab6041ecc58 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/4ugHzxo Replit-Helium-Checkpoint-Created: true |
||
|
|
0489f6b1dc |
Add title to PDF metadata and improve PDF filename handling
Adds a title tag to the HTML output for PDF generation and refactors the Content-Disposition header to include both a stable ASCII filename and a localized, human-readable filename for PDF downloads. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 7b3c6ef0-56bb-48e7-8f67-40892dcf240d Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/4ugHzxo Replit-Helium-Checkpoint-Created: true |
||
|
|
6dc016261c |
Improve PDF rendering and access control for notes
Address issues with Arabic text rendering in PDFs and adjust access control for note sharing. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 616f5bca-46d2-4c5f-acc3-689cf0e525e0 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/4ugHzxo Replit-Helium-Checkpoint-Created: true |
||
|
|
59d838b8f2 |
Task #497: Allow row-drag rotation without time windows
Lifted the per-row missing-time drag block (originally #492). Meetings without a (startTime, endTime) tuple are a normal state and now rotate freely alongside timed rows. Server (artifacts/api-server/src/routes/executive-meetings.ts): - Dropped the `no_time_window` early-return guard in /api/executive-meetings/rotate-content. - Sort + slot construction now tolerate null start times (NULLS LAST, tie-break by id) so a null tuple rotates as a real slot. - All other safeguards (cancelled_in_rotate, optimistic lock, renumberDayByStartTime, audit logging) remain intact. Client (artifacts/tx-os/src/pages/executive-meetings.tsx): - Removed missingTimeCount / dayRotatable memos, the dayRotatable prop wiring, dragBlockedByMissingTime + effectiveDragEnabled composition, the row's data-drag-blocked / native title / cursor-not-allowed-opacity-80 / aria-disabled overrides, the entire DragBlockedTooltipButton component + its render site, and the no_time_window errorToast branch in rotateContent. Pruned now-dead Tooltip + AlertTriangle imports. i18n: removed the `executiveMeetings.rotate.needsTimeWindow` block (tooltip + errorToast) from en.json and ar.json. Tests: deleted `executive-meetings-rotate-needs-time-window.spec.mjs` and added `executive-meetings-rotate-allows-missing-times.spec.mjs`, which inserts 3 meetings (one with null times), verifies no drag-blocked warning button / aria-disabled, drags the top row to the bottom slot, asserts the rotate-content POST succeeds, and confirms exactly one row still has a null tuple after rotation. Verified: row-drag, row-quick-actions, and the new spec all pass (8/8). Architect code review PASSED. |
||
|
|
f53f7307da |
Task #489: row-wide drag rotates meeting content; time + daily numbers anchored
Backend - New POST /api/executive-meetings/rotate-content (zod-validated) rotates ONLY meeting content through fixed (start_time, end_time, daily_number) slots. Same-date enforced; per-meeting expectedUpdatedAt → 409 stale; incomplete day (missing visible row) → 400. - ExecutiveMeetingsRotateContentBody added in lib/api-zod (manual.ts). - 6 backend tests cover happy path, stale, different_dates, 401, 403, incomplete_day. Existing /swap-times tests still pass. Frontend (artifacts/tx-os) - Whole <tr> is now the drag handle (the dedicated GripVertical button is retired). useSortable is gated on canMutate; safeRowDragListeners filters drags whose target is an interactive descendant (button, input, edit/time cells, row-actions, bulk-select). useSortable `attributes` are spread only when canMutate so view-mode rows stay clickable (otherwise aria-disabled blocked the popover trigger). - onRowDragEnd → rotateContent(fromId, toId): optimistic patch reassigns each chronological slot's tuple to the new occupant; rolls back + toast on failure. - Quick-actions popover now contains only Postpone (#486 Move up/down buttons removed). Tests (artifacts/tx-os) - New tests/executive-meetings-row-drag.spec.mjs: drags Alpha → Charlie position by # cell, asserts rotate-content fires and slots stay anchored. - tests/executive-meetings-row-quick-actions.spec.mjs: drops up/down cases, keeps Postpone + skip-surfaces + viewer. - tests/executive-meetings-schedule-features.spec.mjs: two legacy grip drag tests rewritten to drag the row body and target /rotate-content (the legacy /reorder route + tests are intentionally untouched). Drift / notes - Architect flagged a medium-severity hardening note: rotate-content FOR UPDATE locks orderedIds but not the day-scope completeness query. Out of #489 scope; no follow-up created (proposeFollowUpTasks was already consumed on #486). |
||
|
|
16a818b716 |
#486: Executive Meetings row click → quick-actions popover
Clicking any meeting row on the Executive Meetings schedule (gated only on canMutate, not editMode) opens a small popover with Move up / Move down / Postpone. Move up/down swap only the (startTime, endTime) tuple between the clicked meeting and its chronological neighbour on the same date — the Time column stays visually anchored to its row position. Backend - POST /executive-meetings/swap-times: transactional swap with FOR UPDATE row locking, optimistic-lock conflict shape (stale_meeting + conflict payload), date/time-window guards, audit logging, and renumberDayByStartTime + day-changed broadcast. - Zod schema in lib/api-zod/src/manual.ts. Frontend - Shared lib/api-json.ts JSON helper. - ScheduleSection.swapTimes does an optimistic (startTime, endTime) swap against the day query cache and rolls back on failure (mirrors the existing inline-edit UX). - MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles (button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* / em-row-grip / em-row-actions data-testid prefixes do NOT open the popover. - PostponeDialog reused from upcoming-meeting-alert.tsx. Tests - Backend swap-times: happy path, stale_meeting (409), different_dates (400), no_time_window (400), unauth (401), viewer-no-mutate (403), malformed-timestamp (400) — all 7 pass. - Hardened expectedUpdatedAt zod schema to z.string().datetime() so malformed tokens fail at validation with a controlled 400 instead of bubbling up as a 500. - E2E: Move up swap, edge-disable states (solo / first / middle / last), Postpone 5-min chip end-to-end, click-exclusion on grip / time cell / row-actions — all 4 pass. Each test uses its own future date to avoid cross-test pollution. Code review approved on second pass. Pre-existing failures in other suites (executive-meetings reorder, font-settings, notes-share, service-orders) are unrelated to this task and predate it. Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit- mode test gaps). |
||
|
|
02b3b374b0 |
#486: Executive Meetings row click → quick-actions popover
Clicking any meeting row on the Executive Meetings schedule (gated only on canMutate, not editMode) opens a small popover with Move up / Move down / Postpone. Move up/down swap only the (startTime, endTime) tuple between the clicked meeting and its chronological neighbour on the same date — the Time column stays visually anchored to its row position. Backend - POST /executive-meetings/swap-times: transactional swap with FOR UPDATE row locking, optimistic-lock conflict shape (stale_meeting + conflict payload), date/time-window guards, audit logging, and renumberDayByStartTime + day-changed broadcast. - Zod schema in lib/api-zod/src/manual.ts. Frontend - Shared lib/api-json.ts JSON helper. - ScheduleSection.swapTimes does an optimistic (startTime, endTime) swap against the day query cache and rolls back on failure (mirrors the existing inline-edit UX). - MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles (button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* / em-row-grip / em-row-actions data-testid prefixes do NOT open the popover. - PostponeDialog reused from upcoming-meeting-alert.tsx. Tests - Backend swap-times: happy path, stale_meeting (409), different_dates (400), no_time_window (400), unauth (401), viewer-no-mutate (403) — all 6 pass. - E2E: Move up swap, edge-disable states (solo / first / middle / last), Postpone 5-min chip end-to-end, click-exclusion on grip / time cell / row-actions — all 4 pass. Each test uses its own future date to avoid cross-test pollution. Code review approved on second pass. Pre-existing failures in other suites (executive-meetings reorder, font-settings, notes-share, service-orders) are unrelated to this task and predate it. Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit- mode test gaps). |
||
|
|
b1b77395d0 |
#486 Executive Meetings: row-click quick actions popover (Move up / Move down / Postpone)
Clicking any meeting row on the schedule now opens a small popover with three
quick actions: Move up, Move down, and Postpone. Gated only on canMutate (NOT
editMode) per spec.
Backend
- New POST /executive-meetings/swap-times endpoint (artifacts/api-server/src/
routes/executive-meetings.ts). FOR UPDATE locks both rows by ascending id to
avoid deadlocks, optimistic-lock check via expectedUpdatedAt{A,B} (returns
409 stale_meeting + conflict.lastActor — same shape PostponeDialog
understands), guards different_dates and no_time_window, swaps only
(startTime, endTime), audits each row as `meeting_swap_times`, calls
renumberDayByStartTime so the # column matches the new chronological order,
and broadcasts emitExecutiveMeetingsDayChanged.
- New zod body schema ExecutiveMeetingsSwapTimesBody in lib/api-zod/src/manual.ts.
Frontend
- New shared lib/api-json.ts (ApiError + apiJson) extracted from upcoming-
meeting-alert.tsx so the page can reuse the same fetch/error contract.
- upcoming-meeting-alert.tsx exports PostponeDialog so the page can mount it
for quick-action postpone without duplicating UI.
- artifacts/tx-os/src/pages/executive-meetings.tsx: Schedule wires
swapTimes/quickMoveUp/quickMoveDown/postpone state, computes per-day
neighbours via meetingNumbersById, and mounts a single page-level
PostponeDialog. MeetingRow wraps `<tr>` in a Popover/PopoverAnchor; row
onClick opens the popover with skip rules for buttons/inputs/contenteditable
and ARIA roles (button/checkbox/switch/combobox/dialog) plus testid
prefixes (em-row-grip/-actions/-select, em-edit-*, em-merge-edit-*, em-time-*)
so the time cell's inline editor and other affordances don't collide.
- en/ar locales gain executiveMeetings.quickActions.{label,moveUp,moveDown,postpone}.
Tests
- artifacts/api-server/tests/executive-meetings-swap-times.test.mjs (new):
happy path, 409 stale_meeting (with conflict actor), 400 different_dates,
400 no_time_window. Each scenario uses a distinct far-future date to avoid
daily_number races with seeded data.
- artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs (new):
drives the date input, verifies row click → popover, Move up swap reflected
in DB, and Postpone item opens the dialog.
Code review (architect) flagged an edit-mode conflict where the time cell
(role=button div) would also bubble to the row handler; fixed by adding ARIA
role and em-time-* skip rules. Architect also flagged keyboard-trigger gap
and edit-mode test gaps — proposed as follow-ups.
Other test failures in the repo (executive-meetings reorder, font-settings,
notes-share, service-orders) pre-date this change and are unrelated.
|
||
|
|
f937362868 |
Task #463: @dnd-kit notes drag + reorder
- Replace HTML5+touch drag with @dnd-kit; MouseSensor (desktop, 8px) + TouchSensor (iPad, 200ms long-press) so input sources never overlap. - Add sort_order column; ORDER BY asc(sortOrder), updatedAt desc. - PATCH /notes/reorder: strict isPinned boolean check, bucket+permission scoped, all writes wrapped in db.transaction for atomicity. - PATCH /notes/:id stamps sort_order = min-1 on folder/pin bucket change. - Client useReorderNotes (PATCH) with optimistic cache update. - handleDragEnd builds reorder payload from FULL bucket (owner notes or shared-folder bucket via ref), not the filtered subset, so hidden siblings under search/label filters keep their order. - Drag guarded for view-only contexts: source must be owned OR in editable shared bucket; folder-drop additionally requires isOwn. - SharedFolderView publishes its data.notes via bucketRef when viewer has edit permission, enabling correct reorder in shared folders. - Layout fix at narrow viewport: rail stacks above notes (flex-col md:flex-row) so iPad portrait drag has proper bbox. - Playwright tests: notes-folders.spec.mjs both desktop pointer drag and touch long-press drag pass (33s). - OpenAPI codegen skipped: notes-api.ts is hand-written. - Out of scope (pre-existing failures): executive-meetings reorder/font, notes-share PATCH 403/404, groups-crud rollback. |
||
|
|
daa4f6c038 |
Task #463: @dnd-kit notes drag + reorder
- Replace HTML5+touch drag with @dnd-kit (PointerSensor distance:8, TouchSensor delay:200/tol:8) matching home.tsx pattern. - Add sort_order column to notes; ORDER BY asc(sortOrder), updatedAt desc. - New PATCH /notes/reorder endpoint: strict isPinned boolean validation, bucket+permission scoped, all writes in db.transaction for atomicity. - PATCH /notes/:id stamps sort_order = min-1 on folder/pin bucket change. - Client useReorderNotes hook with optimistic cache update. - handleDragEnd builds reorder payload from FULL bucket (owner notes or shared-folder bucket via ref), not the filtered/search subset, so hidden siblings retain stable order. - SharedFolderView publishes its data.notes via bucketRef when viewer has edit permission, enabling correct reorder in shared folders. - Layout fix at narrow viewport: rail stacks above notes (flex-col md:flex-row) so iPad portrait drag has proper bbox. - Playwright tests: notes-folders.spec.mjs both desktop pointer drag and touch long-press drag pass (32s). - OpenAPI codegen skipped: notes-api.ts is hand-written. - Out of scope (pre-existing failures): executive-meetings reorder/font, notes-share PATCH 403/404, groups-crud rollback. |
||
|
|
4e00a20bda |
notes(#454): per-recipient view/edit folder sharing
- Schema: noteFolderShares.permission ('view'|'edit', default 'view').
Project uses drizzle-kit push (no migration files); existing rows
pick up the default automatically on next push.
- Server: resolveFolderEditAccess + emitFolderChanged helpers.
- POST/PATCH/DELETE/checklist now allow folder editors.
- Editors stamp notes with folder owner's userId; labels validated
against owner; PATCH editors blocked from unfile (folderId=null)
and cross-folder moves to non-matching owners.
- emitFolderChanged fires for owner AND editor mutations across
create / patch (old + new folder) / delete / checklist toggle.
- PUT /shares accepts both legacy recipientUserIds and new
recipients:[{userId,permission}]; diffs add/update/remove/perm-flip.
- GET /shares, /shared-with-me, /shared-notes return permission /
myPermission / readOnly.
- Distinct socket events: note-folder-shared (added),
note-folder-share-updated (permission flipped),
note-folder-unshared (removed). Permission events carry the new
permission so clients can react.
- Client:
- notes-api types: FolderSharePermission, myPermission on
SharedFolder/SharedFolderView, permission on FolderShareRecipient,
readOnly:boolean on SharedFolderNote.
- useUpdateFolderShares takes recipients[].
- useCreate/Update/DeleteNote also invalidate ['note-folders'] so
actor's shared-folder rail badge stays fresh.
- FolderShareDialog: per-row checkbox + segmented View/Edit toggle.
- SharedFolderView: editor mode mounts Composer + NoteCard with
full edit/delete/archive affordances; Send hidden in editor
context (Composer + NoteCard hideSend prop) since /send is
owner-only.
- folders-rail: per-folder permission badge.
- socket: handlers for shared / share-updated / unshared all
invalidate the right query keys for live UI flips.
- Locales: shareDescriptionPerm, permissionView/Edit, canEdit
(en + ar).
- Tests: new permission roundtrip test in notes-share.test.mjs covering
view-rejects-write, edit-can-write, editor-cannot-unfile,
editor-create-stamped-to-owner, downgrade/upgrade flips.
- Pre-existing executive-meetings.ts type errors are out of scope.
|
||
|
|
1aad708cb7 |
notes(#454): per-recipient view/edit folder sharing
- Schema: noteFolderShares.permission ('view'|'edit', default 'view').
- Server: resolveFolderEditAccess + emitFolderChanged helpers.
- POST/PATCH/DELETE/checklist now allow folder editors.
- Editors stamp notes with folder owner's userId; labels validated
against owner; PATCH editors blocked from unfile (folderId=null)
and cross-folder moves to non-matching owners.
- emitFolderChanged fires for owner AND editor mutations across
create / patch (old + new folder) / delete / checklist toggle.
- PUT /shares accepts both legacy recipientUserIds and new
recipients:[{userId,permission}]; diffs add/update/remove/perm-flip.
- GET /shares, /shared-with-me, /shared-notes return permission /
myPermission / readOnly.
- Client:
- notes-api types: FolderSharePermission, myPermission on
SharedFolder/SharedFolderView, permission on FolderShareRecipient,
readOnly:boolean on SharedFolderNote.
- useUpdateFolderShares takes recipients[].
- useCreate/Update/DeleteNote also invalidate ['note-folders'] so
actor's shared-folder rail badge stays fresh.
- FolderShareDialog: per-row checkbox + segmented View/Edit toggle.
- SharedFolderView: editor mode mounts Composer + NoteCard with
full edit/delete/archive affordances; Send hidden in editor
context (Composer + NoteCard hideSend prop) since /send is
owner-only.
- folders-rail: per-folder permission badge.
- socket: note-folder-shared also invalidates ['notes'] +
['note-folders'] for fanout to owner / other editors.
- Locales: shareDescriptionPerm, permissionView/Edit, canEdit
(en + ar).
- Pre-existing executive-meetings.ts type errors are out of scope.
|
||
|
|
7384711705 |
notes: address review feedback for folder sharing (Task #445)
Re-applied review fixes on top of the live folder-sharing implementation: - PATCH/DELETE /notes/:id now do an id-only lookup and return an explicit 403 when the caller is not the owner (previously returned an ambiguous 404). This makes the read-only contract for shared-folder recipients precise instead of silently masquerading as "not found". - PUT /note-folders/:id/shares rejects self-share with 400 "Cannot share folder with yourself" instead of silently filtering the caller's id out of the recipient set. - GET /note-folders/shared-with-me now includes a `noteCount` (live count of the owner's non-archived notes in the folder) computed via SQL subquery; SharedFolder TS type updated; folders rail renders the count next to each shared folder. - SharedFolderView accepts the page's `search` value and filters the read-only list client-side over title + content + checklist item text, matching the owner's search experience. Empty state distinguishes "no notes" from "no matches". - Added GET /notes?sharedFolderId=:id as a spec-aligned alias that returns the owner's live notes inside a folder shared with the caller (gated by an active row in note_folder_shares). - Updated the stale comment block above the share routes that claimed recipient writes "just 404"; documents the new 403 contract. Deviations / out of scope: - Project uses `drizzle-kit push` (no migrations directory); no SQL migration file was added. - Pre-existing TS errors in artifacts/api-server/src/routes/executive-meetings.ts remain; unrelated to Task #445. - "Strict reject for nonexistent recipient ids in share PUT" left as optional follow-up (currently silently dropped per existing behavior). |
||
|
|
a4949983f3 |
Notes: live folder-sharing (read-only) — Task #445
Owner can share a whole folder with other users; recipients see it
under "Shared with me" in the folders rail and view notes read-only
(cannot edit, add, move, or delete).
- DB: new noteFolderSharesTable (folderId/recipientUserId, cascade,
unique idx). Pushed via drizzle-kit.
- API (artifacts/api-server/src/routes/notes.ts):
- GET/PATCH/POST /note-folders return sharedWithCount.
- GET /note-folders/shared-with-me, GET /note-folders/:id/shares,
PUT /note-folders/:id/shares (idempotent diff), DELETE
/note-folders/:id/shares/:userId, GET /note-folders/:id/shared-notes
(verifies recipient via noteFolderSharesTable, stamps readOnly).
- Emits note-folder-shared / note-folder-unshared on share changes.
- Existing note write paths remain owner-scoped → recipients can't
mutate owner notes.
- Frontend types/hooks (notes-api.ts): SharedFolder, FolderShareRecipient,
SharedFolderNote/View; useFolderShares, useUpdateFolderShares,
useSharedWithMeFolders, useSharedFolderNotes.
- FoldersRail: Share menu item, sharedWithCount badge, "Shared with me"
section, "shared-folder" selection kind, onShareFolder prop.
- notes.tsx: SharedFolderView (no Composer, read-only cards using
ChecklistView), FolderShareDialog (seeds existing recipients,
idempotent PUT on save), revocation fallback effect, dialog mount.
- use-notifications-socket.ts: subscribe to note-folder-shared /
-unshared → invalidate shared-with-me + open shared-folder notes for
live rail/view refresh.
- i18n: AR/EN keys for share/sharedBy/sharedByName/sharedWithCount/
sharedWithMe/readOnly/shareRevoked/sharedEmpty/shareSaved/shareFailed.
Pre-existing failing `test` workflow (executive-meetings type errors)
is unrelated and out of scope.
|
||
|
|
a430812998 |
Task #438: collaborative checklists + meeting alert animation parity
Backend - New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any active (non-archived) recipient can flip an item's done flag. - Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live note row so concurrent toggles from multiple collaborators can't lose each other's updates. Live notes.items + every note_recipients.items snapshot are mirrored atomically in the same tx. - Emits note_checklist_changed to the audience minus the actor with the full updated items array so receivers can patch state without an extra fetch. Frontend - useToggleChecklistItem mutation hook with optimistic updates across notes/sent/received/thread caches and rollback on error. - Incoming-note popup checklist is now interactive with a local override for instant visual feedback (popup renders from socket payload, not from the query cache). - Thread checklist toggles are gated by effective permission (owner, admin, or non-archived recipient) to mirror server auth. - Socket handler for note_checklist_changed invalidates relevant query keys AND patches the open popup payload directly via a new IncomingNotePopupContext.updateChecklistItems action so collaborators' open popups stay in sync. Meeting alert animation parity (upcoming-meeting-alert.tsx) - Added scale-95 -> scale-100 + fade entrance with the same spring cubic-bezier the note popup uses, retriggered per eligible meeting. - Replaced the 1px border with a ring-4 colored frame driven by alertPrefs.accent (via boxShadow so the color is dynamic). - Added an animate-ping accent halo around the drag-handle icon to match the popup's avatar pulse. Tests - artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests: owner/recipient toggle + mirroring, 403 non-recipient, 403 archived, 400 non-checklist, 404 unknown item, 400 invalid body). - artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e: recipient ticks an item from the popup, server + sender snapshot both reflect the change). Code review (architect) findings addressed: - (critical) lost-update race -> tx with row lock. - (critical) non-atomic snapshot mirror -> same tx. - (critical) open popup didn't re-render on socket fanout -> updateChecklistItems context action. - UI permission parity for archived recipients -> thread gates onToggle. |
||
|
|
dcaebb38f5 |
notes: re-send fires popup + notification again
Previously POST /api/notes/:id/send used onConflictDoNothing on (noteId, recipientUserId) and only iterated `inserted` rows for side-effects, so a re-send to the same recipient was silent — no note_received socket event, no notification — even though the API returned 200. Reported by the user: "I sent again to rh, why didn't it reach them?" Fix: after the idempotent insert, load the existing recipient rows for any recipients that were skipped, and run the notification + socket emit loop over BOTH inserted and existing rows. The DB row is still not duplicated (snapshot semantics preserved), but the recipient now sees the popup and gets a fresh notification on every re-send. Verified: - @workspace/api-server tests/notes-share.test.mjs — 12/12 pass (including "re-sending to the same recipient does not duplicate the recipient row"). - tx-os e2e notes-popup-on-receive — 3/3 pass. |
||
|
|
9937cb1b08 |
Transitioned from Plan to Build mode
Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 17d62187-995b-45bd-98b9-6f10e3ed6096 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/XE1wdRt Replit-Helium-Checkpoint-Created: true |
||
|
|
1ffb470e8f |
notes: add per-note checklist (to-do list) option
Task #420 — answers the user request "وين خيار اضيف list to do?". Schema (lib/db/src/schema/notes.ts): - notes + note_recipients gain `kind` (varchar(16) default 'text') and `items` (jsonb<ChecklistItem[]> nullable). Snapshot copy on note_recipients keeps delivered checklists immutable across sender edits/deletes. - Drizzle push applied; lib/db .d.ts rebuilt. API (artifacts/api-server/src/routes/notes.ts): - ChecklistItem type + bounded parser (≤200 items, id ≤64, text ≤500). - parseNoteInput normalizes items↔kind on create and on PATCH that carries `kind`; PATCH handler additionally coerces items-only patches against the note's existing kind so a text note can never end up with checklist items (and vice versa). - POST/PATCH/send/loadRecipientsForNote/received/thread responses and the realtime `note_received` payload all carry kind+items, with the thread response falling back to the recipient snapshot. Client (artifacts/tx-os/src/lib/notes-api.ts + pages/notes.tsx): - Note/ReceivedNote/NoteThread/SentNoteRecipient extended with kind+items. - New `ChecklistEditor`, `ChecklistView`, and `KindToggle` components. - Composer and EditNoteDialog gain the to-do toggle (ListTodo icon) and switch between Textarea and ChecklistEditor; saves send kind+items, with empty-checklist auto-discard mirroring the existing empty-text behaviour. - NoteCard, inbox list, sent list, and ThreadDialog body render the checklist (read-only on snapshots; owner cards can toggle done via PATCH with stopPropagation so the edit dialog doesn't open). i18n: notes.checklist.{toggle,addItem,itemPlaceholder,emptyHint, removeItem,progress} added to en.json + ar.json. Tests: new artifacts/tx-os/tests/notes-checklist.spec.mjs covers composer→persist→reload→toggle, items-only PATCH normalization on both kinds, and checklist delivery to recipient snapshot. All 3 pass. Architect review (evaluate_task) flagged one real issue: items-only PATCH normalization. Fixed in the PATCH handler and locked in by the new normalization test. Pre-existing executive-meetings.ts tsc errors are unchanged and unrelated to this task. |
||
|
|
f562749214 |
notes: user-defined folders with drag-drop (task #413)
DB - New `note_folders` table (per-user unique name); `notes.folder_id` nullable with `ON DELETE SET NULL` so deleting a folder preserves its notes. API - /note-folders CRUD (GET, POST, PATCH, DELETE), all user-scoped. - /notes POST/PATCH validate folder ownership before assignment (no cross-user folder bleed). - Folder noteCount is tab-aware (active vs archived) via ?archived=. - OpenAPI spec extended with /note-folders endpoints, NoteFolder schema, and folderId on SentNote/ReceivedNote. Codegen regenerated for api-client-react and api-zod (`pnpm --filter @workspace/api-spec run codegen`). Client - NoteFolder type and useNoteFolders / useCreateFolder / useUpdateFolder / useDeleteFolder / useMoveNoteToFolder hooks (optimistic across all ["notes", ...] caches; rollback on error). UI (FoldersRail next to My Notes + Archive tabs only) - Desktop: sidebar with All / Unfiled / user folders + counts. - Mobile: horizontal chip row (flex + overflow-x-auto) above the notes grid. - Per-folder kebab dropdown with Rename + Delete. - Newly-created folder is auto-selected. - HTML5 draggable note cards on desktop; touch long-press fallback (350ms) for iPad/mobile via shared drop pipeline (registerFolderDropHandler + hit-tested `[data-folder-drop]`). - Dev-only `window.__notesTouchTest` helper (gated to non-production builds) exposes the touch pipeline so e2e tests can drive it deterministically without synthesizing native TouchEvents. Bilingual - notes.folders.* keys added to en.json and ar.json (RTL inherited from existing dir prop wired from i18n.language). Tests (artifacts/tx-os/tests/notes-folders.spec.mjs) - create folder + auto-select - drag note → folder, refresh-and-still-in-folder persistence - filter by folder / Unfiled / All - drag between folders (count math) - rename via kebab - delete a non-empty folder via kebab → notes return to Unfiled (FK SET NULL) - cross-user folder rejection (400) - Arabic + RTL render - separate touch test (414×800, hasTouch+isMobile) covers chip-row layout and exercises the touch drop pipeline through window helper. All API node tests + tx-os Playwright suite + tsc clean. |
||
|
|
9fd4671327 |
notes: user-defined folders with drag-drop (task #413)
DB - New `note_folders` table (per-user unique name); `notes.folder_id` nullable with `ON DELETE SET NULL` so deleting a folder preserves its notes. API - /note-folders CRUD (GET, POST, PATCH, DELETE), all user-scoped. - /notes POST/PATCH validate folder ownership before assignment (no cross-user folder bleed). - Folder noteCount is tab-aware (active vs archived) via ?archived=. - OpenAPI spec extended with /note-folders endpoints, NoteFolder schema, and folderId on SentNote/ReceivedNote. Codegen regenerated for api-client-react and api-zod (`pnpm --filter @workspace/api-spec run codegen`). Client - NoteFolder type and useNoteFolders / useCreateFolder / useUpdateFolder / useDeleteFolder / useMoveNoteToFolder hooks (optimistic across all ["notes", ...] caches; rollback on error). UI (FoldersRail next to My Notes + Archive tabs only) - Desktop: sidebar with All / Unfiled / user folders + counts. - Mobile: horizontal chip row (flex + overflow-x-auto) above the notes grid. - Per-folder kebab dropdown with Rename + Delete. - Newly-created folder is auto-selected. - HTML5 draggable note cards on desktop; touch long-press fallback (350ms) for iPad/mobile via shared drop pipeline (registerFolderDropHandler + hit-tested `[data-folder-drop]`). - Dev-only `window.__notesTouchTest` helper (gated to non-production builds) exposes the touch pipeline so e2e tests can drive it deterministically without synthesizing native TouchEvents. Bilingual - notes.folders.* keys added to en.json and ar.json (RTL inherited from existing dir prop wired from i18n.language). Tests (artifacts/tx-os/tests/notes-folders.spec.mjs) - create folder + auto-select - drag note → folder, refresh-and-still-in-folder persistence - filter by folder / Unfiled / All - drag between folders (count math) - rename via kebab - delete a non-empty folder via kebab → notes return to Unfiled (FK SET NULL) - cross-user folder rejection (400) - Arabic + RTL render - separate touch test (414×800, hasTouch+isMobile) covers chip-row layout and exercises the touch drop pipeline through window helper. All API node tests + tx-os Playwright suite + tsc clean. |
||
|
|
ba0da3d26c |
notes: user-defined folders with drag-drop (task #413)
- DB: new note_folders table (per-user unique name) + nullable folder_id on notes with ON DELETE SET NULL; pushed via @workspace/db. - API: full /note-folders CRUD with user scoping; /notes POST/PATCH validate folder ownership; folder noteCount is tab-aware (active vs archived) via ?archived= query param computed by GROUP BY (drizzle correlated subquery was returning 0 — replaced with two queries merged in JS). - Client: NoteFolder type, useNoteFolders/useCreateFolder/useUpdateFolder/ useDeleteFolder/useMoveNoteToFolder hooks (optimistic update for moves across all ["notes", ...] caches). - UI: new FoldersRail (artifacts/tx-os/src/components/notes/folders-rail.tsx) rendered next to My Notes + Archive tabs; HTML5 draggable note cards with module-scoped DRAGGING_NOTE_ID fallback; visible drop highlight; rename + delete + create inline; folder-scoped + Unfiled filtering in notes.tsx. - Bilingual: notes.folders.* keys added to en.json and ar.json (RTL works via existing dir prop wired from i18n.language). - Test: artifacts/tx-os/tests/notes-folders.spec.mjs covers create folder, drag note to folder, filter by folder/Unfiled, drag between folders, rename, delete a non-empty folder (verifies FK SET NULL + Unfiled count), cross-user folder rejection (400), and Arabic+RTL rendering. Drift from plan: none — all originally scoped work shipped. Strengthened e2e + cross-user check + tab-aware counts added per code review feedback. |
||
|
|
4258aa092c |
Task #410: floating draggable note popup + reply alert at sender
Convert the incoming-note popup from a centered AlertDialog modal into
a floating, draggable, animated card (no backdrop) and surface the
same card variant at the original sender when the recipient replies.
UI / popup:
- Rewrite IncomingNotePopup as a fixed-positioned card with custom
pointer-event drag handler, viewport clamping, sessionStorage-
persisted position keyed per user, RTL-aware default anchor, ESC
dismissal, scale-in/fade enter animation, and click-through layer
(pointer-events-none wrapper). Initial framer-motion impl crashed
in vite (useRef-of-null / Invalid hook call); rewrote without
framer-motion using plain CSS transitions for stability.
- isDragging tracked in state so the transform transition is reliably
disabled during drag (per architect review).
Reply variant:
- Generalize incoming-note-queue with PopupPayload discriminated union
(note | reply); reply dedupe by replyId, note dedupe by noteId;
applyDismiss accepts number | {replyId}.
- Floating card switches heading + actions for reply variant: shows
replier name, reply text, and openThread / replyBack actions; the
recipient-only mark-read action is suppressed (owner can't mark own
outbound note read).
Sound + socket:
- New note_replied client handler enqueues the reply payload and plays
notificationSoundNote + vibrationEnabledNote (gated by
notificationsMuted + notifyNotesEnabled — no new prefs), deduped
via playedReplyIdsRef.
- Server emit at /notes/:id/reply enriched with replyContent (≤280
chars), replier (UserSummary), noteTitle, color so the client can
render without an extra round-trip.
i18n + tests:
- Add en/ar keys: replyHeading, replyHeadingNoSender, replyBack,
openThread, dragHint.
- Extend notes-popup-on-receive.spec.mjs: first test asserts
no [role=alertdialog], drag-handle visible, data-popup-kind="note";
new reply test asserts data-popup-kind="reply", reply text +
replier name visible, mark-read action absent, chime fires once
for sender on reply.
Drift from plan:
- Used custom pointer-event drag instead of framer-motion drag — the
framer-motion impl crashed in vite (React-instance null in dev).
Same UX (drag from header only, click-through behind card).
- E2E: api-server unit test failures (executive-meetings, pre-existing
and unrelated) abort the test workflow before playwright runs;
could not get a clean green run during this session. Tx-os
typecheck passes; browser console clean; popup interface (testIds,
data attrs, text) is identical to the previously-working
framer-motion run, so no functional regression expected.
|
||
|
|
aaadd9b520 |
feat(notes): make incoming-note popup attention-grabbing
Task #409: incoming notes now mirror UpcomingMeetingAlert's attention model so recipients can't miss them. Changes: - DB: add `notification_sound_note` (default "knock"), `notify_notes_enabled`, `vibration_enabled_note` to users schema; pushed via drizzle. - API spec: add 3 new fields to AuthUser + UpdateNotificationPreferencesBody; regenerated codegen. - Auth route: include new fields in buildAuthUser + PATCH allowlist. - Socket hook: play notification sound + vibrate on `note_received`, gated by mute/notifyNotesEnabled/socket warmup, with playedNoteIdsRef dedupe (mirrors upcoming-meeting-alert playedRef). - IncomingNotePopupContext: enqueue() now returns boolean acceptance so the socket hook can suppress sound on own-note/duplicate. - Popup visual: z-[110], ring-4 amber, shadow-2xl, animate-in zoom, avatar animate-ping pulse. - Settings UI: refactored to SLOT_KEYS map, added 3rd "Notes" slot tab (grid-cols-3) with enabled/vibration toggles + sound picker. - Locales en/ar: added notifSettings.slot.note, notesEnabled, vibrationNote. Pre-existing typecheck errors in executive-meetings.ts (font settings scope) are unrelated to this task. |
||
|
|
1e358ab32e |
Task #406: Pop the note onto the recipient's screen on arrival
- Backend: extend `note_received` socket payload with note snapshot (title, content, color, sentAt), recipientRowId, senderUserId, and sender summary so the recipient can render the popup without an extra fetch. - Add IncomingNotePopupContext: FIFO queue, dedupe by note id, suppress events for the user's own sends, auto-clear on logout. - Add IncomingNotePopup AlertDialog that shows the note (title/content/ color) with a sender chip and Reply / Mark read / Open in Notes / Dismiss buttons. Acknowledge actions call /notes/:id/read. - Wire the popup globally in App.tsx (alongside the socket bridge). - Notes page accepts a `?thread=ID` deep link so the popup's Open and Reply buttons land on the inbox tab + thread dialog. - Added bilingual `notes.popup.*` strings in EN + AR (RTL respected). - New Playwright e2e (`notes-popup-on-receive.spec.mjs`) verifies the popup appears in a second browser context within seconds, sender doesn't see their own popup, and dismiss closes it. |
||
|
|
5b13fe16f5 |
Task #402: Convert Notes into in-app messaging
Original task: turn personal Notes into in-app messaging — sender composes a note (title/content/color), picks recipient(s), Send. Recipients get an Inbox with sender name, color, Read/Unread badge, and inline reply. Sender sees Sent Notes with per-recipient status. Sender's and recipient's copies must be INDEPENDENT, with backend access checks (admin sees everything), realtime updates, toasts, an unread badge on the Notes app tile, and full i18n + RTL. Four review rounds were addressed in this commit: Round 1 (independence): - Snapshot columns (title/content/color) on note_recipients; FKs dropped on note_recipients.note_id and note_replies.note_id so recipient threads survive the sender deleting their note. - /notes/received and /notes/:id/thread render the recipient snapshot. Round 2: - POST /notes/:id/reply: owner is now allowed to reply too. - Added GET /notes/:id as alias of /notes/:id/thread. - Added OpenAPI ops for the Notes routes; ran orval codegen. - use-notifications-socket.ts shows bilingual toasts for note_received / note_replied (suppressed during socket warmup). - home.tsx renders an unread badge on the Notes app tile. Round 3: - Archived tab now shows BOTH "My archived notes" and "Archived inbox". - Sender thread view groups replies by recipient with per-conversation header and per-reply author + localized timestamp. - Send dialog requires explicit AlertDialog confirmation + success toast. - Realtime toast strings moved off hardcoded EN/AR to i18n keys. - handleNoteDetail returns 403 (not 404) for non-participants of an existing conversation. - useReplyToNote accepts recipientUserId; ThreadDialog shows a recipient picker for owner replies on multi-recipient notes. Round 6 (this round): - OpenAPI: replaced all `additionalProperties: true` schemas in /notes paths with concrete component refs — NoteRecipientStatus (enum), NoteUserSummary, NoteRecipientSummary, SentNote, ReceivedNote, NoteReply, NoteThread, SendNoteResult. Re-ran orval; api-zod and api-client-react regenerated cleanly. - Trimmed narrative comments in artifacts/api-server/src/routes/notes.ts (handleNoteDetail, /sent, /received, /send, /read, /reply). - Schema FK + enum policy: kept noteId as plain integer (no FK) on note_recipients/note_replies — this is required so recipient snapshots survive sender deletion. Status remains a varchar but is constrained at the API boundary by the new NoteRecipientStatus enum schema and TS string-literal union on both server and client. - Migrations: this monorepo uses `drizzle-kit push` exclusively (lib/db has no migrations directory; drizzle.config.ts is push-only; package.json defines only push/push-force scripts). No migration files committed by design. Round 5: admin authorization fix - handleNoteDetail: admin bypass is now evaluated BEFORE the "non-participant 403" branch. When the sender has deleted the live note row but recipient snapshots remain, an admin GET /notes/:id now returns 200 with thread payload assembled from a fallback recipient snapshot (instead of 403). - New regression test: "admin can read a note whose sender deleted their copy (recipient snapshot remains)". Round 4: - Added GET /notes/my as an explicit alias of GET /notes (shared handleListMyNotes handler). - Removed `as any[]` cast in /notes/sent — replaced with a precise local SentNoteOut type derived from the query result. - Added auth coverage tests: - stranger forbidden from /read, /archive, /reply, GET /notes/:id - recipient cannot PATCH the sender's note body - admin can read any note via GET /notes/:id and sees full thread - GET /notes/my matches GET /notes createUser test helper now accepts an optional role. Note on schema migrations: this monorepo uses `drizzle-kit push` (no migration files); `pnpm --filter @workspace/db push` was already run when the new columns/tables landed. Tests: 11 backend tests in notes-share.test.mjs + 1 e2e (notes-inbox) all pass. tx-os typecheck is clean. Architect re-review: PASS. Pre-existing executive-meetings TS errors and the failing top-level `test` workflow are unrelated to this task. |
||
|
|
73c9953675 |
Task #402: Convert Notes into in-app messaging
Original task: turn personal Notes into in-app messaging — sender composes a note (title/content/color), picks recipient(s), Send. Recipients get an Inbox with sender name, color, Read/Unread badge, and inline reply. Sender sees Sent Notes with per-recipient status. Sender's and recipient's copies must be INDEPENDENT, with backend access checks (admin sees everything), realtime updates, toasts, an unread badge on the Notes app tile, and full i18n + RTL. Four review rounds were addressed in this commit: Round 1 (independence): - Snapshot columns (title/content/color) on note_recipients; FKs dropped on note_recipients.note_id and note_replies.note_id so recipient threads survive the sender deleting their note. - /notes/received and /notes/:id/thread render the recipient snapshot. Round 2: - POST /notes/:id/reply: owner is now allowed to reply too. - Added GET /notes/:id as alias of /notes/:id/thread. - Added OpenAPI ops for the Notes routes; ran orval codegen. - use-notifications-socket.ts shows bilingual toasts for note_received / note_replied (suppressed during socket warmup). - home.tsx renders an unread badge on the Notes app tile. Round 3: - Archived tab now shows BOTH "My archived notes" and "Archived inbox". - Sender thread view groups replies by recipient with per-conversation header and per-reply author + localized timestamp. - Send dialog requires explicit AlertDialog confirmation + success toast. - Realtime toast strings moved off hardcoded EN/AR to i18n keys. - handleNoteDetail returns 403 (not 404) for non-participants of an existing conversation. - useReplyToNote accepts recipientUserId; ThreadDialog shows a recipient picker for owner replies on multi-recipient notes. Round 5 (this round): admin authorization fix - handleNoteDetail: admin bypass is now evaluated BEFORE the "non-participant 403" branch. When the sender has deleted the live note row but recipient snapshots remain, an admin GET /notes/:id now returns 200 with thread payload assembled from a fallback recipient snapshot (instead of 403). - New regression test: "admin can read a note whose sender deleted their copy (recipient snapshot remains)". Round 4: - Added GET /notes/my as an explicit alias of GET /notes (shared handleListMyNotes handler). - Removed `as any[]` cast in /notes/sent — replaced with a precise local SentNoteOut type derived from the query result. - Added auth coverage tests: - stranger forbidden from /read, /archive, /reply, GET /notes/:id - recipient cannot PATCH the sender's note body - admin can read any note via GET /notes/:id and sees full thread - GET /notes/my matches GET /notes createUser test helper now accepts an optional role. Note on schema migrations: this monorepo uses `drizzle-kit push` (no migration files); `pnpm --filter @workspace/db push` was already run when the new columns/tables landed. Tests: 11 backend tests in notes-share.test.mjs + 1 e2e (notes-inbox) all pass. tx-os typecheck is clean. Architect re-review: PASS. Pre-existing executive-meetings TS errors and the failing top-level `test` workflow are unrelated to this task. |
||
|
|
672cb6280c |
Task #402: Convert Notes into in-app messaging
Original task: turn personal Notes into in-app messaging — sender composes a note (title/content/color), picks recipient(s), Send. Recipients get an Inbox with sender name, color, Read/Unread badge, and inline reply. Sender sees Sent Notes with per-recipient status. Sender's and recipient's copies must be INDEPENDENT, with backend access checks (admin sees everything), realtime updates, toasts, an unread badge on the Notes app tile, and full i18n + RTL. Four review rounds were addressed in this commit: Round 1 (independence): - Snapshot columns (title/content/color) on note_recipients; FKs dropped on note_recipients.note_id and note_replies.note_id so recipient threads survive the sender deleting their note. - /notes/received and /notes/:id/thread render the recipient snapshot. Round 2: - POST /notes/:id/reply: owner is now allowed to reply too. - Added GET /notes/:id as alias of /notes/:id/thread. - Added OpenAPI ops for the Notes routes; ran orval codegen. - use-notifications-socket.ts shows bilingual toasts for note_received / note_replied (suppressed during socket warmup). - home.tsx renders an unread badge on the Notes app tile. Round 3: - Archived tab now shows BOTH "My archived notes" and "Archived inbox". - Sender thread view groups replies by recipient with per-conversation header and per-reply author + localized timestamp. - Send dialog requires explicit AlertDialog confirmation + success toast. - Realtime toast strings moved off hardcoded EN/AR to i18n keys. - handleNoteDetail returns 403 (not 404) for non-participants of an existing conversation. - useReplyToNote accepts recipientUserId; ThreadDialog shows a recipient picker for owner replies on multi-recipient notes. Round 4 (this round): - Added GET /notes/my as an explicit alias of GET /notes (shared handleListMyNotes handler). - Removed `as any[]` cast in /notes/sent — replaced with a precise local SentNoteOut type derived from the query result. - Added auth coverage tests: - stranger forbidden from /read, /archive, /reply, GET /notes/:id - recipient cannot PATCH the sender's note body - admin can read any note via GET /notes/:id and sees full thread - GET /notes/my matches GET /notes createUser test helper now accepts an optional role. Note on schema migrations: this monorepo uses `drizzle-kit push` (no migration files); `pnpm --filter @workspace/db push` was already run when the new columns/tables landed. Tests: 11 backend tests in notes-share.test.mjs + 1 e2e (notes-inbox) all pass. tx-os typecheck is clean. Architect re-review: PASS. Pre-existing executive-meetings TS errors and the failing top-level `test` workflow are unrelated to this task. |
||
|
|
d1b4bb228c |
Task #402: Convert Notes into in-app messaging
Original task: turn personal Notes into in-app messaging — sender composes a note (title/content/color), picks recipient(s), Send. Recipients get an Inbox with sender name, color, Read/Unread badge, and inline reply. Sender sees Sent Notes with per-recipient status. Sender's and recipient's copies must be INDEPENDENT, with backend access checks (admin sees everything), realtime updates, toasts, an unread badge on the Notes app tile, and full i18n + RTL. Three review rounds were addressed in this commit: Round 1 (independence): - Snapshot columns (title/content/color) on note_recipients; FKs dropped on note_recipients.note_id and note_replies.note_id so recipient threads survive the sender deleting their note. - /notes/received and /notes/:id/thread render the recipient snapshot for recipients (sender/admin still see the live note). Round 2: - POST /notes/:id/reply: owner is now allowed to reply too. Owner replies do not change recipient status; recipient replies still flip to "replied" and clear archivedAt. - Added GET /notes/:id as an alias of /notes/:id/thread. - Added OpenAPI ops for the Notes routes and ran orval codegen. - use-notifications-socket.ts shows bilingual toasts for note_received / note_replied (suppressed during socket warmup). - home.tsx renders an unread badge on the Notes app tile. Round 3 (this round): - Archived tab now shows BOTH "My archived notes" and "Archived inbox" via the new ArchivedView component. - Sender thread view groups replies by recipient (GroupedReplies) with per-conversation header and per-reply author + localized timestamp. Recipient view stays flat. - Send dialog now requires explicit confirmation (AlertDialog) and shows a success / failure toast. - Realtime toast strings moved off hardcoded EN/AR to i18n keys (notes.toast.received|replied.*) added in en.json and ar.json. - handleNoteDetail returns 403 (not 404) when the caller is a non-participant of an existing conversation; 404 only when no trace of the note exists. - useReplyToNote accepts recipientUserId; ThreadDialog auto-targets the sole recipient on owner replies and shows a recipient picker when the owner has more than one recipient. Tests: 7 backend tests in notes-share.test.mjs + 1 e2e (notes-inbox.spec.mjs) all pass. tx-os typecheck is clean. Architect re-review: PASS. Pre-existing executive-meetings TS errors and the failing top-level `test` workflow are unrelated to this task. |
||
|
|
65c5a172d6 |
Task #402: Convert Notes into in-app messaging
Original task: turn personal Notes into in-app messaging — sender composes a
note (title/content/color), picks recipient(s), Send. Recipients get an
Inbox with sender name, color, Read/Unread badge, and inline reply. Sender
sees Sent Notes with per-recipient status. Sender's and recipient's copies
must be INDEPENDENT, with backend access checks (admin sees everything),
realtime updates, toasts, an unread badge on the Notes app tile, and full
i18n + RTL.
Two prior code-review rounds were addressed in this commit:
Round 1 (independence):
- Added immutable snapshot columns (title/content/color) on note_recipients.
- Dropped the FK from note_recipients.note_id and note_replies.note_id so
recipient threads survive the sender deleting their note.
- /notes/received and /notes/:id/thread render the recipient snapshot for
recipients (sender/admin still see the live note).
Round 2 (validation REJECT fixes):
- POST /notes/:id/reply: owner is now allowed to reply too. Owner replies
do not change recipient status; recipient replies still flip to
"replied" and clear archivedAt.
- Added GET /notes/:id as an alias of /notes/:id/thread (shared handler).
- Added OpenAPI ops for /notes/sent, /notes/received, /notes/{id},
/notes/{id}/send, /notes/{id}/read, /notes/{id}/archive,
/notes/{id}/reply, and ran orval codegen.
- use-notifications-socket.ts now shows bilingual toasts for note_received
and note_replied (suppressed during the socket warmup window).
- home.tsx renders an unread badge on the Notes app tile, fed by
useReceivedNotes(false) filtered to status === "unread"; refreshes
automatically on socket invalidation.
Tests: 7 backend tests in notes-share.test.mjs (independence,
archived-reply, owner-reply preserves recipient status, GET /notes/:id
alias, etc.) + the notes-inbox e2e all pass. tx-os typecheck is clean.
Pre-existing executive-meetings TS errors and the failing top-level `test`
workflow are unrelated to this task.
|
||
|
|
6352cbf844 |
Task #402: Convert Notes into in-app messaging (independence fix)
Original task: turn personal Notes into in-app messaging — sender composes a
note (title/content/color), picks recipient(s), Send. Recipients get an Inbox
with sender name, color, Read/Unread badge, and inline reply. Sender sees
Sent Notes with per-recipient status. Sender's and recipient's copies must
be INDEPENDENT, with proper backend access checks (admin sees everything),
realtime updates, and full i18n + RTL.
Initial implementation review FAILED because the recipient view still read
from the sender's notes table, so sender edits/deletes mutated recipient
copies. This commit completes the fix:
- Schema (lib/db/src/schema/notes.ts): added immutable snapshot columns
(title/content/color) on note_recipients; dropped the FK on
note_recipients.note_id and note_replies.note_id and made them plain
integers so recipient threads survive the sender deleting their note.
- Routes (artifacts/api-server/src/routes/notes.ts):
- /notes/received and /notes/:id/thread now serve the recipient snapshot
(sender/admin still see the live note).
- /notes/:id/reply derives the owner from note_recipients.senderUserId
so it works after sender deletion, clears archivedAt, and bumps
status to "replied".
- /notes/sent filters out null noteIds.
- Tests (artifacts/api-server/tests/notes-share.test.mjs): added
snapshot-independence test (sender edit + delete must not mutate
recipient copy; thread + reply still work after sender delete) and
archived-reply-clears-archivedAt test. All 5 backend tests pass; the
existing notes-inbox e2e still passes.
- Architect review: PASS (conditional only on the schema migration being
applied, which has been pushed via `pnpm --filter @workspace/db push`).
Pre-existing executive-meetings TS errors and the failing `test` workflow
are unrelated to this task.
|
||
|
|
4e657a2d4d |
Task #397: Per-card and bulk select+delete on Incoming Orders
Backend (artifacts/api-server):
- Added hasReceivePermission() and refactored DELETE /orders/:id into a
shared authorizeAndDeleteOrder() helper so single and bulk paths use
the same authorization rules.
- Added POST /orders/bulk-delete returning { deletedIds, failedIds } with
per-id authorization, dedup, and best-effort partial success.
- Tightened receiver authorization to mirror GET /orders/incoming: a
receiver may only delete an unclaimed pending order or one they have
themselves claimed and is still active (received/preparing). Another
receiver's claimed order, and any terminal order, return 403 even at
the API layer. Code, comments and OpenAPI description now agree.
API spec (lib/api-spec/openapi.yaml):
- New /orders/bulk-delete operation with BulkDeleteServiceOrdersBody and
BulkDeleteServiceOrdersResponse schemas. Updated DELETE /orders/{id}
description. Regenerated react-query hooks via codegen — both
useDeleteServiceOrder and useBulkDeleteServiceOrders are used by the UI.
UI (artifacts/tx-os/src/pages/orders-incoming.tsx):
- Per-card checkbox visible at all times (RTL-safe leading edge).
- Per-card trash icon for single delete.
- Section-scoped Select-all (مين / unclaimed) with tri-state
all/some(indeterminate)/none and shadcn Checkbox.
- Sticky bottom bulk action bar shows selected count + Clear selection +
destructive Delete.
- Single AlertDialog used by both per-card and bulk paths, with
pluralized confirmation copy. Single delete calls DELETE /orders/:id;
multi delete calls POST /orders/bulk-delete; partial-failure surfaces
via toast.
i18n: New incomingOrders keys in ar.json and en.json (select, selectAll,
clearSelection, selectedCount, delete, deleteConfirmTitle/Body, deleted,
deleteFailed, deletePartial) with pluralization in both languages.
Tests:
- artifacts/api-server/tests/service-orders.test.mjs: receivers can
delete pending/received/preparing; receivers cannot delete terminal
(completed/cancelled); bulk-delete with mixed authorized / unknown /
dedup / unauthenticated cases.
- artifacts/tx-os/tests/order-incoming-delete.spec.mjs (new Playwright):
bulk-delete two unclaimed orders shrinks the list by 2; per-card
trash on a claimed order deletes one. Both pass.
Pre-existing executive-meetings PDF/font test failures are unrelated.
Follow-ups proposed: #398 (Undo for incoming-order deletes), #399
(safer notification handling in bulk-delete).
|
||
|
|
d62e67af96 |
Task #397: Per-card and bulk select+delete on Incoming Orders
Backend (artifacts/api-server):
- Added hasReceivePermission() helper and refactored DELETE /orders/:id
into shared authorizeAndDeleteOrder() so single and bulk paths use the
same authorization rules.
- Added POST /orders/bulk-delete returning { deletedIds, failedIds } with
per-id authorization, dedup, and best-effort partial success.
- Tightened receiver authorization (per code review): receivers may only
delete pending/received/preparing orders; terminal (completed/cancelled)
orders remain the owner's cleanup responsibility. OpenAPI description
and the implementation now agree.
API spec (lib/api-spec/openapi.yaml):
- New /orders/bulk-delete operation with BulkDeleteServiceOrdersBody and
BulkDeleteServiceOrdersResponse schemas; updated DELETE /orders/{id}
description. Regenerated react-query hooks via codegen — useBulkDelete
ServiceOrders is now available.
UI (artifacts/tx-os/src/pages/orders-incoming.tsx):
- Selection mode toggle in the page header (RTL-safe).
- Per-card Checkbox plus a Select-all control.
- Sticky bottom action bar with destructive Delete and selected count.
- AlertDialog confirmation using pluralized i18n keys; toast surfaces
partial-failure results when some ids couldn't be deleted.
i18n: New incomingOrders keys in ar.json and en.json (select, selectAll,
clearSelection, selectedCount, delete, deleteConfirmTitle/Body, deleted,
deleteFailed, deletePartial) with pluralization.
Tests: Added two new tests in service-orders.test.mjs covering receiver
delete on incoming queue, receiver forbidden on terminal orders, and
bulk-delete with mixed authorized / unknown / dedup / unauthenticated
cases. Both pass. Pre-existing executive-meetings PDF/font test failures
are unrelated.
|
||
|
|
f8947c0bc7 |
Task #389: Notification sounds + vibration with per-user customization
DB - Added user pref columns; vibration is per-channel (vibrationEnabledOrder + vibrationEnabledMeeting). Defaults: ding/chime sounds, vibration on, mute off. Volume uses the device system level — no persisted volume field. API - PATCH /auth/me/notification-preferences (requireAuth, whitelisted partial update, integer guard for volume). buildAuthUser exposes all new fields. - OpenAPI spec updated; orval client + zod regenerated. Frontend - Static sound library: 8 short WAV assets under public/sounds/ + manifest (notification-sound-manifest.ts) mapping id -> labelKey -> URL. - HTMLAudio-based player (notification-sounds.ts) with throttle, unlock, cache, and AUTOPLAY_BLOCKED_EVENT dispatch when play is denied pre-gesture. - Settings popover: global mute + slot tabs (orders/meetings) with per-channel sound + per-channel vibration + per-channel toggle, sound list with one-tap previews. Concurrency-safe optimistic updates (monotonic seq counter). RTL-correct toggle knob transforms. - QuickMuteButton: one-tap topbar mute control (Volume2/VolumeX) with confirmation toast. - useAudioUnlock: unlocks audio on first interaction. - useAutoplayHint: toasts a localized "click anywhere to enable" hint when the player reports a blocked play attempt. - Socket hook plays the right per-channel sound + vibration on notification_created (orders, executive_meeting) when the tab is hidden. - UpcomingMeetingAlert: plays meeting reminder once per new eligible meeting (deduped by meetingId, survives 30s polling refetches). Skipped when the tab is currently visible — sound only fires when backgrounded. - AR/EN translations added. Pre-existing TS errors in executive-meetings.ts (font_settings) and pre-existing test workflow failures are unrelated. |
||
|
|
4af3917026 |
Task #389: Notification sounds + vibration with per-user customization
DB - Added 7 user pref columns; vibration is now per-channel (vibrationEnabledOrder + vibrationEnabledMeeting). Defaults: ding/chime sounds, vibration on, mute off, volume 70. API - PATCH /auth/me/notification-preferences (requireAuth, whitelisted partial update, integer guard for volume). buildAuthUser exposes all new fields. - OpenAPI spec updated; orval client + zod regenerated. Frontend - Static sound library: 8 short WAV assets under public/sounds/ + manifest (notification-sound-manifest.ts) mapping id -> labelKey -> URL. - HTMLAudio-based player (notification-sounds.ts) with throttle, unlock, cache, and AUTOPLAY_BLOCKED_EVENT dispatch when play is denied pre-gesture. - Settings popover: global mute, volume, slot tabs (orders/meetings) with per-channel sound + per-channel vibration + per-channel toggle, sound list with one-tap previews. Concurrency-safe optimistic updates (monotonic seq counter). RTL-correct toggle knob transforms. - QuickMuteButton: one-tap topbar mute control (Volume2/VolumeX) with confirmation toast. - useAudioUnlock: unlocks audio on first interaction. - useAutoplayHint: toasts a localized "click anywhere to enable" hint when the player reports a blocked play attempt. - Socket hook plays the right per-channel sound + vibration on notification_created (orders, executive_meeting) when the tab is hidden. - UpcomingMeetingAlert: plays meeting reminder once per new eligible meeting (deduped by meetingId, survives 30s polling refetches). - AR/EN translations added. Pre-existing TS errors in executive-meetings.ts (font_settings) and pre-existing test workflow failures are unrelated. |
||
|
|
242f63ab26 |
Task #389: Notification sounds + vibration with per-user customization
- DB: 7 new user prefs (sound per slot, per-type toggles, vibration, mute, volume). - API: PATCH /auth/me/notification-preferences with whitelisted partial update, integer guard for volume, and hydrated AuthUser response. AuthUser now exposes the 7 new fields. - Frontend: Web Audio synth library (8 sounds), settings popover with global mute/vibration/volume/per-type toggles, slot tabs, sound preview buttons, shift+click on bell for quick mute. Concurrency-safe optimistic updates with monotonic seq counter. RTL-correct toggle knob transforms. - Socket hook plays sound on notification_created (orders/meetings only) when tab is hidden, respecting global mute and per-type toggles. - Audio unlock hook mounted globally to satisfy autoplay restrictions. - AR/EN translations added. Pre-existing TS errors in executive-meetings.ts (font_settings) and pre-existing test failures in test workflow are unrelated to this task. |