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:
riyadhafraa
2026-05-14 06:23:49 +00:00
parent c43a28e143
commit 7a2ae8434d
57 changed files with 335 additions and 360 deletions
+2 -1
View File
@@ -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/
+182
View File
@@ -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.
-209
View File
@@ -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
+4 -4
View File
@@ -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
+11 -11
View File
@@ -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 112 hour // bug — the picker MUST require an explicit toggle for any 112 hour
// without a typed AM/PM marker, and MUST accept any 1323 hour or // without a typed AM/PM marker, and MUST accept any 1323 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 (112, but also accepts a full time as a power-user // 1. Hour input (112, 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".
+1 -1
View File
@@ -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]),
+10 -10
View File
@@ -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
+3 -3
View File
@@ -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 112, minute input 0059, AM/PM toggle). The // field (hour input 112, minute input 0059, 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
+5 -5
View File
@@ -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
+27 -27
View File
@@ -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
// 112 hour without first picking AM or PM is intentionally an // 112 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 (112 hour, no // implicit default" rule: ambiguous typed shapes (112 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 112 hours so the // ٠٩:٣٠ -> 09:30, ١٠:٤٥ -> 10:45. Both are 112 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 112 hour with no marker, so the picker requires // "7" + "15" is a 112 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
+1 -1
View File
@@ -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.
// //
+1 -1
View File
@@ -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`.
+2
View File
@@ -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
View File
@@ -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 BC** (from Task #522) — build-tool and codegen patches. 7. **Dependency Batches BC** — 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.