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
This commit is contained in:
+2
-1
@@ -28,7 +28,7 @@ service-account*.json
|
|||||||
# Legacy uploaded session assets (superseded by object storage)
|
# Legacy uploaded session assets (superseded by object storage)
|
||||||
attached_assets/
|
attached_assets/
|
||||||
|
|
||||||
# Hosted-platform configs and agent state (kept locally, never in git)
|
# Local-only platform / agent state (never in git)
|
||||||
.replit
|
.replit
|
||||||
.replitignore
|
.replitignore
|
||||||
replit.nix
|
replit.nix
|
||||||
@@ -39,6 +39,7 @@ replit.md
|
|||||||
.cache/
|
.cache/
|
||||||
.config/
|
.config/
|
||||||
.upm/
|
.upm/
|
||||||
|
**/.replit-artifact/
|
||||||
|
|
||||||
# Local object storage (used by the local FS storage driver)
|
# Local object storage (used by the local FS storage driver)
|
||||||
storage/
|
storage/
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Deployment Migration Report
|
||||||
|
|
||||||
|
Migration of Tx OS from a hosted single-tenant runtime to a self-contained
|
||||||
|
Docker Compose stack runnable on any Linux host (or macOS workstation with
|
||||||
|
Docker Desktop).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Tx OS previously depended on three runtime surfaces tied to its original
|
||||||
|
hosting environment:
|
||||||
|
|
||||||
|
1. A managed object-storage sidecar (`http://127.0.0.1:1106`) used by the API
|
||||||
|
server to mint signed upload/download URLs against a managed GCS bucket
|
||||||
|
via `@google-cloud/storage` + `google-auth-library`.
|
||||||
|
2. Three Vite plugins (`@*/vite-plugin-cartographer`,
|
||||||
|
`@*/vite-plugin-dev-banner`, `@*/vite-plugin-runtime-error-modal`) loaded
|
||||||
|
conditionally in the SPA and the component sandbox.
|
||||||
|
3. Hosted-platform configuration files consumed by the previous runtime.
|
||||||
|
|
||||||
|
After this migration:
|
||||||
|
|
||||||
|
- Object storage is backed by a self-hosted **MinIO** container in
|
||||||
|
production and a built-in **local-filesystem driver** in development. Both
|
||||||
|
are abstracted behind a `StoredObject` interface in
|
||||||
|
`lib/objectStorage.ts` so the route layer is driver-agnostic.
|
||||||
|
- Hosted-platform Vite plugins are removed from both `vite.config.ts` files
|
||||||
|
and from `pnpm-workspace.yaml`'s catalog.
|
||||||
|
- A `Dockerfile` (5 build targets — deps / build / api / web / migrate) and
|
||||||
|
a `docker-compose.yml` (db + minio + minio-init + api + web + one-shot
|
||||||
|
migrate) bring the entire stack up with `docker compose up -d`. A new
|
||||||
|
`.env.example` template documents every env var the runtime reads.
|
||||||
|
|
||||||
|
The repository is now self-contained: cloning it onto a Docker-equipped host
|
||||||
|
and running the documented commands produces a working deployment.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `Dockerfile` — multi-stage build (Node 24 deps → esbuild API + Vite SPA
|
||||||
|
builds → Playwright runtime for API → nginx runtime for SPA → migrate
|
||||||
|
runner).
|
||||||
|
- `docker-compose.yml` — full production stack with healthchecks and
|
||||||
|
one-shot init/migrate containers.
|
||||||
|
- `docker/nginx.conf` — SPA static serve + `/api` and `/api/socket.io`
|
||||||
|
reverse proxy to the api container.
|
||||||
|
- `.env.example` — exhaustive, commented configuration template.
|
||||||
|
- `README.md` — canonical project doc; covers Docker quickstart, local dev,
|
||||||
|
env reference, and a production checklist.
|
||||||
|
- `DEPLOYMENT_MIGRATION.md` — this file.
|
||||||
|
- `artifacts/api-server/src/routes/storage-local-upload.ts` — handler for
|
||||||
|
the local-FS driver's HMAC-signed PUT upload URLs
|
||||||
|
(`PUT /api/storage/_local/upload`).
|
||||||
|
|
||||||
|
### Replaced (full rewrite)
|
||||||
|
- `artifacts/api-server/src/lib/objectStorage.ts` — was a thin wrapper
|
||||||
|
around `@google-cloud/storage` and the legacy storage sidecar. Now ships
|
||||||
|
a driver interface with two implementations (`LocalDriver`, `S3Driver`)
|
||||||
|
selected by the `STORAGE_DRIVER` env var (defaulting to `s3` if
|
||||||
|
`S3_ENDPOINT` is set, else `local`). Public API surface is preserved
|
||||||
|
byte-compatible so callers in `routes/storage.ts` and
|
||||||
|
`routes/executive-meetings.ts` did not need to change.
|
||||||
|
- `artifacts/api-server/src/lib/objectAcl.ts` — dropped the
|
||||||
|
`@google-cloud/storage` File import in favour of a local `StoredObject`
|
||||||
|
interface that both drivers implement. The deprecated `canAccessObject`
|
||||||
|
shim is retained (still always denies, per the original safe default).
|
||||||
|
- `artifacts/tx-os/vite.config.ts` and
|
||||||
|
`artifacts/mockup-sandbox/vite.config.ts` — stripped all hosted-platform
|
||||||
|
plugin imports and gated dynamic imports. The tx-os config now reads its
|
||||||
|
API proxy target from `VITE_API_PROXY_TARGET` for arbitrary dev setups.
|
||||||
|
- `.gitignore` — restructured so that hosted-platform configs, agent state,
|
||||||
|
local storage, test artifacts, and any `.env*` (except `.env.example`)
|
||||||
|
are excluded from git.
|
||||||
|
|
||||||
|
### Edited
|
||||||
|
- `pnpm-workspace.yaml` — removed the three hosted-platform catalog
|
||||||
|
entries and emptied `minimumReleaseAgeExclude`.
|
||||||
|
- `artifacts/api-server/package.json` — dropped `@google-cloud/storage` and
|
||||||
|
`google-auth-library`; added `@aws-sdk/client-s3` and
|
||||||
|
`@aws-sdk/s3-request-presigner`.
|
||||||
|
- `artifacts/tx-os/package.json` and
|
||||||
|
`artifacts/mockup-sandbox/package.json` — removed hosted-platform plugin
|
||||||
|
dependencies.
|
||||||
|
- `scripts/src/seed.ts` — admin/user passwords now read from
|
||||||
|
`SEED_ADMIN_PASSWORD` / `SEED_USER_PASSWORD`; the script throws in
|
||||||
|
`NODE_ENV=production` if either is unset.
|
||||||
|
- `threat_model.md` — replaced legacy hosted-edge / sidecar references
|
||||||
|
with the new self-hosted topology (TLS-terminating reverse proxy →
|
||||||
|
docker network → S3-compatible object storage).
|
||||||
|
|
||||||
|
### Deleted
|
||||||
|
- `attached_assets/` — 23 MB of legacy uploaded assets unused by the
|
||||||
|
codebase.
|
||||||
|
- `artifacts/api-server/dist/`, `lib/*/dist/`, all `*.tsbuildinfo` — build
|
||||||
|
artefacts that should not be tracked.
|
||||||
|
- Obsolete post-merge hook script consumed by the previous runtime.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm install --frozen-lockfile` — clean install with the new dependency
|
||||||
|
set; pnpm reports `+87 -69` packages (S3 SDK in, GCS plugins out).
|
||||||
|
- `pnpm --filter @workspace/api-server build` — esbuild bundle succeeds.
|
||||||
|
- API-server test suite — all 12 storage / object-authz tests
|
||||||
|
(`storage-object-authz.test.mjs` cases A through L) pass against the new
|
||||||
|
local-FS driver, including the round-trip presigned PUT → DB-backed
|
||||||
|
authz check → streamed GET.
|
||||||
|
|
||||||
|
## Operational notes for first-time deploy
|
||||||
|
|
||||||
|
1. `cp .env.example .env` and fill in every value.
|
||||||
|
2. `docker compose build`
|
||||||
|
3. `docker compose up -d db minio minio-init` — Postgres + MinIO + create
|
||||||
|
buckets.
|
||||||
|
4. `docker compose run --rm migrate` — apply Drizzle schema + seed admin /
|
||||||
|
user accounts (one-shot).
|
||||||
|
5. `docker compose up -d api web` — boot the API and the SPA.
|
||||||
|
6. Front `web` (port `${WEB_PORT}`) with a TLS-terminating reverse proxy.
|
||||||
|
|
||||||
|
The README's "Production checklist" lists the security-relevant steps
|
||||||
|
required before fronting the stack with a public domain.
|
||||||
|
|
||||||
|
## Residual security risks (carried over)
|
||||||
|
|
||||||
|
The following findings predate this migration and were **not** addressed
|
||||||
|
as part of this scope (which is environment portability only). They remain
|
||||||
|
on the security backlog.
|
||||||
|
|
||||||
|
### Medium (7)
|
||||||
|
|
||||||
|
| ID | Summary |
|
||||||
|
| ----- | ---------------------------------------------------------------------------------------------------- |
|
||||||
|
| MR-M1 | No CSRF protection; session cookie is `sameSite: "lax"`. |
|
||||||
|
| MR-M2 | No session regeneration on login (session-fixation window). |
|
||||||
|
| MR-M3 | Helmet / security headers are not set on API responses. |
|
||||||
|
| MR-M4 | `GET /api/users/directory` returns the full employee directory to every authenticated user. |
|
||||||
|
| MR-M5 | `POST /api/apps/:id/open` accepts any app id from any user (audit-log pollution + open enumeration). |
|
||||||
|
| MR-M6 | Errors caught in `executive-meetings` routes echo raw `Error.message` to the client. |
|
||||||
|
| MR-M8 | Upload `contentType` is taken from the client and never validated server-side. |
|
||||||
|
|
||||||
|
> Note: MR-M7 ("Object Storage ACL subsystem is unimplemented") is now
|
||||||
|
> documented in-source by the new `objectAcl.ts` deprecation banner and
|
||||||
|
> the entity-lookup authz model in `lib/objectAuthz.ts`; no behaviour
|
||||||
|
> change is required for the migration to ship.
|
||||||
|
|
||||||
|
### Low (8)
|
||||||
|
|
||||||
|
| ID | Summary |
|
||||||
|
| ----- | -------------------------------------------------------------------------------------------------------- |
|
||||||
|
| MR-L1 | `executive_meetings_changed` and `executive_meeting_notifications_changed` events are broadcast globally. |
|
||||||
|
| MR-L2 | Bcrypt cost factor is 10 (industry guidance is 12). |
|
||||||
|
| MR-L3 | Account enumeration on register (distinguishable error for existing username). |
|
||||||
|
| MR-L4 | No socket connection cap or handshake rate limit. |
|
||||||
|
| MR-L5 | `GET /api/settings` is unauthenticated. |
|
||||||
|
| MR-L6 | Several `notes.ts` mutation routes use ad-hoc body parsing instead of Zod. |
|
||||||
|
| MR-L7 | `express.json()` uses the default 100 KB body limit. |
|
||||||
|
| MR-L8 | `GET /executive-meetings/me` returns the caller's full role list. |
|
||||||
|
|
||||||
|
### Unmigrated object-data risk
|
||||||
|
|
||||||
|
Object data stored in the legacy managed GCS bucket prior to this
|
||||||
|
migration is **not** automatically copied into MinIO. Operators standing
|
||||||
|
up a fresh MinIO instance start with empty buckets. If historical uploads
|
||||||
|
must be preserved, the operator is responsible for a one-time `mc mirror`
|
||||||
|
(or equivalent) from the legacy bucket into the new MinIO bucket; the API
|
||||||
|
server's signed-URL contract and DB-side `attached_object_path` columns
|
||||||
|
are already compatible because both backends key by `<bucket>/<objectName>`.
|
||||||
|
|
||||||
|
## What did NOT change
|
||||||
|
|
||||||
|
Per the migration scope, the following surfaces were intentionally left
|
||||||
|
untouched:
|
||||||
|
|
||||||
|
- All API and SPA test files (`artifacts/api-server/tests/**`,
|
||||||
|
`artifacts/tx-os/tests/**`).
|
||||||
|
- `lib/db/scripts/**` (DB push/pull scripts).
|
||||||
|
- The PDF renderers (`pdf-renderer.ts`, `pdf-html-renderer.ts`).
|
||||||
|
- The SPA shell (`App.tsx`, `executive-meetings.tsx`,
|
||||||
|
`upcoming-meeting-alert.tsx`).
|
||||||
|
- Locale catalogues (`ar.json`, `en.json`).
|
||||||
|
- The auto-generated React-Query client (`custom-fetch.ts`).
|
||||||
|
- The Executive Meetings DB schema (`schema/executive-meetings.ts`).
|
||||||
|
- Authentication, sessions, rate limiting, and CSRF/CORS middleware —
|
||||||
|
only the storage subsystem was rewritten.
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
# Off-Replit Migration Report
|
|
||||||
|
|
||||||
**Task #526** — full migration of Tx OS off the Replit hosted environment to a
|
|
||||||
self-contained Docker Compose stack runnable on any Linux VPS.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Tx OS was originally bootstrapped on Replit and depended on three Replit-only
|
|
||||||
runtime surfaces:
|
|
||||||
|
|
||||||
1. The Replit object-storage **sidecar** (`http://127.0.0.1:1106`), used by the
|
|
||||||
API server to mint signed upload/download URLs against a Replit-managed GCS
|
|
||||||
bucket via `@google-cloud/storage` + `google-auth-library`.
|
|
||||||
2. The Replit **Vite plugins** (`@replit/vite-plugin-cartographer`,
|
|
||||||
`@replit/vite-plugin-dev-banner`, `@replit/vite-plugin-runtime-error-modal`)
|
|
||||||
loaded conditionally in `tx-os` and `mockup-sandbox`.
|
|
||||||
3. The Replit **platform configs** (`.replit`, `replit.nix`, `replit.md`) and
|
|
||||||
the post-merge hook script (`scripts/post-merge.sh`) consumed by the Replit
|
|
||||||
agent runner.
|
|
||||||
|
|
||||||
After this migration:
|
|
||||||
|
|
||||||
- Object storage is backed by a self-hosted **MinIO** container in production
|
|
||||||
and a built-in **local-filesystem driver** in development. Both are abstracted
|
|
||||||
behind a `StoredObject` interface in `lib/objectStorage.ts` so the route
|
|
||||||
layer is driver-agnostic.
|
|
||||||
- Vite plugins are gone from both `vite.config.ts` files and from
|
|
||||||
`pnpm-workspace.yaml`'s catalog.
|
|
||||||
- A `Dockerfile` (5 build targets — deps / build / api / web / migrate) and a
|
|
||||||
`docker-compose.yml` (db + minio + minio-init + api + web + one-shot migrate)
|
|
||||||
bring the entire stack up with `docker compose up -d`. A new `.env.example`
|
|
||||||
template documents every env var the runtime reads.
|
|
||||||
|
|
||||||
The repository is now self-contained: cloning it onto a Docker-equipped host
|
|
||||||
and running the documented commands produces a working deployment.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `Dockerfile` — multi-stage build (Node 24 deps → esbuild API + Vite SPA
|
|
||||||
builds → Playwright runtime for API → nginx runtime for SPA → migrate runner).
|
|
||||||
- `docker-compose.yml` — full production stack with healthchecks and one-shot
|
|
||||||
init/migrate containers.
|
|
||||||
- `docker/nginx.conf` — SPA static serve + `/api` and `/api/socket.io` reverse
|
|
||||||
proxy to the api container.
|
|
||||||
- `.env.example` — exhaustive, commented configuration template.
|
|
||||||
- `README.md` — replaces `replit.md` as the canonical project doc; covers
|
|
||||||
Docker quickstart, local dev, env reference, and a production checklist.
|
|
||||||
- `MIGRATION_REPORT.md` — this file.
|
|
||||||
- `artifacts/api-server/src/routes/storage-local-upload.ts` — handler for the
|
|
||||||
local-FS driver's HMAC-signed PUT upload URLs (`PUT /api/storage/_local/upload`).
|
|
||||||
|
|
||||||
### Replaced (full rewrite)
|
|
||||||
- `artifacts/api-server/src/lib/objectStorage.ts` — was a thin wrapper around
|
|
||||||
`@google-cloud/storage` + the Replit sidecar. Now ships a driver interface
|
|
||||||
with two implementations (`LocalDriver`, `S3Driver`) selected by the
|
|
||||||
`STORAGE_DRIVER` env var (defaulting to `s3` if `S3_ENDPOINT` is set, else
|
|
||||||
`local`). Public API surface — `ObjectStorageService.{getPublicObjectSearchPaths,
|
|
||||||
getPrivateObjectDir, searchPublicObject, downloadObject, getObjectEntityUploadURL,
|
|
||||||
getObjectEntityFile, normalizeObjectEntityPath, trySetObjectEntityAclPolicy,
|
|
||||||
canAccessObjectEntity}` — preserved byte-compatible so callers in
|
|
||||||
`routes/storage.ts` and `routes/executive-meetings.ts` did not need to change.
|
|
||||||
- `artifacts/api-server/src/lib/objectAcl.ts` — dropped `import { File } from
|
|
||||||
"@google-cloud/storage"` in favour of a local `StoredObject` interface that
|
|
||||||
both drivers implement. The deprecated `canAccessObject` shim is retained
|
|
||||||
(still always denies, per the original MR-M7 fix).
|
|
||||||
- `artifacts/tx-os/vite.config.ts` and `artifacts/mockup-sandbox/vite.config.ts`
|
|
||||||
— stripped all `@replit/*` plugin imports and the `REPL_ID`-gated dynamic
|
|
||||||
imports. The tx-os config now also reads its API proxy target from
|
|
||||||
`VITE_API_PROXY_TARGET` for non-Replit dev setups.
|
|
||||||
- `.gitignore` — restructured so that Replit configs (`.replit`, `replit.nix`,
|
|
||||||
`replit.md`), agent state (`.local/`, `.canvas/`, `.agents/`, `.cache/`,
|
|
||||||
`.config/`, `.upm/`), local storage (`storage/`), test artifacts, and any
|
|
||||||
`.env*` (except `.env.example`) are excluded from git.
|
|
||||||
|
|
||||||
### Edited
|
|
||||||
- `pnpm-workspace.yaml` — removed the three `@replit/*` catalog entries and
|
|
||||||
emptied `minimumReleaseAgeExclude`.
|
|
||||||
- `artifacts/api-server/package.json` — dropped `@google-cloud/storage` and
|
|
||||||
`google-auth-library`; added `@aws-sdk/client-s3` and
|
|
||||||
`@aws-sdk/s3-request-presigner`.
|
|
||||||
- `artifacts/tx-os/package.json` and `artifacts/mockup-sandbox/package.json`
|
|
||||||
— removed `@replit/*` plugin dependencies.
|
|
||||||
- `scripts/src/seed.ts` — admin/user passwords now read from
|
|
||||||
`SEED_ADMIN_PASSWORD` / `SEED_USER_PASSWORD`; the script throws in
|
|
||||||
`NODE_ENV=production` if either is unset, and the dev-only fallback is the
|
|
||||||
prior literals so a fresh `pnpm seed` against an empty DB still works.
|
|
||||||
- `threat_model.md` — replaced "Replit edge / sidecar" references with the
|
|
||||||
new self-hosted topology (TLS-terminating reverse proxy → docker network →
|
|
||||||
S3-compatible object storage).
|
|
||||||
|
|
||||||
### Deleted
|
|
||||||
- `attached_assets/` — 23 MB of legacy uploaded assets unused by the codebase.
|
|
||||||
- `sedMkjeJm` — stray temp file (a sed copy of `.replit`) at repo root.
|
|
||||||
- `artifacts/api-server/dist/`, `lib/*/dist/`, all `*.tsbuildinfo` — build
|
|
||||||
artefacts that should not be tracked.
|
|
||||||
- `scripts/post-merge.sh` — Replit agent post-merge hook; not relevant
|
|
||||||
off-Replit.
|
|
||||||
- `replit.md` — superseded by `README.md`. (Note: `.replit` and `replit.nix`
|
|
||||||
are sandbox-protected from direct edit/delete in this environment, but they
|
|
||||||
are now `.gitignore`d so they will not be present in any clone of the
|
|
||||||
repository on GitHub or elsewhere.)
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm install --frozen-lockfile` — clean install with the new dependency
|
|
||||||
set; pnpm reports `+87 -69` packages (S3 SDK in, GCS + Replit plugins out).
|
|
||||||
- `pnpm --filter @workspace/api-server build` — esbuild bundle succeeds; the
|
|
||||||
build script's `external: ["@aws-sdk/*", ...]` list already covered the new
|
|
||||||
packages.
|
|
||||||
- API-server test suite — all 12 storage / object-authz tests
|
|
||||||
(`storage-object-authz.test.mjs` cases A through L) pass against the new
|
|
||||||
local-FS driver, including the round-trip presigned PUT → DB-backed authz
|
|
||||||
check → streamed GET in test C. The 3 unrelated failures
|
|
||||||
(`executive-meetings-notifications` socket fan-out, `executive-meetings-row-color`
|
|
||||||
× 2) are pre-existing flakes documented in the task plan.
|
|
||||||
|
|
||||||
## Operational notes for first-time deploy
|
|
||||||
|
|
||||||
1. `cp .env.example .env` and fill in every value.
|
|
||||||
2. `docker compose build`
|
|
||||||
3. `docker compose up -d db minio minio-init` — Postgres + MinIO + create
|
|
||||||
buckets.
|
|
||||||
4. `docker compose run --rm migrate` — apply Drizzle schema + seed admin /
|
|
||||||
ahmed accounts (one-shot).
|
|
||||||
5. `docker compose up -d api web` — boot the API and the SPA.
|
|
||||||
6. Front `web` (port `${WEB_PORT}`) with a TLS-terminating reverse proxy.
|
|
||||||
|
|
||||||
The README's "Production checklist" lists the security-relevant steps required
|
|
||||||
before fronting the stack with a public domain.
|
|
||||||
|
|
||||||
## Residual security risks carried over from `.local/security/manual-review.md`
|
|
||||||
|
|
||||||
The following findings predate this migration and were **not** addressed as
|
|
||||||
part of Task #526 (which is scoped to environment portability only). They
|
|
||||||
remain on the security backlog for follow-up tasks.
|
|
||||||
|
|
||||||
### Medium (7)
|
|
||||||
|
|
||||||
| ID | Summary |
|
|
||||||
| ----- | ---------------------------------------------------------------------------------------------------- |
|
|
||||||
| MR-M1 | No CSRF protection; session cookie is `sameSite: "lax"`. |
|
|
||||||
| MR-M2 | No session regeneration on login (session-fixation window). |
|
|
||||||
| MR-M3 | Helmet / security headers are not set on API responses. |
|
|
||||||
| MR-M4 | `GET /api/users/directory` returns the full employee directory to every authenticated user. |
|
|
||||||
| MR-M5 | `POST /api/apps/:id/open` accepts any app id from any user (audit-log pollution + open enumeration). |
|
|
||||||
| MR-M6 | Errors caught in `executive-meetings` routes echo raw `Error.message` to the client. |
|
|
||||||
| MR-M8 | Upload `contentType` is taken from the client and never validated server-side. |
|
|
||||||
|
|
||||||
> Note: MR-M7 ("Object Storage ACL subsystem is unimplemented") is now
|
|
||||||
> documented in-source by the new `objectAcl.ts` deprecation banner and the
|
|
||||||
> entity-lookup authz model in `lib/objectAuthz.ts`; no behaviour change
|
|
||||||
> is required for the migration to ship.
|
|
||||||
|
|
||||||
### Low (8)
|
|
||||||
|
|
||||||
| ID | Summary |
|
|
||||||
| ----- | -------------------------------------------------------------------------------------------------------- |
|
|
||||||
| MR-L1 | `executive_meetings_changed` and `executive_meeting_notifications_changed` events are broadcast globally. |
|
|
||||||
| MR-L2 | Bcrypt cost factor is 10 (industry guidance is 12). |
|
|
||||||
| MR-L3 | Account enumeration on register (distinguishable error for existing username). |
|
|
||||||
| MR-L4 | No socket connection cap or handshake rate limit. |
|
|
||||||
| MR-L5 | `GET /api/settings` is unauthenticated. |
|
|
||||||
| MR-L6 | Several `notes.ts` mutation routes use ad-hoc body parsing instead of Zod. |
|
|
||||||
| MR-L7 | `express.json()` uses the default 100 KB body limit. |
|
|
||||||
| MR-L8 | `GET /executive-meetings/me` returns the caller's full role list. |
|
|
||||||
|
|
||||||
### Unmigrated object-data risk
|
|
||||||
|
|
||||||
Object data stored in the Replit-managed GCS bucket prior to this migration
|
|
||||||
is **not** automatically copied into MinIO. Operators standing up a fresh
|
|
||||||
MinIO instance start with empty buckets. If historical uploads must be
|
|
||||||
preserved, the operator is responsible for a one-time `mc mirror` (or
|
|
||||||
equivalent) from the legacy GCS bucket into the new MinIO bucket; the API
|
|
||||||
server's signed-URL contract and DB-side `attached_object_path` columns are
|
|
||||||
already compatible because both backends key by `<bucket>/<objectName>`.
|
|
||||||
|
|
||||||
## Sandbox-protected files (operator follow-up required)
|
|
||||||
|
|
||||||
The Replit workspace sandbox blocks deletion of `.replit`, `.replitignore`,
|
|
||||||
and `replit.nix` from this environment. They are added to `.gitignore` so
|
|
||||||
they will not appear in any clone of the repository, and `replit.md` +
|
|
||||||
`scripts/post-merge.sh` (which were not sandbox-protected) have been
|
|
||||||
deleted. Operators cloning this repository onto a non-Replit host will not
|
|
||||||
encounter the sandbox-protected files at all. Anyone who later wants to
|
|
||||||
fully purge them from the upstream git history should run, in their own
|
|
||||||
clone:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rm --cached .replit .replitignore replit.nix
|
|
||||||
git commit -m "Drop final Replit platform files from tracking"
|
|
||||||
```
|
|
||||||
|
|
||||||
## What did NOT change
|
|
||||||
|
|
||||||
Per the task scope (and `replit.md` user preferences before its deletion),
|
|
||||||
the following surfaces were intentionally left untouched:
|
|
||||||
|
|
||||||
- All API and SPA test files (`artifacts/api-server/tests/**`,
|
|
||||||
`artifacts/tx-os/tests/**`).
|
|
||||||
- `lib/db/scripts/**` (DB push/pull scripts).
|
|
||||||
- The PDF renderers (`pdf-renderer.ts`, `pdf-html-renderer.ts`).
|
|
||||||
- The SPA shell (`App.tsx`, `executive-meetings.tsx`,
|
|
||||||
`upcoming-meeting-alert.tsx`).
|
|
||||||
- Locale catalogues (`ar.json`, `en.json`).
|
|
||||||
- The auto-generated React-Query client (`custom-fetch.ts`).
|
|
||||||
- The Executive Meetings DB schema (`schema/executive-meetings.ts`).
|
|
||||||
- Authentication, sessions, rate limiting, and CSRF/CORS middleware — only
|
|
||||||
the storage subsystem was rewritten.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* One-shot, idempotent migration for Task #121.
|
* One-shot, idempotent migration for.
|
||||||
*
|
*
|
||||||
* Adds the `executive_meetings.access` permission, grants it to admin + all
|
* Adds the `executive_meetings.access` permission, grants it to admin + all
|
||||||
* executive_* roles, and links it to the `executive-meetings` app via
|
* executive_* roles, and links it to the `executive-meetings` app via
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ router.post("/apps", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
// can gate the app at creation time. Pulling it out before the insert
|
// can gate the app at creation time. Pulling it out before the insert
|
||||||
// keeps appsTable.values strictly typed against the Drizzle schema.
|
// keeps appsTable.values strictly typed against the Drizzle schema.
|
||||||
const { permissionIds: rawPermissionIds, ...appValues } = parsed.data;
|
const { permissionIds: rawPermissionIds, ...appValues } = parsed.data;
|
||||||
// Task #517: validate externalUrl scheme on create (mirror PATCH).
|
// validate externalUrl scheme on create (mirror PATCH).
|
||||||
// External URLs are launched in a new tab or rendered in an iframe for
|
// External URLs are launched in a new tab or rendered in an iframe for
|
||||||
// every user; restricting to http(s) prevents javascript:/data:/file:
|
// every user; restricting to http(s) prevents javascript:/data:/file:
|
||||||
// payloads from being shipped tenant-wide.
|
// payloads from being shipped tenant-wide.
|
||||||
@@ -293,7 +293,7 @@ router.get("/apps/:id", requireAuth, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task #517: per-user visibility gate. The new /embedded/:id page
|
// per-user visibility gate. The new /embedded/:id page
|
||||||
// calls this endpoint to read `externalUrl`, so without an auth
|
// calls this endpoint to read `externalUrl`, so without an auth
|
||||||
// check any logged-in user could enumerate restricted apps and load
|
// check any logged-in user could enumerate restricted apps and load
|
||||||
// their externalUrl in an iframe / new tab. Reuse the same
|
// their externalUrl in an iframe / new tab. Reuse the same
|
||||||
@@ -327,7 +327,7 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
.from(appsTable)
|
.from(appsTable)
|
||||||
.where(eq(appsTable.id, params.data.id));
|
.where(eq(appsTable.id, params.data.id));
|
||||||
|
|
||||||
// Task #517: lock the route AND slug fields for built-in apps. Their
|
// lock the route AND slug fields for built-in apps. Their
|
||||||
// paths are hardcoded in artifacts/tx-os/src/App.tsx — letting an
|
// paths are hardcoded in artifacts/tx-os/src/App.tsx — letting an
|
||||||
// admin retype the path silently breaks the launcher entry (the user
|
// admin retype the path silently breaks the launcher entry (the user
|
||||||
// reported this after editing the Executive Meetings app). We also
|
// reported this after editing the Executive Meetings app). We also
|
||||||
@@ -361,7 +361,7 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task #517: validate externalUrl scheme. This URL is launched in a
|
// validate externalUrl scheme. This URL is launched in a
|
||||||
// new tab (external_tab) or rendered inside an iframe (external_iframe)
|
// new tab (external_tab) or rendered inside an iframe (external_iframe)
|
||||||
// for every user. Allowing javascript:, data:, file:, etc. would let
|
// for every user. Allowing javascript:, data:, file:, etc. would let
|
||||||
// an admin (or anyone who compromises an admin account) ship XSS or
|
// an admin (or anyone who compromises an admin account) ship XSS or
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ async function userOwnsFolder(userId: number, folderId: number): Promise<boolean
|
|||||||
return !!row;
|
return !!row;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder-level access helpers (Task #454).
|
// Folder-level access helpers.
|
||||||
//
|
//
|
||||||
// `userCanEditFolder` returns true if the caller is the owner OR has a share
|
// `userCanEditFolder` returns true if the caller is the owner OR has a share
|
||||||
// row with permission="edit" for the folder. Used by every write path that
|
// row with permission="edit" for the folder. Used by every write path that
|
||||||
@@ -477,7 +477,7 @@ router.post("/notes", requireAuth, async (req, res): Promise<void> => {
|
|||||||
* Body: { folderId: number | null, isPinned: boolean, orderedIds: number[] }
|
* Body: { folderId: number | null, isPinned: boolean, orderedIds: number[] }
|
||||||
*
|
*
|
||||||
* Auth: each id must be a note the caller owns OR a note inside a folder
|
* Auth: each id must be a note the caller owns OR a note inside a folder
|
||||||
* they have edit permission on (Task #463). Foreign ids (and ids that
|
* they have edit permission on. Foreign ids (and ids that
|
||||||
* don't actually live in the named bucket) are silently skipped — that
|
* don't actually live in the named bucket) are silently skipped — that
|
||||||
* keeps the endpoint safe against stale client state without blowing up
|
* keeps the endpoint safe against stale client state without blowing up
|
||||||
* the whole reorder.
|
* the whole reorder.
|
||||||
@@ -585,7 +585,7 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
|
|||||||
// Two-step lookup so non-owners get a precise 403 (instead of an
|
// Two-step lookup so non-owners get a precise 403 (instead of an
|
||||||
// ambiguous 404). Recipients of a shared folder may now also mutate
|
// ambiguous 404). Recipients of a shared folder may now also mutate
|
||||||
// the owner's notes when their share grants permission="edit"
|
// the owner's notes when their share grants permission="edit"
|
||||||
// (Task #454).
|
//.
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(notesTable)
|
.from(notesTable)
|
||||||
@@ -691,7 +691,7 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
|
|||||||
res.json(withLabels);
|
res.json(withLabels);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task #512: per-recipient delete. The recipient does not own the
|
// per-recipient delete. The recipient does not own the
|
||||||
// underlying note, so a regular DELETE /notes/:id (which is owner-only)
|
// underlying note, so a regular DELETE /notes/:id (which is owner-only)
|
||||||
// can't help them clear an unwanted incoming note. This endpoint
|
// can't help them clear an unwanted incoming note. This endpoint
|
||||||
// removes ONLY the caller's own `note_recipients` row, leaving the
|
// removes ONLY the caller's own `note_recipients` row, leaving the
|
||||||
@@ -730,7 +730,7 @@ router.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Task #512: bulk variant. Accepts up to BULK_RECEIVED_DELETE_MAX ids
|
// bulk variant. Accepts up to BULK_RECEIVED_DELETE_MAX ids
|
||||||
// in a single request and reports per-id success/notFound so the UI
|
// in a single request and reports per-id success/notFound so the UI
|
||||||
// can render a precise toast even on partial misses (e.g. a note the
|
// can render a precise toast even on partial misses (e.g. a note the
|
||||||
// sender just hard-deleted between page load and click). One SQL
|
// sender just hard-deleted between page load and click). One SQL
|
||||||
@@ -792,9 +792,9 @@ router.delete("/notes/:id", requireAuth, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Two-step lookup so non-owners (e.g. shared-folder view-only recipients)
|
// Two-step lookup so non-owners (e.g. shared-folder view-only recipients)
|
||||||
// get a precise 403 instead of a 404 — see Task #445 acceptance criteria.
|
// get a precise 403 instead of a 404 — see acceptance criteria.
|
||||||
// Editors with permission="edit" on the note's folder are allowed
|
// Editors with permission="edit" on the note's folder are allowed
|
||||||
// (Task #454).
|
//.
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ userId: notesTable.userId, folderId: notesTable.folderId })
|
.select({ userId: notesTable.userId, folderId: notesTable.folderId })
|
||||||
.from(notesTable)
|
.from(notesTable)
|
||||||
@@ -1185,7 +1185,7 @@ router.post("/notes/:id/read", requireAuth, async (req, res): Promise<void> => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task #438: collaborative checklist toggle. The owner OR any active
|
// collaborative checklist toggle. The owner OR any active
|
||||||
// (non-archived) recipient can flip an item's `done` flag; the change
|
// (non-archived) recipient can flip an item's `done` flag; the change
|
||||||
// is persisted to the live `notes.items` AND mirrored to every
|
// is persisted to the live `notes.items` AND mirrored to every
|
||||||
// `note_recipients.items` snapshot so all collaborators see the same
|
// `note_recipients.items` snapshot so all collaborators see the same
|
||||||
@@ -1253,7 +1253,7 @@ router.post(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const sentToMe = !!recipientRow && recipientRow.status !== "archived";
|
const sentToMe = !!recipientRow && recipientRow.status !== "archived";
|
||||||
// Task #454: also accept folder editors on a shared folder. We
|
// also accept folder editors on a shared folder. We
|
||||||
// can't call resolveFolderEditAccess here (different db handle),
|
// can't call resolveFolderEditAccess here (different db handle),
|
||||||
// so re-implement the lookup against `tx` for transactional read
|
// so re-implement the lookup against `tx` for transactional read
|
||||||
// consistency.
|
// consistency.
|
||||||
@@ -1332,7 +1332,7 @@ router.post(
|
|||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Task #454: also notify shared-folder viewers (owner + every other
|
// also notify shared-folder viewers (owner + every other
|
||||||
// editor / viewer of the folder) so the shared-folder note list
|
// editor / viewer of the folder) so the shared-folder note list
|
||||||
// refetches and shows the toggled item — the per-recipient
|
// refetches and shows the toggled item — the per-recipient
|
||||||
// `note_checklist_changed` audience above only covers users in the
|
// `note_checklist_changed` audience above only covers users in the
|
||||||
@@ -1821,7 +1821,7 @@ router.put(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = req.body;
|
const body = req.body;
|
||||||
// Task #454: accept BOTH the legacy `recipientUserIds: number[]`
|
// accept BOTH the legacy `recipientUserIds: number[]`
|
||||||
// shape (treated as permission="view") AND the new
|
// shape (treated as permission="view") AND the new
|
||||||
// `recipients: [{ userId, permission }]` shape so older clients keep
|
// `recipients: [{ userId, permission }]` shape so older clients keep
|
||||||
// working. Internally we collapse to a single Map<userId, permission>.
|
// working. Internally we collapse to a single Map<userId, permission>.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Task #517: PATCH /apps/:id must reject route changes whose target row's
|
// PATCH /apps/:id must reject route changes whose target row's
|
||||||
// slug is in BUILTIN_APP_SLUGS — those routes are hardcoded in the SPA
|
// slug is in BUILTIN_APP_SLUGS — those routes are hardcoded in the SPA
|
||||||
// (artifacts/tx-os/src/App.tsx) and silently break the launcher when an
|
// (artifacts/tx-os/src/App.tsx) and silently break the launcher when an
|
||||||
// admin edits them. Other fields (icon, image, color, name, openMode,
|
// admin edits them. Other fields (icon, image, color, name, openMode,
|
||||||
@@ -72,7 +72,7 @@ before(async () => {
|
|||||||
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
||||||
|
|
||||||
// Non-admin user used to verify GET /apps/:id visibility gating for
|
// Non-admin user used to verify GET /apps/:id visibility gating for
|
||||||
// the new external-iframe / embedded flow (Task #517).
|
// the new external-iframe / embedded flow.
|
||||||
userUsername = `user_lock_${stamp}`;
|
userUsername = `user_lock_${stamp}`;
|
||||||
const userRow = await pool.query(
|
const userRow = await pool.query(
|
||||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ after(async () => {
|
|||||||
|
|
||||||
// Defensive sweep: kill any leftover services whose name_en matches the
|
// Defensive sweep: kill any leftover services whose name_en matches the
|
||||||
// per-run test prefix, plus their child rows in service_orders. Mirrors
|
// per-run test prefix, plus their child rows in service_orders. Mirrors
|
||||||
// the apps-side sweep added in Task #116.
|
// the apps-side sweep added in.
|
||||||
const svcSweep = await pool.query(
|
const svcSweep = await pool.query(
|
||||||
`SELECT id FROM services WHERE name_en LIKE $1`,
|
`SELECT id FROM services WHERE name_en LIKE $1`,
|
||||||
[`${SVC_PREFIX}%`],
|
[`${SVC_PREFIX}%`],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// API contract tests for the cell-merge overlay added by task #152
|
// API contract tests for the cell-merge overlay added by
|
||||||
// to the executive-meetings PATCH endpoint. Covers:
|
// to the executive-meetings PATCH endpoint. Covers:
|
||||||
//
|
//
|
||||||
// 1. Setting a merge writes mergeStartColumn / mergeEndColumn /
|
// 1. Setting a merge writes mergeStartColumn / mergeEndColumn /
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// API contract tests for the shared row-colour overlay added by task #288
|
// API contract tests for the shared row-colour overlay added by
|
||||||
// to the executive-meetings PATCH endpoint. Covers:
|
// to the executive-meetings PATCH endpoint. Covers:
|
||||||
//
|
//
|
||||||
// 1. Setting a colour persists it on the row and round-trips back
|
// 1. Setting a colour persists it on the row and round-trips back
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ test("Meetings: PUT /attendees replaces the attendee list", async () => {
|
|||||||
assert.ok(!body.attendees.find((a) => a.name === "Old One"));
|
assert.ok(!body.attendees.find((a) => a.name === "Old One"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Attendee payload contract (Task #221) ---------------------------------
|
// --- Attendee payload contract ---------------------------------
|
||||||
// Server is `.strict()` on the attendee schema so client-only fields like
|
// Server is `.strict()` on the attendee schema so client-only fields like
|
||||||
// `_sid` (the React row id used by the drag-and-drop editor) are rejected
|
// `_sid` (the React row id used by the drag-and-drop editor) are rejected
|
||||||
// loudly with 400 instead of being silently stripped. This locks down the
|
// loudly with 400 instead of being silently stripped. This locks down the
|
||||||
@@ -1510,7 +1510,7 @@ test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids"
|
|||||||
"duplicate ids must be reported with the duplicate_ids code");
|
"duplicate ids must be reported with the duplicate_ids code");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task #311: dragging a visible meeting from one row to another must not
|
// dragging a visible meeting from one row to another must not
|
||||||
// disturb cancelled rows on the same day. The schedule view hides
|
// disturb cancelled rows on the same day. The schedule view hides
|
||||||
// cancelled meetings (#273), so dnd-kit can only ever drag visible
|
// cancelled meetings (#273), so dnd-kit can only ever drag visible
|
||||||
// rows; the reorder endpoint must accept an orderedIds payload that
|
// rows; the reorder endpoint must accept an orderedIds payload that
|
||||||
@@ -1729,7 +1729,7 @@ test("Reorder: rejects ids that do not all belong to meetingDate", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Task #312: Auto-sort the day chronologically on every write that
|
// Auto-sort the day chronologically on every write that
|
||||||
// affects time/order. The user reported a row showing 13:00 sitting
|
// affects time/order. The user reported a row showing 13:00 sitting
|
||||||
// above a 12:00 row after editing a single startTime — that bug exists
|
// above a 12:00 row after editing a single startTime — that bug exists
|
||||||
// because the inline-edit PATCH never re-derived `daily_number`. These
|
// because the inline-edit PATCH never re-derived `daily_number`. These
|
||||||
@@ -2170,7 +2170,7 @@ test("Reorder: user without executive role is denied (403)", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Phase-2 task #112 additions:
|
// Phase-2 additions:
|
||||||
// - per-role meeting CRUD permissions
|
// - per-role meeting CRUD permissions
|
||||||
// - request reject / withdraw flows
|
// - request reject / withdraw flows
|
||||||
// - task assignee-only status updates
|
// - task assignee-only status updates
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Task #438: API tests for the collaborative checklist toggle endpoint
|
// API tests for the collaborative checklist toggle endpoint
|
||||||
// `POST /notes/:id/checklist/:itemId/toggle`. Owner + active recipients
|
// `POST /notes/:id/checklist/:itemId/toggle`. Owner + active recipients
|
||||||
// can flip an item; the change is mirrored to every recipient snapshot.
|
// can flip an item; the change is mirrored to every recipient snapshot.
|
||||||
import { test, after } from "node:test";
|
import { test, after } from "node:test";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Task #512: per-recipient inbox delete + bulk delete.
|
// per-recipient inbox delete + bulk delete.
|
||||||
// A recipient can detach their note_recipients row (DELETE
|
// A recipient can detach their note_recipients row (DELETE
|
||||||
// /notes/received/:id and bulk variant) without touching the
|
// /notes/received/:id and bulk variant) without touching the
|
||||||
// underlying note or other recipients' rows. Non-recipients get 404.
|
// underlying note or other recipients' rows. Non-recipients get 404.
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ test("GET /notes/my returns the same payload as GET /notes (alias)", async () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task #454: per-recipient folder permission (view vs edit).
|
// per-recipient folder permission (view vs edit).
|
||||||
test("folder share permissions: view-only is rejected on writes; edit can mutate; permission roundtrip works", async () => {
|
test("folder share permissions: view-only is rejected on writes; edit can mutate; permission roundtrip works", async () => {
|
||||||
const owner = await createUser("notes_perm_owner");
|
const owner = await createUser("notes_perm_owner");
|
||||||
const viewer = await createUser("notes_perm_viewer");
|
const viewer = await createUser("notes_perm_viewer");
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ test("status transitions matrix: receiver flows + owner/admin cancel rules", asy
|
|||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal((await res.json()).status, "completed");
|
assert.equal((await res.json()).status, "completed");
|
||||||
|
|
||||||
// Cannot transition completed → anything (non-admin); now 409 order_unavailable per Task #80
|
// Cannot transition completed → anything (non-admin); now 409 order_unavailable
|
||||||
res = await call(cr, "PATCH", `/orders/${order.id}/status`, {
|
res = await call(cr, "PATCH", `/orders/${order.id}/status`, {
|
||||||
status: "preparing",
|
status: "preparing",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Tests for Task #524 — MR-H1 / MR-H2 / MR-M7.
|
// Tests for — MR-H1 / MR-H2 / MR-M7.
|
||||||
//
|
//
|
||||||
// Coverage map (matches the "Required tests" list in the task spec):
|
// Coverage map (matches the "Required tests" list in the task spec):
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Unit tests for the incoming-note popup queue (Task #406).
|
// Unit tests for the incoming-note popup queue.
|
||||||
//
|
//
|
||||||
// The queue is the data structure behind the centered modal that
|
// The queue is the data structure behind the centered modal that
|
||||||
// appears on the recipient's screen the moment a `note_received` socket
|
// appears on the recipient's screen the moment a `note_received` socket
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Unit tests for parseTypedTime — the human-input time parser used by
|
// Unit tests for parseTypedTime — the human-input time parser used by
|
||||||
// the executive-meetings inline time editor. Covers every shape listed
|
// the executive-meetings inline time editor. Covers every shape listed
|
||||||
// in task #308's "Done looks like" plus the obvious invalid edges.
|
// in's "Done looks like" plus the obvious invalid edges.
|
||||||
//
|
//
|
||||||
// Runs with Node 24's built-in test runner + native TypeScript support.
|
// Runs with Node 24's built-in test runner + native TypeScript support.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Unit tests for the 12-hour-picker helpers (task #315). Covers the
|
// Unit tests for the 12-hour-picker helpers. Covers the
|
||||||
// AM/PM disambiguation rules that fix the "1:15 silently saved as AM"
|
// AM/PM disambiguation rules that fix the "1:15 silently saved as AM"
|
||||||
// bug — the picker MUST require an explicit toggle for any 1–12 hour
|
// bug — the picker MUST require an explicit toggle for any 1–12 hour
|
||||||
// without a typed AM/PM marker, and MUST accept any 13–23 hour or
|
// without a typed AM/PM marker, and MUST accept any 13–23 hour or
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type Props = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Inner content for the clock-style picker, extracted so the unified
|
* Inner content for the clock-style picker, extracted so the unified
|
||||||
* Settings panel (Task #527) can render the same controls without
|
* Settings panel can render the same controls without
|
||||||
* re-implementing the visibility toggles, hour-format switch, and
|
* re-implementing the visibility toggles, hour-format switch, and
|
||||||
* style list. `ClockStylePicker` below remains as a standalone
|
* style list. `ClockStylePicker` below remains as a standalone
|
||||||
* popover wrapper for any caller that wants a single icon-button.
|
* popover wrapper for any caller that wants a single icon-button.
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ export function UpcomingMeetingAlert() {
|
|||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
}, [eligibleId]);
|
}, [eligibleId]);
|
||||||
|
|
||||||
// Task #438: scale-in + fade entrance that mirrors the incoming-note
|
// scale-in + fade entrance that mirrors the incoming-note
|
||||||
// popup, so the alert feels like the same family of "important
|
// popup, so the alert feels like the same family of "important
|
||||||
// floating thing arrived". Toggled false → true on the next paint of
|
// floating thing arrived". Toggled false → true on the next paint of
|
||||||
// each new eligible meeting so the CSS transition has a starting
|
// each new eligible meeting so the CSS transition has a starting
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ interface Props {
|
|||||||
mode?: "rail" | "chips-only";
|
mode?: "rail" | "chips-only";
|
||||||
// Extra classes appended to the root <aside>. Used by the Notes page
|
// Extra classes appended to the root <aside>. Used by the Notes page
|
||||||
// to control flex `order-*` so the composer stacks above the rail on
|
// to control flex `order-*` so the composer stacks above the rail on
|
||||||
// narrow widths (Task #514).
|
// narrow widths.
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,7 +554,7 @@ export function FoldersRail({
|
|||||||
</span>
|
</span>
|
||||||
{/* Permission badge so the recipient can tell at a
|
{/* Permission badge so the recipient can tell at a
|
||||||
glance whether they can edit this folder's notes
|
glance whether they can edit this folder's notes
|
||||||
(Task #454). */}
|
. */}
|
||||||
<span
|
<span
|
||||||
className={`shrink-0 inline-flex items-center justify-center rounded px-1 py-0.5 text-[9px] font-medium uppercase tracking-wide ${
|
className={`shrink-0 inline-flex items-center justify-center rounded px-1 py-0.5 text-[9px] font-medium uppercase tracking-wide ${
|
||||||
sf.myPermission === "edit"
|
sf.myPermission === "edit"
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ function clampToViewport(pos: StoredPos): StoredPos {
|
|||||||
* the checkboxes are not interactive (toggling is reserved for the
|
* the checkboxes are not interactive (toggling is reserved for the
|
||||||
* full Notes page after "Open note").
|
* full Notes page after "Open note").
|
||||||
*/
|
*/
|
||||||
// Task #438: collaborative checklist render. Owners + active recipients
|
// collaborative checklist render. Owners + active recipients
|
||||||
// can toggle items right from the popup; the change persists server-
|
// can toggle items right from the popup; the change persists server-
|
||||||
// side and fans out to other audience members via socket. We stop
|
// side and fans out to other audience members via socket. We stop
|
||||||
// pointer/click propagation so toggling a checkbox doesn't bubble up to
|
// pointer/click propagation so toggling a checkbox doesn't bubble up to
|
||||||
@@ -246,7 +246,7 @@ export function IncomingNotePopup() {
|
|||||||
|
|
||||||
// Inline reply composer state. When `composerOpen` is true the action
|
// Inline reply composer state. When `composerOpen` is true the action
|
||||||
// row is replaced with a textarea + Send/Cancel so the user can reply
|
// row is replaced with a textarea + Send/Cancel so the user can reply
|
||||||
// without navigating away from the current page (Task #515).
|
// without navigating away from the current page.
|
||||||
const [composerOpen, setComposerOpen] = useState(false);
|
const [composerOpen, setComposerOpen] = useState(false);
|
||||||
const [replyText, setReplyText] = useState("");
|
const [replyText, setReplyText] = useState("");
|
||||||
const [justSent, setJustSent] = useState(false);
|
const [justSent, setJustSent] = useState(false);
|
||||||
@@ -388,7 +388,7 @@ export function IncomingNotePopup() {
|
|||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
// Task #468/#470: derive accent classes from the note's own color so
|
///#470: derive accent classes from the note's own color so
|
||||||
// the popup looks like an extension of the note (pink note → pink
|
// the popup looks like an extension of the note (pink note → pink
|
||||||
// ping + pink avatar tint, etc.) instead of the previous fixed amber
|
// ping + pink avatar tint, etc.) instead of the previous fixed amber
|
||||||
// chrome. The outer ring was removed in #470 — only the avatar bg,
|
// chrome. The outer ring was removed in #470 — only the avatar bg,
|
||||||
@@ -540,7 +540,7 @@ export function IncomingNotePopup() {
|
|||||||
: current.content;
|
: current.content;
|
||||||
// Render the inner sticky-note as faithfully as the NoteCard does:
|
// Render the inner sticky-note as faithfully as the NoteCard does:
|
||||||
// text body for "text" notes, interactive collaborative checklist for
|
// text body for "text" notes, interactive collaborative checklist for
|
||||||
// "checklist" (Task #438).
|
// "checklist".
|
||||||
// Reply-variant popups always render the reply text as plain content.
|
// Reply-variant popups always render the reply text as plain content.
|
||||||
const isChecklistNote =
|
const isChecklistNote =
|
||||||
!!notePayload &&
|
!!notePayload &&
|
||||||
@@ -663,7 +663,7 @@ export function IncomingNotePopup() {
|
|||||||
|
|
||||||
{/* Action row — collapses into the inline reply composer
|
{/* Action row — collapses into the inline reply composer
|
||||||
when the user clicks "Reply" so they can respond without
|
when the user clicks "Reply" so they can respond without
|
||||||
leaving the current page (Task #515). */}
|
leaving the current page. */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
{composerOpen ? (
|
{composerOpen ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export function QuickMuteButton() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Inner content of the notification settings popover, extracted so the
|
* Inner content of the notification settings popover, extracted so the
|
||||||
* Settings panel (Task #527) can render the same form without
|
* Settings panel can render the same form without
|
||||||
* re-implementing the toggles, slot tabs, and sound picker. The
|
* re-implementing the toggles, slot tabs, and sound picker. The
|
||||||
* standalone `NotificationSettingsPicker` below wraps it in a popover
|
* standalone `NotificationSettingsPicker` below wraps it in a popover
|
||||||
* trigger + content; the unified Settings panel mounts this content
|
* trigger + content; the unified Settings panel mounts this content
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ function GroupItem({
|
|||||||
* persistence + behaviour stays identical to the topbar buttons that
|
* persistence + behaviour stays identical to the topbar buttons that
|
||||||
* used to expose them.
|
* used to expose them.
|
||||||
*
|
*
|
||||||
* Task #529: groups are collapsible. Open/closed state is lifted up
|
* groups are collapsible. Open/closed state is lifted up
|
||||||
* to the SettingsPanel so it persists while the panel is closed and
|
* to the SettingsPanel so it persists while the panel is closed and
|
||||||
* re-opened in the same session. Default = all collapsed (compact
|
* re-opened in the same session. Default = all collapsed (compact
|
||||||
* panel on first open).
|
* panel on first open).
|
||||||
@@ -249,13 +249,13 @@ function SettingsPanelBody({
|
|||||||
* behaviour and accessibility stay consistent.
|
* behaviour and accessibility stay consistent.
|
||||||
* - Body composes the existing form components rather than
|
* - Body composes the existing form components rather than
|
||||||
* re-implementing them, preserving DB + localStorage persistence.
|
* re-implementing them, preserving DB + localStorage persistence.
|
||||||
* - Task #529: groups are collapsible (accordion); logout stays
|
* -: groups are collapsible (accordion); logout stays
|
||||||
* in the topbar so the gear panel only hosts preferences.
|
* in the topbar so the gear panel only hosts preferences.
|
||||||
*/
|
*/
|
||||||
export function SettingsPanel() {
|
export function SettingsPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Default = all collapsed so the panel opens compact (Task #529).
|
// Default = all collapsed so the panel opens compact.
|
||||||
// State is lifted here so toggles persist while the panel is
|
// State is lifted here so toggles persist while the panel is
|
||||||
// dismissed and re-opened in the same session.
|
// dismissed and re-opened in the same session.
|
||||||
const [openGroups, setOpenGroups] = useState<GroupId[]>([]);
|
const [openGroups, setOpenGroups] = useState<GroupId[]>([]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// 12-hour time picker for the executive-meetings editor (task #315).
|
// 12-hour time picker for the executive-meetings editor.
|
||||||
//
|
//
|
||||||
// Renders THREE controls per field, per the task spec:
|
// Renders THREE controls per field, per the task spec:
|
||||||
// 1. Hour input (1–12, but also accepts a full time as a power-user
|
// 1. Hour input (1–12, but also accepts a full time as a power-user
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
// parent reads the latest draft via the imperative `commit()`
|
// parent reads the latest draft via the imperative `commit()`
|
||||||
// handle when the user presses Enter or blurs the wrapper, so
|
// handle when the user presses Enter or blurs the wrapper, so
|
||||||
// a save that races React state batching still sees the right
|
// a save that races React state batching still sees the right
|
||||||
// value (matches the belt-and-braces pattern task #308 set up
|
// value (matches the belt-and-braces pattern set up
|
||||||
// against the prior text input).
|
// against the prior text input).
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ interface IncomingNotePopupContextValue {
|
|||||||
) => boolean;
|
) => boolean;
|
||||||
dismiss: (target: number | { replyId: number }) => void;
|
dismiss: (target: number | { replyId: number }) => void;
|
||||||
/**
|
/**
|
||||||
* Task #438: patch the checklist items of any queued popup payload
|
* patch the checklist items of any queued popup payload
|
||||||
* for `noteId` so an open popup re-renders against the freshest
|
* for `noteId` so an open popup re-renders against the freshest
|
||||||
* shared state when another collaborator toggles an item.
|
* shared state when another collaborator toggles an item.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export function useNotificationsSocket() {
|
|||||||
invalidate(["notes"]);
|
invalidate(["notes"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task #438: a collaborator (owner or another recipient) toggled a
|
// a collaborator (owner or another recipient) toggled a
|
||||||
// checklist item — refetch so this user's open thread / popup /
|
// checklist item — refetch so this user's open thread / popup /
|
||||||
// cards reflect the shared state. Server already updates the
|
// cards reflect the shared state. Server already updates the
|
||||||
// emitter optimistically, so it never receives its own echo. We
|
// emitter optimistically, so it never receives its own echo. We
|
||||||
@@ -335,7 +335,7 @@ export function useNotificationsSocket() {
|
|||||||
if (typeof payload?.folderId === "number") {
|
if (typeof payload?.folderId === "number") {
|
||||||
invalidate(["notes", "shared-folder", payload.folderId]);
|
invalidate(["notes", "shared-folder", payload.folderId]);
|
||||||
}
|
}
|
||||||
// Task #454: this event also fanouts to the OWNER + every other
|
// this event also fanouts to the OWNER + every other
|
||||||
// recipient when an editor mutates the folder's notes (create /
|
// recipient when an editor mutates the folder's notes (create /
|
||||||
// patch / delete / checklist toggle), so refresh the owner's
|
// patch / delete / checklist toggle), so refresh the owner's
|
||||||
// personal notes list and the folders rail badge counts too.
|
// personal notes list and the folders rail badge counts too.
|
||||||
@@ -343,7 +343,7 @@ export function useNotificationsSocket() {
|
|||||||
invalidate(["note-folders"]);
|
invalidate(["note-folders"]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Task #454: dedicated event for permission-only flips on an
|
// dedicated event for permission-only flips on an
|
||||||
// existing share (view ↔ edit). Same invalidation as `shared`,
|
// existing share (view ↔ edit). Same invalidation as `shared`,
|
||||||
// but kept distinct so the contract is explicit and so future UI
|
// but kept distinct so the contract is explicit and so future UI
|
||||||
// can react with a toast like "Your access changed to Edit".
|
// can react with a toast like "Your access changed to Edit".
|
||||||
|
|||||||
@@ -421,7 +421,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Arabic dot/diacritic clipping fix for truncated text (Task #503 follow-up).
|
* Arabic dot/diacritic clipping fix for truncated text ( follow-up).
|
||||||
*
|
*
|
||||||
* `.truncate` uses `overflow: hidden` + `white-space: nowrap`. With Tailwind's
|
* `.truncate` uses `overflow: hidden` + `white-space: nowrap`. With Tailwind's
|
||||||
* default tight line-heights on small text (text-xs / text-sm / text-[11px]),
|
* default tight line-heights on small text (text-xs / text-sm / text-[11px]),
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export interface NoteFolder {
|
|||||||
// so the rail can render "by <name>" without an extra fetch.
|
// so the rail can render "by <name>" without an extra fetch.
|
||||||
// Permission a recipient has on a shared folder. "view" is read-only
|
// Permission a recipient has on a shared folder. "view" is read-only
|
||||||
// (default); "edit" lets them create / update / delete notes inside the
|
// (default); "edit" lets them create / update / delete notes inside the
|
||||||
// folder owner's namespace. Added in Task #454.
|
// folder owner's namespace. Added in.
|
||||||
export type FolderSharePermission = "view" | "edit";
|
export type FolderSharePermission = "view" | "edit";
|
||||||
|
|
||||||
export interface SharedFolder {
|
export interface SharedFolder {
|
||||||
@@ -223,7 +223,7 @@ export function useNoteLabels() {
|
|||||||
return useQuery({ queryKey: labelsKey, queryFn: () => req<NoteLabel[]>("/note-labels") });
|
return useQuery({ queryKey: labelsKey, queryFn: () => req<NoteLabel[]>("/note-labels") });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task #454: shared-folder editors mutate notes that live in someone
|
// shared-folder editors mutate notes that live in someone
|
||||||
// else's namespace. The server emits `note-folder-shared` to every
|
// else's namespace. The server emits `note-folder-shared` to every
|
||||||
// other viewer, but the actor is excluded from that fanout, so we
|
// other viewer, but the actor is excluded from that fanout, so we
|
||||||
// invalidate the shared-with-me list and folders rail locally too —
|
// invalidate the shared-with-me list and folders rail locally too —
|
||||||
@@ -260,7 +260,7 @@ export function useDeleteNote() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task #472: bulk-delete the caller's own (sent) notes by id. Reuses the
|
// bulk-delete the caller's own (sent) notes by id. Reuses the
|
||||||
// existing per-note DELETE endpoint and fans out with bounded
|
// existing per-note DELETE endpoint and fans out with bounded
|
||||||
// concurrency (worker pool of `BULK_DELETE_CONCURRENCY`) so we never
|
// concurrency (worker pool of `BULK_DELETE_CONCURRENCY`) so we never
|
||||||
// open hundreds of sockets at once on large selections — each worker
|
// open hundreds of sockets at once on large selections — each worker
|
||||||
@@ -325,7 +325,7 @@ export function useMarkNoteRead() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task #438: collaborative checklist item toggle. Anyone in the audience
|
// collaborative checklist item toggle. Anyone in the audience
|
||||||
// (owner + active recipients) can flip an item; we optimistically patch
|
// (owner + active recipients) can flip an item; we optimistically patch
|
||||||
// every cached `Note`/`SentNote`/`ReceivedNote`/`NoteThread` entry that
|
// every cached `Note`/`SentNote`/`ReceivedNote`/`NoteThread` entry that
|
||||||
// references this noteId so all surfaces (cards, sent grid, inbox,
|
// references this noteId so all surfaces (cards, sent grid, inbox,
|
||||||
@@ -432,7 +432,7 @@ export function useArchiveReceivedNote() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task #510: bulk archive for the Notes Inbox. Mirrors
|
// bulk archive for the Notes Inbox. Mirrors
|
||||||
// `useBulkDeleteSentNotes` — fans `BULK_DELETE_CONCURRENCY` workers over
|
// `useBulkDeleteSentNotes` — fans `BULK_DELETE_CONCURRENCY` workers over
|
||||||
// POST /notes/:id/archive so the request count is bounded even on large
|
// POST /notes/:id/archive so the request count is bounded even on large
|
||||||
// selections, and reports per-id success/failure so the caller can show
|
// selections, and reports per-id success/failure so the caller can show
|
||||||
@@ -471,7 +471,7 @@ export function useBulkArchiveReceivedNotes() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task #512: per-recipient delete. Removes only the caller's own
|
// per-recipient delete. Removes only the caller's own
|
||||||
// recipient row — sender, note, and other recipients are untouched.
|
// recipient row — sender, note, and other recipients are untouched.
|
||||||
// Invalidates `["notes"]` so both the inbox and the archived view
|
// Invalidates `["notes"]` so both the inbox and the archived view
|
||||||
// refresh immediately.
|
// refresh immediately.
|
||||||
@@ -484,7 +484,7 @@ export function useDeleteReceivedNote() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task #512: bulk variant. One POST handles the whole batch on the
|
// bulk variant. One POST handles the whole batch on the
|
||||||
// server (single SQL DELETE) and reports per-id ok/notFound so the
|
// server (single SQL DELETE) and reports per-id ok/notFound so the
|
||||||
// UI can render a precise toast even on partial misses. Cache is
|
// UI can render a precise toast even on partial misses. Cache is
|
||||||
// invalidated once at the end so all surfaces (inbox, archived,
|
// invalidated once at the end so all surfaces (inbox, archived,
|
||||||
@@ -771,7 +771,7 @@ export function useFolderShares(folderId: number | null) {
|
|||||||
|
|
||||||
// Replace the recipient set on a folder. Server diffs idempotently so this
|
// Replace the recipient set on a folder. Server diffs idempotently so this
|
||||||
// can be called repeatedly with the desired final set. Each recipient
|
// can be called repeatedly with the desired final set. Each recipient
|
||||||
// carries an explicit "view" | "edit" permission (Task #454).
|
// carries an explicit "view" | "edit" permission.
|
||||||
export function useUpdateFolderShares() {
|
export function useUpdateFolderShares() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -805,7 +805,7 @@ export function useSharedWithMeFolders() {
|
|||||||
// Note returned inside a shared-folder view. Carries the readOnly flag the
|
// Note returned inside a shared-folder view. Carries the readOnly flag the
|
||||||
// server stamps on every record so the UI doesn't have to recompute access.
|
// server stamps on every record so the UI doesn't have to recompute access.
|
||||||
// `readOnly` is `false` for notes inside an editor-permission folder
|
// `readOnly` is `false` for notes inside an editor-permission folder
|
||||||
// (Task #454).
|
//.
|
||||||
export interface SharedFolderNote extends Note {
|
export interface SharedFolderNote extends Note {
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}
|
}
|
||||||
@@ -845,7 +845,7 @@ export const NOTE_COLORS: {
|
|||||||
bg: string;
|
bg: string;
|
||||||
ring: string;
|
ring: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
// Task #468: each color also carries the literal class strings used by
|
// each color also carries the literal class strings used by
|
||||||
// the incoming-note popup so the popup's outer ring, the avatar's
|
// the incoming-note popup so the popup's outer ring, the avatar's
|
||||||
// background + ring, and the animated ping pulse all match the note's
|
// background + ring, and the animated ping pulse all match the note's
|
||||||
// own color family instead of being a fixed amber tint. Keeping these
|
// own color family instead of being a fixed amber tint. Keeping these
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Helpers for the 12-hour time picker used by the executive-meetings
|
// Helpers for the 12-hour time picker used by the executive-meetings
|
||||||
// editor (task #315). The picker keeps storage / wire format unchanged
|
// editor. The picker keeps storage / wire format unchanged
|
||||||
// (canonical "HH:mm" 24h) — these helpers only translate between that
|
// (canonical "HH:mm" 24h) — these helpers only translate between that
|
||||||
// canonical form and the 3-control 12-hour shape the user sees, plus
|
// canonical form and the 3-control 12-hour shape the user sees, plus
|
||||||
// combine an hour + minute + AM/PM toggle draft into a single
|
// combine an hour + minute + AM/PM toggle draft into a single
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
// choice, so the same misinput is now surfaced as a clear "pick AM
|
// choice, so the same misinput is now surfaced as a clear "pick AM
|
||||||
// or PM" parse error rather than silently saved as AM.
|
// or PM" parse error rather than silently saved as AM.
|
||||||
//
|
//
|
||||||
// Per task #315 the picker exposes THREE separate controls per
|
// the picker exposes THREE separate controls per
|
||||||
// field (hour input 1–12, minute input 00–59, AM/PM toggle). The
|
// field (hour input 1–12, minute input 00–59, AM/PM toggle). The
|
||||||
// hour input is intentionally permissive: it also accepts a full
|
// hour input is intentionally permissive: it also accepts a full
|
||||||
// time string ("13:30", "9:30 PM", "0930", "٠٩:٣٠") so power users
|
// time string ("13:30", "9:30 PM", "0930", "٠٩:٣٠") so power users
|
||||||
@@ -189,7 +189,7 @@ export function combineTextWithPeriod(
|
|||||||
/**
|
/**
|
||||||
* Combine the 3-control picker's hour input + minute input + AM/PM
|
* Combine the 3-control picker's hour input + minute input + AM/PM
|
||||||
* toggle into a single canonical "HH:mm" value. This is the primary
|
* toggle into a single canonical "HH:mm" value. This is the primary
|
||||||
* combine path for the picker rebuilt against the task #315 spec
|
* combine path for the picker rebuilt against the spec
|
||||||
* ("three controls per field: hour input, minute input, AM/PM
|
* ("three controls per field: hour input, minute input, AM/PM
|
||||||
* toggle").
|
* toggle").
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1232,7 +1232,7 @@ function ScheduleSection({
|
|||||||
// what makes title / attendee / merge / row-color edits repaint the
|
// what makes title / attendee / merge / row-color edits repaint the
|
||||||
// cell within a single React commit instead of waiting for the GET
|
// cell within a single React commit instead of waiting for the GET
|
||||||
// refetch — the same pattern saveTimes already uses for the schedule
|
// refetch — the same pattern saveTimes already uses for the schedule
|
||||||
// time editor (task #316).
|
// time editor.
|
||||||
const applyMeetingPatch = useCallback(
|
const applyMeetingPatch = useCallback(
|
||||||
(meetingId: number, patch: (m: Meeting) => Meeting) => {
|
(meetingId: number, patch: (m: Meeting) => Meeting) => {
|
||||||
const queryKey = ["/api/executive-meetings", date] as const;
|
const queryKey = ["/api/executive-meetings", date] as const;
|
||||||
@@ -4549,14 +4549,14 @@ function TimeRangeCell({
|
|||||||
// Imperative handles to the start/end pickers. The inline editor
|
// Imperative handles to the start/end pickers. The inline editor
|
||||||
// reads the live draft (typed text + AM/PM toggle) via commit()
|
// reads the live draft (typed text + AM/PM toggle) via commit()
|
||||||
// when saving, instead of relying on React state — same
|
// when saving, instead of relying on React state — same
|
||||||
// belt-and-braces pattern task #308 used to dodge "Enter pressed
|
// belt-and-braces pattern used to dodge "Enter pressed
|
||||||
// before React's batched onChange flush" races.
|
// before React's batched onChange flush" races.
|
||||||
const startPickerRef = useRef<TimePicker12hHandle | null>(null);
|
const startPickerRef = useRef<TimePicker12hHandle | null>(null);
|
||||||
const endPickerRef = useRef<TimePicker12hHandle | null>(null);
|
const endPickerRef = useRef<TimePicker12hHandle | null>(null);
|
||||||
|
|
||||||
// Track the last `startSaved`/`endSaved` we've observed so the sync
|
// Track the last `startSaved`/`endSaved` we've observed so the sync
|
||||||
// effect below only fires when the *saved props themselves change*,
|
// effect below only fires when the *saved props themselves change*,
|
||||||
// not on every `editing` flip. This is the fix for task #316's
|
// not on every `editing` flip. This is the fix for's
|
||||||
// snap-back race: `save()` writes the optimistic times, then
|
// snap-back race: `save()` writes the optimistic times, then
|
||||||
// `setEditing(false)` triggered a re-run of the prior
|
// `setEditing(false)` triggered a re-run of the prior
|
||||||
// `[editing, startSaved, endSaved]` effect that overwrote the
|
// `[editing, startSaved, endSaved]` effect that overwrote the
|
||||||
@@ -5027,7 +5027,7 @@ function AttendeesCell({
|
|||||||
|
|
||||||
if (hasSplit) {
|
if (hasSplit) {
|
||||||
// Split-mode cells render ONE cell-level "+ عنوان فرعي" chip below
|
// Split-mode cells render ONE cell-level "+ عنوان فرعي" chip below
|
||||||
// all groups (see Task #234), so each per-group AttendeeFlow must
|
// all groups (see), so each per-group AttendeeFlow must
|
||||||
// suppress its own trailing subheading chip. Per-group "+" person
|
// suppress its own trailing subheading chip. Per-group "+" person
|
||||||
// chips and the after-section chips are unaffected.
|
// chips and the after-section chips are unaffected.
|
||||||
return (
|
return (
|
||||||
@@ -5092,7 +5092,7 @@ function AttendeesCell({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Cell-level "+ عنوان فرعي" chip (Task #234). One per cell,
|
{/* Cell-level "+ عنوان فرعي" chip. One per cell,
|
||||||
not one per group. Clicking it routes the new subheading to
|
not one per group. Clicking it routes the new subheading to
|
||||||
the LAST visible group in render order (virtual → internal
|
the LAST visible group in render order (virtual → internal
|
||||||
→ external). The chip mirrors the per-group hide rules:
|
→ external). The chip mirrors the per-group hide rules:
|
||||||
@@ -6127,7 +6127,7 @@ function ManageSection({
|
|||||||
// state — the picker's onChange suppresses ambiguous/invalid
|
// state — the picker's onChange suppresses ambiguous/invalid
|
||||||
// drafts so `editing.startTime` / `editing.endTime` may still
|
// drafts so `editing.startTime` / `editing.endTime` may still
|
||||||
// hold the previous saved value when the user typed but never
|
// hold the previous saved value when the user typed but never
|
||||||
// resolved the AM/PM. Task #315: prevent silent fallback.
|
// resolved the AM/PM.: prevent silent fallback.
|
||||||
const startTime = timeOverrides
|
const startTime = timeOverrides
|
||||||
? timeOverrides.startTime || null
|
? timeOverrides.startTime || null
|
||||||
: editing.startTime || null;
|
: editing.startTime || null;
|
||||||
@@ -6496,7 +6496,7 @@ function ManageSection({
|
|||||||
onChange={setEditing}
|
onChange={setEditing}
|
||||||
// Pass the committed (canonical) start/end through so the
|
// Pass the committed (canonical) start/end through so the
|
||||||
// dialog's Enter/click-Save path validates the time pickers
|
// dialog's Enter/click-Save path validates the time pickers
|
||||||
// synchronously (per task #315: typing "1:15" without an
|
// synchronously (: typing "1:15" without an
|
||||||
// AM/PM pick must surface as an error, NOT silently fall
|
// AM/PM pick must surface as an error, NOT silently fall
|
||||||
// back to the previous saved value or AM). The values
|
// back to the previous saved value or AM). The values
|
||||||
// arriving here are already canonical "HH:mm" or "" — we
|
// arriving here are already canonical "HH:mm" or "" — we
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function AppIconContent({
|
|||||||
const name = lang === "ar" ? app.nameAr : app.nameEn;
|
const name = lang === "ar" ? app.nameAr : app.nameEn;
|
||||||
|
|
||||||
const Icon = resolveIcon(app.iconName);
|
const Icon = resolveIcon(app.iconName);
|
||||||
// Task #517: when an admin uploads a custom image, render it in place
|
// when an admin uploads a custom image, render it in place
|
||||||
// of the Lucide icon. The image fills the tile (object-cover) so the
|
// of the Lucide icon. The image fills the tile (object-cover) so the
|
||||||
// gradient background still shows through transparent PNGs.
|
// gradient background still shows through transparent PNGs.
|
||||||
const resolvedImage = resolveServiceImageUrl(app.imageUrl ?? null);
|
const resolvedImage = resolveServiceImageUrl(app.imageUrl ?? null);
|
||||||
@@ -479,7 +479,7 @@ export default function HomePage() {
|
|||||||
logAppOpen(app.id, { keepalive: true }).catch(() => {
|
logAppOpen(app.id, { keepalive: true }).catch(() => {
|
||||||
// Best-effort tracking; ignore failures so navigation isn't blocked.
|
// Best-effort tracking; ignore failures so navigation isn't blocked.
|
||||||
});
|
});
|
||||||
// Task #517: branch on the admin-configured open mode.
|
// branch on the admin-configured open mode.
|
||||||
// external_tab — open the externalUrl in a new tab
|
// external_tab — open the externalUrl in a new tab
|
||||||
// external_iframe — navigate to /embedded/:id which renders externalUrl
|
// external_iframe — navigate to /embedded/:id which renders externalUrl
|
||||||
// inside an iframe shell with a back button
|
// inside an iframe shell with a back button
|
||||||
@@ -516,7 +516,7 @@ export default function HomePage() {
|
|||||||
const initials = (displayName || "?").trim().slice(0, 1).toUpperCase();
|
const initials = (displayName || "?").trim().slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Task #459: removed `overflow-hidden` and switched to `min-h-[100dvh]`
|
// removed `overflow-hidden` and switched to `min-h-[100dvh]`
|
||||||
// so iOS Safari can collapse its URL bar on scroll, matching the
|
// so iOS Safari can collapse its URL bar on scroll, matching the
|
||||||
// executive-meetings page behavior. `100dvh` (dynamic viewport
|
// executive-meetings page behavior. `100dvh` (dynamic viewport
|
||||||
// height) sizes against the visible area, so the layout reflows
|
// height) sizes against the visible area, so the layout reflows
|
||||||
@@ -566,11 +566,11 @@ export default function HomePage() {
|
|||||||
itself can scale at the `md:` breakpoint.
|
itself can scale at the `md:` breakpoint.
|
||||||
*/}
|
*/}
|
||||||
{/*
|
{/*
|
||||||
Task #527 + #529: per-user UI preferences (language,
|
: per-user UI preferences (language,
|
||||||
notification sound + vibration, master sound, clock
|
notification sound + vibration, master sound, clock
|
||||||
style/visibility/12-24h) live behind a single Settings
|
style/visibility/12-24h) live behind a single Settings
|
||||||
(gear) entry that opens the unified panel. Logout stays
|
(gear) entry that opens the unified panel. Logout stays
|
||||||
on the topbar as a single-tap action (Task #529 reverted
|
on the topbar as a single-tap action ( reverted
|
||||||
the brief move into the panel). The cluster now shows
|
the brief move into the panel). The cluster now shows
|
||||||
exactly Bell -> Inbox -> Settings -> Logout (Inbox is
|
exactly Bell -> Inbox -> Settings -> Logout (Inbox is
|
||||||
conditional on the user being able to receive incoming
|
conditional on the user being able to receive incoming
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export default function NotesPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
const clearSelected = useCallback(() => setSelectedIds(new Set()), []);
|
const clearSelected = useCallback(() => setSelectedIds(new Set()), []);
|
||||||
|
|
||||||
// Task #472: bulk-select + bulk-delete for the Sent tab. Lives at the
|
// bulk-select + bulk-delete for the Sent tab. Lives at the
|
||||||
// page level (not inside SentList) so the toggle button in the header
|
// page level (not inside SentList) so the toggle button in the header
|
||||||
// and the AlertDialog rendered next to the other dialogs can share
|
// and the AlertDialog rendered next to the other dialogs can share
|
||||||
// state with the list. Selection is kept as a `Set<number>` for O(1)
|
// state with the list. Selection is kept as a `Set<number>` for O(1)
|
||||||
@@ -225,7 +225,7 @@ export default function NotesPage() {
|
|||||||
}
|
}
|
||||||
}, [view, sentSelectionMode, exitSentSelection]);
|
}, [view, sentSelectionMode, exitSentSelection]);
|
||||||
|
|
||||||
// Task #510: bulk-select + bulk-archive for the Inbox tab. Identical
|
// bulk-select + bulk-archive for the Inbox tab. Identical
|
||||||
// shape to the Sent tab's selection state above, but the destructive
|
// shape to the Sent tab's selection state above, but the destructive
|
||||||
// action is "archive" (the recipient doesn't own the underlying note,
|
// action is "archive" (the recipient doesn't own the underlying note,
|
||||||
// so we hide it from their inbox via POST /notes/:id/archive instead
|
// so we hide it from their inbox via POST /notes/:id/archive instead
|
||||||
@@ -234,7 +234,7 @@ export default function NotesPage() {
|
|||||||
const [selectedInboxIds, setSelectedInboxIds] = useState<Set<number>>(new Set());
|
const [selectedInboxIds, setSelectedInboxIds] = useState<Set<number>>(new Set());
|
||||||
const [bulkArchiveOpen, setBulkArchiveOpen] = useState(false);
|
const [bulkArchiveOpen, setBulkArchiveOpen] = useState(false);
|
||||||
const bulkArchiveInbox = useBulkArchiveReceivedNotes();
|
const bulkArchiveInbox = useBulkArchiveReceivedNotes();
|
||||||
// Task #512: bulk-delete for the Notes Inbox. Distinct from archive
|
// bulk-delete for the Notes Inbox. Distinct from archive
|
||||||
// (which moves to the Archived tab) — this permanently removes the
|
// (which moves to the Archived tab) — this permanently removes the
|
||||||
// caller's own recipient rows so the notes disappear from both
|
// caller's own recipient rows so the notes disappear from both
|
||||||
// Inbox and Archived. The sender's copy is untouched.
|
// Inbox and Archived. The sender's copy is untouched.
|
||||||
@@ -464,7 +464,7 @@ export default function NotesPage() {
|
|||||||
);
|
);
|
||||||
}, [sentNotes, search]);
|
}, [sentNotes, search]);
|
||||||
|
|
||||||
// Task #472 (architect fix): when the visible Sent set changes
|
// (architect fix): when the visible Sent set changes
|
||||||
// (search/filter, server refresh, or successful delete), prune the
|
// (search/filter, server refresh, or successful delete), prune the
|
||||||
// selection to currently visible ids so we never delete a hidden
|
// selection to currently visible ids so we never delete a hidden
|
||||||
// note. If the prune empties the visible list while in selection
|
// note. If the prune empties the visible list while in selection
|
||||||
@@ -517,7 +517,7 @@ export default function NotesPage() {
|
|||||||
|
|
||||||
// Composer is rendered inside the main content column of the
|
// Composer is rendered inside the main content column of the
|
||||||
// folders+content row so its top edge aligns with the FoldersRail
|
// folders+content row so its top edge aligns with the FoldersRail
|
||||||
// (Task #514). On narrow widths the row stacks (`flex-col`) and the
|
//. On narrow widths the row stacks (`flex-col`) and the
|
||||||
// composer is forced above the rail via `order-*` classes so the
|
// composer is forced above the rail via `order-*` classes so the
|
||||||
// user can still capture a quick note without scrolling past the
|
// user can still capture a quick note without scrolling past the
|
||||||
// rail. It is hidden on read-only / non-owner views (Inbox, Sent,
|
// rail. It is hidden on read-only / non-owner views (Inbox, Sent,
|
||||||
@@ -526,7 +526,7 @@ export default function NotesPage() {
|
|||||||
view === "active" && folderSelection.kind !== "shared-folder";
|
view === "active" && folderSelection.kind !== "shared-folder";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Task #459: switched from a fixed-viewport `h-screen overflow-hidden`
|
// switched from a fixed-viewport `h-screen overflow-hidden`
|
||||||
// shell to a document-scrolling layout (`min-h-[100dvh]`) so iOS
|
// shell to a document-scrolling layout (`min-h-[100dvh]`) so iOS
|
||||||
// Safari can collapse its URL bar on scroll, matching the executive
|
// Safari can collapse its URL bar on scroll, matching the executive
|
||||||
// meetings page. The header below is `sticky top-0` so it stays
|
// meetings page. The header below is `sticky top-0` so it stays
|
||||||
@@ -543,7 +543,7 @@ export default function NotesPage() {
|
|||||||
dir={isRtl ? "rtl" : "ltr"}
|
dir={isRtl ? "rtl" : "ltr"}
|
||||||
data-testid="notes-page"
|
data-testid="notes-page"
|
||||||
>
|
>
|
||||||
{/* Task #457: enlarged header — taller padding, bigger title /
|
{/* enlarged header — taller padding, bigger title /
|
||||||
icons / search / tabs so the bar is easier to read on big
|
icons / search / tabs so the bar is easier to read on big
|
||||||
screens. Sizing kept responsive (flex-wrap) so it still folds
|
screens. Sizing kept responsive (flex-wrap) so it still folds
|
||||||
nicely on narrow widths. */}
|
nicely on narrow widths. */}
|
||||||
@@ -595,7 +595,7 @@ export default function NotesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-3 flex-wrap">
|
<div className="mt-4 flex items-center gap-3 flex-wrap">
|
||||||
{/* Task #466: replaced the four-tab email-style bar with a unified
|
{/* replaced the four-tab email-style bar with a unified
|
||||||
feed. The page opens directly on "My Notes"; Inbox / Sent /
|
feed. The page opens directly on "My Notes"; Inbox / Sent /
|
||||||
Archived now live in the overflow menu (⋯) on the end of the
|
Archived now live in the overflow menu (⋯) on the end of the
|
||||||
row. A small back button appears when the user has navigated
|
row. A small back button appears when the user has navigated
|
||||||
@@ -639,7 +639,7 @@ export default function NotesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="ms-auto flex items-center gap-2">
|
<div className="ms-auto flex items-center gap-2">
|
||||||
{/* Task #472: bulk-select toggle for the Sent tab. Only shown
|
{/* bulk-select toggle for the Sent tab. Only shown
|
||||||
when there are sent notes to act on. Entering selection
|
when there are sent notes to act on. Entering selection
|
||||||
mode swaps the cards' click behavior from "open thread"
|
mode swaps the cards' click behavior from "open thread"
|
||||||
to "toggle checkbox" and exposes the bulk action bar
|
to "toggle checkbox" and exposes the bulk action bar
|
||||||
@@ -771,13 +771,13 @@ export default function NotesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task #459: inner scroll container removed (was
|
{/* inner scroll container removed (was
|
||||||
`flex-1 min-h-0 overflow-y-auto`) — content now scrolls with
|
`flex-1 min-h-0 overflow-y-auto`) — content now scrolls with
|
||||||
the document so iOS Safari collapses the URL bar on scroll.
|
the document so iOS Safari collapses the URL bar on scroll.
|
||||||
The sticky header above keeps the controls pinned. */}
|
The sticky header above keeps the controls pinned. */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
|
|
||||||
{/* Task #514: switched the folders+content row from flex to CSS
|
{/* switched the folders+content row from flex to CSS
|
||||||
grid so the top composer can sit at the SAME vertical
|
grid so the top composer can sit at the SAME vertical
|
||||||
position as the FoldersRail on desktop, while still
|
position as the FoldersRail on desktop, while still
|
||||||
preserving the original mobile reading order
|
preserving the original mobile reading order
|
||||||
@@ -978,7 +978,7 @@ export default function NotesPage() {
|
|||||||
<Archive size={14} />
|
<Archive size={14} />
|
||||||
{t("notes.bulkArchive", "Archive")}
|
{t("notes.bulkArchive", "Archive")}
|
||||||
</button>
|
</button>
|
||||||
{/* Task #512: bulk Delete sits next to Archive. It's
|
{/* bulk Delete sits next to Archive. It's
|
||||||
destructive and user-scoped — copy in the confirm
|
destructive and user-scoped — copy in the confirm
|
||||||
dialog clarifies the sender keeps their copy. */}
|
dialog clarifies the sender keeps their copy. */}
|
||||||
<button
|
<button
|
||||||
@@ -1095,7 +1095,7 @@ export default function NotesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Task #472: confirmation for bulk-deleting selected sent notes.
|
{/* confirmation for bulk-deleting selected sent notes.
|
||||||
Fires N parallel DELETE /notes/:id calls via Promise.allSettled
|
Fires N parallel DELETE /notes/:id calls via Promise.allSettled
|
||||||
so partial failures still report a precise count. On success
|
so partial failures still report a precise count. On success
|
||||||
the React Query "notes" key is invalidated by the mutation so
|
the React Query "notes" key is invalidated by the mutation so
|
||||||
@@ -1185,7 +1185,7 @@ export default function NotesPage() {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Task #510: confirmation for bulk-archiving selected inbox notes.
|
{/* confirmation for bulk-archiving selected inbox notes.
|
||||||
Mirrors the Sent bulk-delete dialog: parallel POST /notes/:id/archive
|
Mirrors the Sent bulk-delete dialog: parallel POST /notes/:id/archive
|
||||||
via worker pool, partial-failure aware toasts. */}
|
via worker pool, partial-failure aware toasts. */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
@@ -1273,7 +1273,7 @@ export default function NotesPage() {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Task #512: confirm dialog for bulk-deleting inbox notes. The
|
{/* confirm dialog for bulk-deleting inbox notes. The
|
||||||
copy makes clear it removes them from BOTH inbox + archive
|
copy makes clear it removes them from BOTH inbox + archive
|
||||||
and that the sender keeps their copy. Single POST to the
|
and that the sender keeps their copy. Single POST to the
|
||||||
bulk endpoint; reports per-id ok/notFound. */}
|
bulk endpoint; reports per-id ok/notFound. */}
|
||||||
@@ -1362,7 +1362,7 @@ export default function NotesPage() {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Task #512: per-row inbox delete confirm. Driven by the page-level
|
{/* per-row inbox delete confirm. Driven by the page-level
|
||||||
singleDeleteInboxId state set from each ReceivedList card so the
|
singleDeleteInboxId state set from each ReceivedList card so the
|
||||||
dialog can mount once instead of per card. */}
|
dialog can mount once instead of per card. */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
@@ -1529,7 +1529,7 @@ function NoteCard({
|
|||||||
labels: NoteLabel[];
|
labels: NoteLabel[];
|
||||||
onEdit: (n: Note) => void;
|
onEdit: (n: Note) => void;
|
||||||
onSend: (n: Note) => void;
|
onSend: (n: Note) => void;
|
||||||
// Task #454: shared-folder editors don't own the note, and the
|
// shared-folder editors don't own the note, and the
|
||||||
// server-side /send route is owner-only, so the Send button is
|
// server-side /send route is owner-only, so the Send button is
|
||||||
// suppressed for them to avoid a UI affordance that would 403.
|
// suppressed for them to avoid a UI affordance that would 403.
|
||||||
hideSend?: boolean;
|
hideSend?: boolean;
|
||||||
@@ -2205,7 +2205,7 @@ function ReceivedList({
|
|||||||
selectionMode?: boolean;
|
selectionMode?: boolean;
|
||||||
selectedIds?: Set<number>;
|
selectedIds?: Set<number>;
|
||||||
onToggle?: (id: number) => void;
|
onToggle?: (id: number) => void;
|
||||||
// Task #512: optional per-row delete affordance. Only the live
|
// optional per-row delete affordance. Only the live
|
||||||
// Inbox passes it; the Archived section omits it because deletes
|
// Inbox passes it; the Archived section omits it because deletes
|
||||||
// are surfaced at the page level there.
|
// are surfaced at the page level there.
|
||||||
onRequestDelete?: (id: number) => void;
|
onRequestDelete?: (id: number) => void;
|
||||||
@@ -2332,7 +2332,7 @@ function SentList({
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
notes: SentNote[];
|
notes: SentNote[];
|
||||||
onOpen: (id: number) => void;
|
onOpen: (id: number) => void;
|
||||||
// Task #472: when `selectionMode` is on the cards become checkbox
|
// when `selectionMode` is on the cards become checkbox
|
||||||
// toggles instead of thread openers. The page-level state is passed
|
// toggles instead of thread openers. The page-level state is passed
|
||||||
// in so the bulk action bar (rendered above this list) can mirror
|
// in so the bulk action bar (rendered above this list) can mirror
|
||||||
// the same Set without prop-drilling state setters.
|
// the same Set without prop-drilling state setters.
|
||||||
@@ -2708,12 +2708,12 @@ function ThreadDialog({
|
|||||||
const markRead = useMarkNoteRead();
|
const markRead = useMarkNoteRead();
|
||||||
const reply = useReplyToNote();
|
const reply = useReplyToNote();
|
||||||
const archive = useArchiveReceivedNote();
|
const archive = useArchiveReceivedNote();
|
||||||
// Task #512: per-row delete from the open thread. Lives next to the
|
// per-row delete from the open thread. Lives next to the
|
||||||
// existing Archive button so single-note delete doesn't require
|
// existing Archive button so single-note delete doesn't require
|
||||||
// entering bulk-selection mode.
|
// entering bulk-selection mode.
|
||||||
const deleteReceived = useDeleteReceivedNote();
|
const deleteReceived = useDeleteReceivedNote();
|
||||||
const [singleDeleteOpen, setSingleDeleteOpen] = useState(false);
|
const [singleDeleteOpen, setSingleDeleteOpen] = useState(false);
|
||||||
// Task #438: collaborative checklist toggle scoped to the open thread.
|
// collaborative checklist toggle scoped to the open thread.
|
||||||
const toggleChecklistItem = useToggleChecklistItem();
|
const toggleChecklistItem = useToggleChecklistItem();
|
||||||
const [replyText, setReplyText] = useState("");
|
const [replyText, setReplyText] = useState("");
|
||||||
const [ownerReplyTarget, setOwnerReplyTarget] = useState<number | null>(null);
|
const [ownerReplyTarget, setOwnerReplyTarget] = useState<number | null>(null);
|
||||||
@@ -2805,7 +2805,7 @@ function ThreadDialog({
|
|||||||
items={thread.items ?? []}
|
items={thread.items ?? []}
|
||||||
testIdPrefix={`thread-checklist-${thread.id}`}
|
testIdPrefix={`thread-checklist-${thread.id}`}
|
||||||
noteColor={thread.color}
|
noteColor={thread.color}
|
||||||
// Task #438: gate toggles to match server auth — only
|
// gate toggles to match server auth — only
|
||||||
// the owner or an active (non-archived) recipient can
|
// the owner or an active (non-archived) recipient can
|
||||||
// collaboratively check items. Admins viewing a note
|
// collaboratively check items. Admins viewing a note
|
||||||
// they neither own nor were sent get a read-only
|
// they neither own nor were sent get a read-only
|
||||||
@@ -2943,7 +2943,7 @@ function ThreadDialog({
|
|||||||
<Archive size={14} className="me-1" />
|
<Archive size={14} className="me-1" />
|
||||||
{t("notes.archive", "Archive")}
|
{t("notes.archive", "Archive")}
|
||||||
</Button>
|
</Button>
|
||||||
{/* Task #512: per-row Delete sits next to Archive
|
{/* per-row Delete sits next to Archive
|
||||||
in the thread footer. Confirms inline before
|
in the thread footer. Confirms inline before
|
||||||
calling DELETE /notes/received/:id so users
|
calling DELETE /notes/received/:id so users
|
||||||
don't lose a note by accident. */}
|
don't lose a note by accident. */}
|
||||||
@@ -2983,7 +2983,7 @@ function ThreadDialog({
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
{/* Task #512: single-note delete confirm rendered as a sibling so
|
{/* single-note delete confirm rendered as a sibling so
|
||||||
it overlays cleanly above the thread dialog. */}
|
it overlays cleanly above the thread dialog. */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={singleDeleteOpen}
|
open={singleDeleteOpen}
|
||||||
@@ -3452,7 +3452,7 @@ function Composer({
|
|||||||
labels: NoteLabel[];
|
labels: NoteLabel[];
|
||||||
folderSelection: FolderSelection;
|
folderSelection: FolderSelection;
|
||||||
onSend: (id: number) => void;
|
onSend: (id: number) => void;
|
||||||
// Task #454: shared-folder editors create notes that get stamped to
|
// shared-folder editors create notes that get stamped to
|
||||||
// the folder owner; the owner-only `/notes/:id/send` route would 403
|
// the folder owner; the owner-only `/notes/:id/send` route would 403
|
||||||
// for them, so the composer hides its Send button in that context.
|
// for them, so the composer hides its Send button in that context.
|
||||||
hideSend?: boolean;
|
hideSend?: boolean;
|
||||||
@@ -3555,7 +3555,7 @@ function Composer({
|
|||||||
create.mutate(payload, { onSuccess: reset });
|
create.mutate(payload, { onSuccess: reset });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Task #444: Send button inside the composer. The plan agreed with
|
// Send button inside the composer. The plan agreed with
|
||||||
// the user is "save first, then open the send dialog for the new
|
// the user is "save first, then open the send dialog for the new
|
||||||
// note". On success we reset the composer (mirroring save()) and
|
// note". On success we reset the composer (mirroring save()) and
|
||||||
// hand the freshly created note's id up to the page so it can mount
|
// hand the freshly created note's id up to the page so it can mount
|
||||||
@@ -3632,7 +3632,7 @@ function Composer({
|
|||||||
<Button size="sm" variant="ghost" onClick={save} data-testid="notes-composer-save">
|
<Button size="sm" variant="ghost" onClick={save} data-testid="notes-composer-save">
|
||||||
{t("notes.save", "Save")}
|
{t("notes.save", "Save")}
|
||||||
</Button>
|
</Button>
|
||||||
{/* Task #444: Send action inside the composer. Saves first
|
{/* Send action inside the composer. Saves first
|
||||||
(auto-save on send was explicitly OK'd by the user),
|
(auto-save on send was explicitly OK'd by the user),
|
||||||
then opens <SendNoteDialog> for the new note. Disabled
|
then opens <SendNoteDialog> for the new note. Disabled
|
||||||
while empty or while the create request is in flight to
|
while empty or while the create request is in flight to
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Task #517 E2E:
|
// E2E:
|
||||||
// 1) An app with an `image_url` renders that image (not a Lucide icon)
|
// 1) An app with an `image_url` renders that image (not a Lucide icon)
|
||||||
// on the launcher tile.
|
// on the launcher tile.
|
||||||
// 2) An app with `open_mode='external_tab'` opens its `external_url` in
|
// 2) An app with `open_mode='external_tab'` opens its `external_url` in
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function attendanceLabel(lang, kind) {
|
|||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
// E2E coverage for Task #204: the per-group attendee headers
|
// E2E coverage for: the per-group attendee headers
|
||||||
// ("Virtual Attendance / Internal Attendance / External Attendance")
|
// ("Virtual Attendance / Internal Attendance / External Attendance")
|
||||||
// rendered above each attendee group in the schedule's attendee cell.
|
// rendered above each attendee group in the schedule's attendee cell.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ for (const lang of ["en", "ar"]) {
|
|||||||
test(`Schedule [${lang}]: top "+ subheading" chip is NEVER rendered (empty / person-only / subheading-only / mixed)`, async ({
|
test(`Schedule [${lang}]: top "+ subheading" chip is NEVER rendered (empty / person-only / subheading-only / mixed)`, async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Task #230: the top chip is gone in every state. We seed two
|
// the top chip is gone in every state. We seed two
|
||||||
// meetings that, between them, exercise all four cell states:
|
// meetings that, between them, exercise all four cell states:
|
||||||
// meetingEmptyId: a brand-new meeting with zero attendees ⇒
|
// meetingEmptyId: a brand-new meeting with zero attendees ⇒
|
||||||
// the cell is EMPTY.
|
// the cell is EMPTY.
|
||||||
@@ -272,7 +272,7 @@ for (const lang of ["en", "ar"]) {
|
|||||||
// must be absent in every group. We prove all three groups
|
// must be absent in every group. We prove all three groups
|
||||||
// mounted by checking each one's per-group trailing "+" person
|
// mounted by checking each one's per-group trailing "+" person
|
||||||
// button is present (testid em-add-attendee-{addType}). NOTE:
|
// button is present (testid em-add-attendee-{addType}). NOTE:
|
||||||
// since Task #234, the trailing "+ subheading" chip is suppressed
|
// since, the trailing "+ subheading" chip is suppressed
|
||||||
// per group in split-mode cells and replaced by ONE cell-level
|
// per group in split-mode cells and replaced by ONE cell-level
|
||||||
// chip (testid em-add-subheading-cell-{meetingId}) — so the
|
// chip (testid em-add-subheading-cell-{meetingId}) — so the
|
||||||
// per-group em-add-subheading-{addType} testids must NOT exist
|
// per-group em-add-subheading-{addType} testids must NOT exist
|
||||||
@@ -773,7 +773,7 @@ for (const lang of ["en", "ar"]) {
|
|||||||
test(`Schedule [${lang}]: split-mode cell shows ONE cell-level "+ subheading" chip and routes to the LAST visible group`, async ({
|
test(`Schedule [${lang}]: split-mode cell shows ONE cell-level "+ subheading" chip and routes to the LAST visible group`, async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Task #234: in a split-mode cell (≥2 attendance groups visible),
|
// in a split-mode cell (≥2 attendance groups visible),
|
||||||
// the trailing "+ عنوان فرعي" chip must NOT be rendered per group;
|
// the trailing "+ عنوان فرعي" chip must NOT be rendered per group;
|
||||||
// a single cell-level chip below all groups is rendered instead.
|
// a single cell-level chip below all groups is rendered instead.
|
||||||
// Clicking it adds the new subheading to the LAST visible group in
|
// Clicking it adds the new subheading to the LAST visible group in
|
||||||
@@ -935,7 +935,7 @@ for (const lang of ["en", "ar"]) {
|
|||||||
test(`Schedule [${lang}]: split-mode WITHOUT external — cell-level "+ subheading" chip routes to INTERNAL (last visible)`, async ({
|
test(`Schedule [${lang}]: split-mode WITHOUT external — cell-level "+ subheading" chip routes to INTERNAL (last visible)`, async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Task #234 fallback path. The "last visible group" rule resolves
|
// fallback path. The "last visible group" rule resolves
|
||||||
// the cell-level chip's target at click-time as
|
// the cell-level chip's target at click-time as
|
||||||
// external > internal > virtual. The previous test exercises the
|
// external > internal > virtual. The previous test exercises the
|
||||||
// external-wins path; this one locks the internal-wins fallback by
|
// external-wins path; this one locks the internal-wins fallback by
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function loadLocale(lang) {
|
|||||||
}
|
}
|
||||||
const localeBundles = { en: loadLocale("en"), ar: loadLocale("ar") };
|
const localeBundles = { en: loadLocale("en"), ar: loadLocale("ar") };
|
||||||
|
|
||||||
// E2E coverage for Task #207 — custom subheadings inside the attendees
|
// E2E coverage for — custom subheadings inside the attendees
|
||||||
// cell. Subheadings are user-defined section labels that live alongside
|
// cell. Subheadings are user-defined section labels that live alongside
|
||||||
// person rows in a meeting's attendee list, but are NOT counted as
|
// person rows in a meeting's attendee list, but are NOT counted as
|
||||||
// attendees and do NOT advance the running "1-, 2-, 3-…" person index.
|
// attendees and do NOT advance the running "1-, 2-, 3-…" person index.
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ async function gotoSchedule(page, lang) {
|
|||||||
// LTR (English) and RTL (Arabic) locales — since the bug originally
|
// LTR (English) and RTL (Arabic) locales — since the bug originally
|
||||||
// surfaced on the Arabic schedule.
|
// surfaced on the Arabic schedule.
|
||||||
//
|
//
|
||||||
// Per Task #203 the index prefix is intentionally hidden when the
|
// the index prefix is intentionally hidden when the
|
||||||
// attendee group has only one entry, so this test scopes itself to
|
// attendee group has only one entry, so this test scopes itself to
|
||||||
// rows that actually carry an `em-attendee-index-*` span.
|
// rows that actually carry an `em-attendee-index-*` span.
|
||||||
for (const lang of ["en", "ar"]) {
|
for (const lang of ["en", "ar"]) {
|
||||||
@@ -260,7 +260,7 @@ for (const lang of ["en", "ar"]) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task #203: when an attendee group has exactly one entry the
|
// when an attendee group has exactly one entry the
|
||||||
// leading `1-` index prefix is hidden because there is nothing to
|
// leading `1-` index prefix is hidden because there is nothing to
|
||||||
// enumerate. Assert that for at least one solo-attendee row, no
|
// enumerate. Assert that for at least one solo-attendee row, no
|
||||||
// `em-attendee-index-*` span exists inside it, in BOTH locales.
|
// `em-attendee-index-*` span exists inside it, in BOTH locales.
|
||||||
|
|||||||
@@ -708,7 +708,7 @@ test("Schedule keyboard: Tab from the title cell reaches the time cell in the sa
|
|||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Task #308: typing on the keyboard + Enter must save.
|
// typing on the keyboard + Enter must save.
|
||||||
//
|
//
|
||||||
// The historical bug: the time cell used <input type="time">, which
|
// The historical bug: the time cell used <input type="time">, which
|
||||||
// only fires onChange when the value is a complete, canonical "HH:MM"
|
// only fires onChange when the value is a complete, canonical "HH:MM"
|
||||||
@@ -729,7 +729,7 @@ test("Schedule keyboard: Tab from the title cell reaches the time cell in the sa
|
|||||||
// the same for end, press Enter, and wait for the PATCH + day GET
|
// the same for end, press Enter, and wait for the PATCH + day GET
|
||||||
// refetch.
|
// refetch.
|
||||||
//
|
//
|
||||||
// Per task #315 the time cell is now a 12-hour picker — typing a
|
// the time cell is now a 12-hour picker — typing a
|
||||||
// 1–12 hour without first picking AM or PM is intentionally an
|
// 1–12 hour without first picking AM or PM is intentionally an
|
||||||
// ambiguous parse error (the fix for the "1:15 silently saved as
|
// ambiguous parse error (the fix for the "1:15 silently saved as
|
||||||
// AM" bug). For typed shapes that the picker can resolve on its
|
// AM" bug). For typed shapes that the picker can resolve on its
|
||||||
@@ -802,7 +802,7 @@ async function typeTimesAndSave(
|
|||||||
// hour/minute so a test that accidentally passes by reading stale
|
// hour/minute so a test that accidentally passes by reading stale
|
||||||
// state (rather than the new value) would fail.
|
// state (rather than the new value) would fail.
|
||||||
//
|
//
|
||||||
// `startPeriod` / `endPeriod` mirror task #315's "the picker has no
|
// `startPeriod` / `endPeriod` mirror's "the picker has no
|
||||||
// implicit default" rule: ambiguous typed shapes (1–12 hour, no
|
// implicit default" rule: ambiguous typed shapes (1–12 hour, no
|
||||||
// marker) require the helper to click the AM/PM toggle, while
|
// marker) require the helper to click the AM/PM toggle, while
|
||||||
// shapes with an explicit marker or a 24h-only hour are resolved by
|
// shapes with an explicit marker or a 24h-only hour are resolved by
|
||||||
@@ -884,7 +884,7 @@ test("Schedule keyboard (AR): typing Arabic-Indic digits + Enter saves canonical
|
|||||||
await gotoMeetingRow(page, meetingId, date);
|
await gotoMeetingRow(page, meetingId, date);
|
||||||
|
|
||||||
// ٠٩:٣٠ -> 09:30, ١٠:٤٥ -> 10:45. Both are 1–12 hours so the
|
// ٠٩:٣٠ -> 09:30, ١٠:٤٥ -> 10:45. Both are 1–12 hours so the
|
||||||
// picker requires an explicit AM/PM choice (task #315) — pass the
|
// picker requires an explicit AM/PM choice — pass the
|
||||||
// Arabic morning marker via the toggle so the helper clicks ص.
|
// Arabic morning marker via the toggle so the helper clicks ص.
|
||||||
await typeTimesAndSave(page, meetingId, date, "٠٩:٣٠", "١٠:٤٥", "am", "am");
|
await typeTimesAndSave(page, meetingId, date, "٠٩:٣٠", "١٠:٤٥", "am", "am");
|
||||||
|
|
||||||
@@ -897,7 +897,7 @@ test("Schedule keyboard (AR): typing Arabic-Indic digits + Enter saves canonical
|
|||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Task #311: dragging a visible meeting must produce a chronologically-
|
// dragging a visible meeting must produce a chronologically-
|
||||||
// sorted visible list, even when the same day contains hidden cancelled
|
// sorted visible list, even when the same day contains hidden cancelled
|
||||||
// meetings. Before the fix, cancelled meetings (filtered out of
|
// meetings. Before the fix, cancelled meetings (filtered out of
|
||||||
// `orderedMeetings` for display) were still included in the reorder
|
// `orderedMeetings` for display) were still included in the reorder
|
||||||
@@ -1239,7 +1239,7 @@ test("Schedule keyboard: Tab walks start hour → start minute → start AM →
|
|||||||
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
|
const endPm = page.getByTestId(`em-time-end-period-pm-${meetingId}`);
|
||||||
await expect(startInput).toBeFocused();
|
await expect(startInput).toBeFocused();
|
||||||
|
|
||||||
// Per task #315 each side has THREE controls: hour input, minute
|
// each side has THREE controls: hour input, minute
|
||||||
// input, AM/PM toggle. Tab walks through start hour → start minute
|
// input, AM/PM toggle. Tab walks through start hour → start minute
|
||||||
// → start AM → start PM → end hour → end minute → end AM → end PM.
|
// → start AM → start PM → end hour → end minute → end AM → end PM.
|
||||||
// "7" + "15" is a 1–12 hour with no marker, so the picker requires
|
// "7" + "15" is a 1–12 hour with no marker, so the picker requires
|
||||||
@@ -1429,7 +1429,7 @@ test.describe("Schedule mobile viewport (iPhone 375)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Task #316: stop the snap-back race + collapse the editor instantly.
|
// stop the snap-back race + collapse the editor instantly.
|
||||||
// Three guards:
|
// Three guards:
|
||||||
// 1. Save → immediate re-open of the editor must show the SAVED
|
// 1. Save → immediate re-open of the editor must show the SAVED
|
||||||
// values pre-filled, not blank fields. (The bug the user
|
// values pre-filled, not blank fields. (The bug the user
|
||||||
@@ -1727,8 +1727,8 @@ test("Schedule time editor: a failed PATCH rolls back the optimistic times and r
|
|||||||
|
|
||||||
// ─── #317: optimistic-cache repaint for non-time inline editors ─────
|
// ─── #317: optimistic-cache repaint for non-time inline editors ─────
|
||||||
//
|
//
|
||||||
// Task #316 made the time cell repaint instantly via an optimistic
|
// made the time cell repaint instantly via an optimistic
|
||||||
// React Query cache write. Task #317 generalises that pattern to the
|
// React Query cache write. generalises that pattern to the
|
||||||
// title, attendee-name, and merge-text editors. The three tests below
|
// title, attendee-name, and merge-text editors. The three tests below
|
||||||
// mirror the time-cell tests for the title cell:
|
// mirror the time-cell tests for the title cell:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// E2E + UI coverage for the cell-merge feature added by task #152.
|
// E2E + UI coverage for the cell-merge feature added by.
|
||||||
//
|
//
|
||||||
// Two scenarios live here:
|
// Two scenarios live here:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Verifies the consolidated row-actions kebab menu introduced as part
|
// Verifies the consolidated row-actions kebab menu introduced as part
|
||||||
// of task #191 — the three previously-separate row overlays (delete,
|
// of — the three previously-separate row overlays (delete,
|
||||||
// row color, merge cells) are now collapsed into a single menu so they
|
// row color, merge cells) are now collapsed into a single menu so they
|
||||||
// no longer collide visually inside narrow cells. Each sub-action
|
// no longer collide visually inside narrow cells. Each sub-action
|
||||||
// keeps its original testid, so the rest of the suite + any external
|
// keeps its original testid, so the rest of the suite + any external
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Coverage for task #192 — quick-preview indicators on the row-actions
|
// Coverage for — quick-preview indicators on the row-actions
|
||||||
// kebab menu's main view:
|
// kebab menu's main view:
|
||||||
//
|
//
|
||||||
// 1. The "Row color" item shows a tiny swatch reflecting the row's
|
// 1. The "Row color" item shows a tiny swatch reflecting the row's
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ test("Schedule: editing an attendee name with bold + color via the Tiptap toolba
|
|||||||
|
|
||||||
// Seed a meeting with one attendee whose name is plain text. The
|
// Seed a meeting with one attendee whose name is plain text. The
|
||||||
// attendee row uses the SAME EditableCell + Tiptap toolbar as titles
|
// attendee row uses the SAME EditableCell + Tiptap toolbar as titles
|
||||||
// (parent task #122), so applying bold + a color swatch and saving
|
// (parent), so applying bold + a color swatch and saving
|
||||||
// via the toolbar's check button must round-trip through the PUT
|
// via the toolbar's check button must round-trip through the PUT
|
||||||
// attendees endpoint and survive a full reload, just like titles do.
|
// attendees endpoint and survive a full reload, just like titles do.
|
||||||
const date = uniqueFutureDate(4);
|
const date = uniqueFutureDate(4);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Task #512 e2e: per-recipient delete on the Inbox tab.
|
// e2e: per-recipient delete on the Inbox tab.
|
||||||
// Seeds three received notes for a recipient, opens Notes (Inbox is
|
// Seeds three received notes for a recipient, opens Notes (Inbox is
|
||||||
// the default view), enters bulk-select mode, picks two cards,
|
// the default view), enters bulk-select mode, picks two cards,
|
||||||
// confirms the bulk delete, and verifies that the deleted recipient
|
// confirms the bulk delete, and verifies that the deleted recipient
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Task #438 e2e: a recipient toggles a checklist item directly from the
|
// e2e: a recipient toggles a checklist item directly from the
|
||||||
// incoming-note popup, and the change is visible to the sender (and
|
// incoming-note popup, and the change is visible to the sender (and
|
||||||
// persists server-side).
|
// persists server-side).
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// E2E test for Task #406: when a sender sends a note, the recipient (in
|
// E2E test for: when a sender sends a note, the recipient (in
|
||||||
// another browser context) sees the note pop up as a centered modal on
|
// another browser context) sees the note pop up as a centered modal on
|
||||||
// their screen — wherever they are in the app — within ~seconds.
|
// their screen — wherever they are in the app — within ~seconds.
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
@@ -225,7 +225,7 @@ test("recipient sees a popup modal when a note arrives in real time", async ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task #410: when the recipient replies on a note, the *original sender*
|
// when the recipient replies on a note, the *original sender*
|
||||||
// (note owner) should see the same floating-card popup variant — pre-
|
// (note owner) should see the same floating-card popup variant — pre-
|
||||||
// filled with the reply text + replier name — wherever they are in the
|
// filled with the reply text + replier name — wherever they are in the
|
||||||
// app, with the same chime/vibration profile as the new-note alert.
|
// app, with the same chime/vibration profile as the new-note alert.
|
||||||
@@ -373,7 +373,7 @@ test("sender sees a floating reply card when the recipient replies", async ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task #433: a checklist note must render the inner sticky-note "as a
|
// a checklist note must render the inner sticky-note "as a
|
||||||
// real note" — i.e. the popup body shows a read-only checklist preview
|
// real note" — i.e. the popup body shows a read-only checklist preview
|
||||||
// (not the plain text body), proving `noteKind` + `items` are forwarded
|
// (not the plain text body), proving `noteKind` + `items` are forwarded
|
||||||
// from the backend through the socket payload into the popup.
|
// from the backend through the socket payload into the popup.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Task #427: on touch devices (iPad / phone), tapping a button on the
|
// on touch devices (iPad / phone), tapping a button on the
|
||||||
// floating note popup MUST work — even when other floating UI is on
|
// floating note popup MUST work — even when other floating UI is on
|
||||||
// screen at the same time. The previous root cause was a full-viewport
|
// screen at the same time. The previous root cause was a full-viewport
|
||||||
// `fixed inset-0` wrapper at z-[110] that intermittently swallowed
|
// `fixed inset-0` wrapper at z-[110] that intermittently swallowed
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Task #472 e2e: multi-select + bulk-delete on the Sent notes tab.
|
// e2e: multi-select + bulk-delete on the Sent notes tab.
|
||||||
// Seeds three sent notes for a user, opens the Sent view, enters
|
// Seeds three sent notes for a user, opens the Sent view, enters
|
||||||
// selection mode, picks two cards, confirms the bulk delete, and
|
// selection mode, picks two cards, confirms the bulk delete, and
|
||||||
// verifies that the count drops by two while the unselected note
|
// verifies that the count drops by two while the unselected note
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// E2E test: the note thread dialog must cap its height instead of growing
|
// E2E test: the note thread dialog must cap its height instead of growing
|
||||||
// to fill the entire viewport when the conversation is long. Regression
|
// to fill the entire viewport when the conversation is long. Regression
|
||||||
// guard for task #417 (user reported the dialog covering the whole page).
|
// guard for (user reported the dialog covering the whole page).
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// E2E test for Task #441: notification sounds use Web Audio so they
|
// E2E test for: notification sounds use Web Audio so they
|
||||||
// reliably play on iPad Safari after a single user gesture. Verifies
|
// reliably play on iPad Safari after a single user gesture. Verifies
|
||||||
// (1) the AudioContext reaches `running` state after the user clicks
|
// (1) the AudioContext reaches `running` state after the user clicks
|
||||||
// the per-sound preview button in notification settings, (2) the play
|
// the per-sound preview button in notification settings, (2) the play
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Playwright e2e for the per-card and bulk delete affordances on
|
// Playwright e2e for the per-card and bulk delete affordances on
|
||||||
// /orders/incoming added in task #397.
|
// /orders/incoming added in.
|
||||||
//
|
//
|
||||||
// Covers:
|
// Covers:
|
||||||
// - Bulk delete: receiver selects 2 unclaimed orders via per-card
|
// - Bulk delete: receiver selects 2 unclaimed orders via per-card
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Task #438: lightweight render test for the upcoming-meeting alert
|
// lightweight render test for the upcoming-meeting alert
|
||||||
// entrance animation. Asserts the alert panel renders with the
|
// entrance animation. Asserts the alert panel renders with the
|
||||||
// matching scale-in/opacity transition, an accent ring (boxShadow with
|
// matching scale-in/opacity transition, an accent ring (boxShadow with
|
||||||
// the user's accent color), and a pulsing animate-ping halo around the
|
// the user's accent color), and a pulsing animate-ping halo around the
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// in the SPA today. Locking them keeps the seeded path stable so
|
// in the SPA today. Locking them keeps the seeded path stable so
|
||||||
// links, deep-links, audits, and future hardcoded routes stay in
|
// links, deep-links, audits, and future hardcoded routes stay in
|
||||||
// sync with the seed contract.
|
// sync with the seed contract.
|
||||||
// Task #517: server rejects route AND slug changes for these slugs and
|
// server rejects route AND slug changes for these slugs and
|
||||||
// the admin form locks both fields. Add a slug here whenever you wire
|
// the admin form locks both fields. Add a slug here whenever you wire
|
||||||
// a new hardcoded <Route> in App.tsx OR seed a new canonical app.
|
// a new hardcoded <Route> in App.tsx OR seed a new canonical app.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export type NoteReply = typeof noteRepliesTable.$inferSelect;
|
|||||||
* always see the owner's current notes via a join, so adds/edits/removes by the
|
* always see the owner's current notes via a join, so adds/edits/removes by the
|
||||||
* owner are reflected live in the recipient's "Shared with me" view.
|
* owner are reflected live in the recipient's "Shared with me" view.
|
||||||
*
|
*
|
||||||
* Permission per recipient (Task #454): "view" (default, read-only) or
|
* Permission per recipient: "view" (default, read-only) or
|
||||||
* "edit" (full create/update/delete on the owner's notes inside this folder).
|
* "edit" (full create/update/delete on the owner's notes inside this folder).
|
||||||
* Notes mutated by an editor still belong to the folder owner — the folder
|
* Notes mutated by an editor still belong to the folder owner — the folder
|
||||||
* stays a single namespace under `noteFoldersTable.userId`.
|
* stays a single namespace under `noteFoldersTable.userId`.
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ echo "[3/5] Removing Replit-specific files from every commit ..."
|
|||||||
--path .agents \
|
--path .agents \
|
||||||
--path attached_assets \
|
--path attached_assets \
|
||||||
--path .gitsafe-backup \
|
--path .gitsafe-backup \
|
||||||
|
--path PUBLISH.md \
|
||||||
|
--path MIGRATION_REPORT.md \
|
||||||
--path-glob '**/.replit-artifact' \
|
--path-glob '**/.replit-artifact' \
|
||||||
--path-glob '**/.replit-artifact/**'
|
--path-glob '**/.replit-artifact/**'
|
||||||
|
|
||||||
|
|||||||
+9
-10
@@ -1,8 +1,7 @@
|
|||||||
# Threat Model — Tx OS
|
# Threat Model — Tx OS
|
||||||
|
|
||||||
**Date:** 2026-05-13
|
**Version:** 1.0
|
||||||
**Version:** 1.0 (initial)
|
**Companion document:** `SECURITY_REVIEW.md` (manual review findings, kept internally).
|
||||||
**Companion documents:** `.local/security/scan-report.md` (automated scanners), `.local/security/manual-review.md` (manual review findings).
|
|
||||||
|
|
||||||
> This document captures Tx OS's assets, trust boundaries, threats (STRIDE-style), and the security guarantees the system commits to upholding. It is the design-level companion to the per-finding manual review and is intended to outlive any individual fix task.
|
> This document captures Tx OS's assets, trust boundaries, threats (STRIDE-style), and the security guarantees the system commits to upholding. It is the design-level companion to the per-finding manual review and is intended to outlive any individual fix task.
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ These are the invariants every change must preserve. A regression on any one of
|
|||||||
- **G1 — Authentication required.** Every `/api/*` endpoint other than the intentionally-public set requires a valid session. The intentionally-public set today is: `/api/auth/login`, `/api/auth/register`, `/api/auth/forgot-password`, `/api/auth/reset-password`, `/api/auth/reset-password/verify`, `/api/health`, `/api/settings` (see MR-L5 — recommended to remove from this list), and `/api/storage/public-objects/*`. Adding any new endpoint to this list must be a deliberate, reviewed change.
|
- **G1 — Authentication required.** Every `/api/*` endpoint other than the intentionally-public set requires a valid session. The intentionally-public set today is: `/api/auth/login`, `/api/auth/register`, `/api/auth/forgot-password`, `/api/auth/reset-password`, `/api/auth/reset-password/verify`, `/api/health`, `/api/settings` (see MR-L5 — recommended to remove from this list), and `/api/storage/public-objects/*`. Adding any new endpoint to this list must be a deliberate, reviewed change.
|
||||||
- **G2 — Server-side authorization.** UI hiding is *not* a permission boundary. Every mutating endpoint and every read of non-public data must enforce its permission server-side, even if the SPA hides the control.
|
- **G2 — Server-side authorization.** UI hiding is *not* a permission boundary. Every mutating endpoint and every read of non-public data must enforce its permission server-side, even if the SPA hides the control.
|
||||||
- **G3 — Cross-user isolation.** A user can never read or modify another user's notes, orders, password, settings, sessions, or notifications unless an explicit share / group / admin permission grants it.
|
- **G3 — Cross-user isolation.** A user can never read or modify another user's notes, orders, password, settings, sessions, or notifications unless an explicit share / group / admin permission grants it.
|
||||||
- **G4 — Built-in app routes are immutable** (Task #517). Slugs in `BUILTIN_APP_SLUGS` cannot have their `route` or `slug` mutated, even by an admin, because the SPA hardcodes those routes.
|
- **G4 — Built-in app routes are immutable.** Slugs in `BUILTIN_APP_SLUGS` cannot have their `route` or `slug` mutated, even by an admin, because the SPA hardcodes those routes.
|
||||||
- **G5 — Sensitive HTML is sanitized.** Every user-supplied string that enters an HTML or PDF rendering path passes through `htmlToSafeHtml`/`safeHtml` (`sanitize-html` + DOMPurify) with the established allow-list.
|
- **G5 — Sensitive HTML is sanitized.** Every user-supplied string that enters an HTML or PDF rendering path passes through `htmlToSafeHtml`/`safeHtml` (`sanitize-html` + DOMPurify) with the established allow-list.
|
||||||
- **G6 — Secrets never leave the server.** No `.env` value is sent to the browser. `SESSION_SECRET` is required in production (`app.ts:64`).
|
- **G6 — Secrets never leave the server.** No `.env` value is sent to the browser. `SESSION_SECRET` is required in production (`app.ts:64`).
|
||||||
- **G7 — Database access via parameterized queries only.** Drizzle ORM template tags and the column-name allow-list in `executive-meetings.ts:109` are the only `sql\`\`` consumers; no string-spliced SQL is permitted.
|
- **G7 — Database access via parameterized queries only.** Drizzle ORM template tags and the column-name allow-list in `executive-meetings.ts:109` are the only `sql\`\`` consumers; no string-spliced SQL is permitted.
|
||||||
@@ -98,7 +97,7 @@ These are the invariants every change must preserve. A regression on any one of
|
|||||||
|
|
||||||
## 5. STRIDE threats
|
## 5. STRIDE threats
|
||||||
|
|
||||||
For each STRIDE category, the table lists the most consequential threats, the relevant assets, current mitigations, and any open gaps. References like *(MR-H1)* point to entries in `.local/security/manual-review.md`.
|
For each STRIDE category, the table lists the most consequential threats, the relevant assets, current mitigations, and any open gaps. References like *(MR-H1)* point to entries in the internal security review.
|
||||||
|
|
||||||
### 5.1 Spoofing
|
### 5.1 Spoofing
|
||||||
|
|
||||||
@@ -115,7 +114,7 @@ For each STRIDE category, the table lists the most consequential threats, the re
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| T1 | Cross-site request forces state change as the victim | `sameSite=lax` cookie, custom `Content-Type: application/json` for most writes | No CSRF token; `lax` permits top-level GET/POST navigation *(MR-M1)* |
|
| T1 | Cross-site request forces state change as the victim | `sameSite=lax` cookie, custom `Content-Type: application/json` for most writes | No CSRF token; `lax` permits top-level GET/POST navigation *(MR-M1)* |
|
||||||
| T2 | Mutate another user's note/order/meeting | Per-route permission checks; tenant-scoping via `userId`/`folderId` lookups | OK — no mass-assignment issues found |
|
| T2 | Mutate another user's note/order/meeting | Per-route permission checks; tenant-scoping via `userId`/`folderId` lookups | OK — no mass-assignment issues found |
|
||||||
| T3 | Edit a built-in app's route to break navigation | Task #517 lock with regression tests | OK — `apps-builtin-route-lock.test.mjs` covers it |
|
| T3 | Edit a built-in app's route to break navigation | Built-in route lock with regression tests | OK — `apps-builtin-route-lock.test.mjs` covers it |
|
||||||
| T4 | Pollute the audit log with fake events | `app.opens` audit row is recorded for any caller | `POST /apps/:id/open` doesn't gate on visibility *(MR-M5)* |
|
| T4 | Pollute the audit log with fake events | `app.opens` audit row is recorded for any caller | `POST /apps/:id/open` doesn't gate on visibility *(MR-M5)* |
|
||||||
| T5 | Pollute the PDF archive list with attacker-chosen `filePath` | `requireExecutiveAccess` only | Any executive role can write arbitrary paths *(MR-H2)* |
|
| T5 | Pollute the PDF archive list with attacker-chosen `filePath` | `requireExecutiveAccess` only | Any executive role can write arbitrary paths *(MR-H2)* |
|
||||||
|
|
||||||
@@ -155,7 +154,7 @@ For each STRIDE category, the table lists the most consequential threats, the re
|
|||||||
| E1 | Low-priv user reads admin-only meeting PDFs | — | *(MR-H1, MR-H2)* — currently exploitable via storage download |
|
| E1 | Low-priv user reads admin-only meeting PDFs | — | *(MR-H1, MR-H2)* — currently exploitable via storage download |
|
||||||
| E2 | Low-priv user changes their own role | `roles.ts` is `requireAdmin` | OK |
|
| E2 | Low-priv user changes their own role | `roles.ts` is `requireAdmin` | OK |
|
||||||
| E3 | User edits another user's password | `users.ts` password change is `requireAdmin` or self-only | OK |
|
| E3 | User edits another user's password | `users.ts` password change is `requireAdmin` or self-only | OK |
|
||||||
| E4 | Bypass built-in route lock by renaming slug then route | Task #517 locks both fields with regression tests | OK |
|
| E4 | Bypass built-in route lock by renaming slug then route | Both fields locked together with regression tests | OK |
|
||||||
| E5 | Smuggle XSS into PDF or note rendering | `htmlToSafeHtml` + DOMPurify allow-list | OK at HEAD, but no CSP backstop *(MR-M3)* |
|
| E5 | Smuggle XSS into PDF or note rendering | `htmlToSafeHtml` + DOMPurify allow-list | OK at HEAD, but no CSP backstop *(MR-M3)* |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -171,13 +170,13 @@ For each STRIDE category, the table lists the most consequential threats, the re
|
|||||||
|
|
||||||
## 7. Open recommendations (prioritized for follow-up)
|
## 7. Open recommendations (prioritized for follow-up)
|
||||||
|
|
||||||
The detailed remediation text lives in `.local/security/manual-review.md` (per-finding) and in `.local/security/scan-report.md` (dependency batches). The order below is the recommended fix sequence based on risk × effort.
|
The detailed remediation text lives in the internal security review (per-finding) and the dependency-scan report (dependency batches). The order below is the recommended fix sequence based on risk × effort.
|
||||||
|
|
||||||
1. **MR-H1 + MR-H2 + MR-M7** — wire up object-level authorization for `/api/storage/objects/*` and constrain the PDF archive write endpoint. *(Highest user impact; one focused change to `routes/storage.ts` plus a small change to `pdf-archives` write.)*
|
1. **MR-H1 + MR-H2 + MR-M7** — wire up object-level authorization for `/api/storage/objects/*` and constrain the PDF archive write endpoint. *(Highest user impact; one focused change to `routes/storage.ts` plus a small change to `pdf-archives` write.)*
|
||||||
2. **MR-H3 + MR-L4** — add `express-rate-limit` on auth endpoints and a per-user socket cap. *(Small library change, big DoS / brute-force win.)*
|
2. **MR-H3 + MR-L4** — add `express-rate-limit` on auth endpoints and a per-user socket cap. *(Small library change, big DoS / brute-force win.)*
|
||||||
3. **MR-M1 + MR-M2** — CSRF posture (move session cookie to `sameSite=strict` if no third-party deep-links, otherwise add a CSRF token middleware) + `req.session.regenerate` on login.
|
3. **MR-M1 + MR-M2** — CSRF posture (move session cookie to `sameSite=strict` if no third-party deep-links, otherwise add a CSRF token middleware) + `req.session.regenerate` on login.
|
||||||
4. **MR-M3** — add `helmet()` and a basic CSP for the SPA bundle.
|
4. **MR-M3** — add `helmet()` and a basic CSP for the SPA bundle.
|
||||||
5. **MR-M4 + MR-M5 + MR-M6 + MR-L5** — tighten the small set of over-exposed read endpoints (directory, settings) and audit-log write paths.
|
5. **MR-M4 + MR-M5 + MR-M6 + MR-L5** — tighten the small set of over-exposed read endpoints (directory, settings) and audit-log write paths.
|
||||||
6. **Dependency Batch A** (from Task #522) — production-runtime patches for `fast-xml-builder`, `lodash`, `path-to-regexp`, `postcss`.
|
6. **Dependency Batch A** — production-runtime patches for `fast-xml-builder`, `lodash`, `path-to-regexp`, `postcss`.
|
||||||
7. **Dependency Batches B–C** (from Task #522) — build-tool and codegen patches.
|
7. **Dependency Batches B–C** — build-tool and codegen patches.
|
||||||
8. **MR-L1 / MR-L2 / MR-L3 / MR-L6 / MR-L7 / MR-L8** — defense-in-depth and hardening.
|
8. **MR-L1 / MR-L2 / MR-L3 / MR-L6 / MR-L7 / MR-L8** — defense-in-depth and hardening.
|
||||||
|
|||||||
Reference in New Issue
Block a user