Commit Graph

107 Commits

Author SHA1 Message Date
riyadhafraa a3ebff2afa feat(setup): Stage 1 first-time setup wizard backend (no UI)
Task #534 — backend, infra, and tooling only. UI ships in Stage 2.

Backend
- New system_settings table (id=1 singleton): installed flag, base_url,
  local_domain, local_ip, https_mode, app_version. Pushed to dev DB.
- New /api/setup/status (open) and /api/setup/{validate,complete}
  (gated by requireSetupOpen — 409 once installed).
- completeInstall is fully transactional: pg_advisory_xact_lock
  serializes concurrent callers, double-gates on installed flag and
  admin existence, then atomically creates the admin user, assigns
  admin role + Admins/Everyone groups, and flips system_settings to
  installed=true. Rolls back on any failure.
- Zod validation, bcrypt hashing, in-memory rate limiter for the
  setup endpoints.

Backward compat
- scripts/src/seed.ts now branches on installed flag + admin
  existence + SEED_*_PASSWORD env vars. Legacy installs (admin
  exists, system_settings empty) get backfilled to installed=true
  via ON CONFLICT DO UPDATE so they are never forced through the
  wizard. When env passwords are unset and no admin exists, the
  seed prints a wizard hint instead of seeding.

Infra
- docker-compose.yml: replaced nginx edge with a Caddy service that
  mounts ./certs and ./docker/Caddyfile. The web service no longer
  publishes a port directly — Caddy is the only public ingress.
- docker/Caddyfile: HTTPS site for LOCAL_DOMAIN/LOCAL_IP with
  WebSocket upgrade preserved and a plaintext :80 fallback when
  HTTPS_MODE=skip (dev-only).
- .env.example: added LOCAL_DOMAIN, LOCAL_IP, BASE_URL, HTTP_PORT,
  HTTPS_PORT, HTTPS_MODE; SEED_*_PASSWORD now optional.

Tooling
- scripts/local-setup.sh: idempotent OS-aware bootstrap (.env upsert,
  mkcert hint, cert SAN check, dry-run via LOCAL_SETUP_DRY_RUN).
  start.sh untouched.

Tests
- artifacts/api-server/tests/setup-wizard.test.mjs: 7/7 pass
  (snapshot/restore admin role + system_settings around tests).
- scripts/tests/local-setup.test.mjs: 2/2 pass (first-run bootstrap
  + second-run no-op idempotency with mkcert/openssl stubs).

Constraints honored: no force-push, no destructive ops, start.sh
preserved, scripts idempotent, volumes/DB never touched, HTTPS skip
mode dev-only, wizard does not edit LOCAL_DOMAIN/LOCAL_IP.

Out of scope / not addressed: pre-existing TS errors in
routes/users.ts and pre-existing failure in
executive-meetings-postpone-race.test.mjs.
2026-05-14 07:32:58 +00:00
riyadhafraa 7a2ae8434d Update project documentation and code comments to remove platform-specific references
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: fa18e5d4-a810-4bd5-8cde-2a60d64d9e3f
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu
Replit-Helium-Checkpoint-Created: true
2026-05-14 06:23:49 +00:00
riyadhafraa 0ef93920d5 Task #517: App image upload, external links, built-in route lock
Admin Add/Edit App now supports:
- Custom image upload (or fall back to Lucide icon) via the existing
  ServiceImageUploader; rendered on the home launcher when set.
- Open mode picker: internal (default), external_tab (window.open),
  external_iframe (renders inside /embedded/:id). External URL input
  shown conditionally and required when an external mode is chosen.
- Internal route field is hidden entirely when an external mode is
  selected, and locked (readOnly + lock hint) when editing a built-in
  app. Slug input is also locked (readOnly) for built-in apps so the
  built-in identity cannot drift via the form.

Backend:
- apps schema gains image_url, external_url, open_mode (default
  'internal'); drizzle-kit push applied.
- New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS +
  isBuiltinAppSlug. Exposed via subpath export
  `@workspace/db/built-in-apps`; the file has zero imports so the
  browser bundle uses it without pulling in `pg`. tx-os now imports it
  directly — duplicate FE constant removed.
- Built-in slug list: services, notifications, admin, notes,
  my-orders, orders-incoming, executive-meetings (everything with a
  hardcoded <Route> in artifacts/tx-os/src/App.tsx). calendar /
  documents are seeded but admin-defined and remain editable.
- PATCH /apps/:id rejects route changes whose previous slug is
  built-in with 400 + code='builtin_route_locked'. Same-route no-op
  is allowed; non-route updates on built-ins still work.
- PATCH /apps/:id ALSO rejects slug changes when the previous slug is
  built-in (code='builtin_slug_locked'). UpdateAppBody zod schema
  intentionally omits slug, so we inspect req.body.slug raw before zod
  stripping. Closes the 2-step bypass: rename slug (allowed) → change
  route (now previous.slug looks non-built-in, allowed).
- POST /apps and PATCH /apps/:id reject externalUrl values that are
  not http:// or https:// (code='invalid_external_url'). Prevents
  shipping javascript:/data:/file: payloads tenant-wide via launcher.

Other:
- New SPA route /embedded/:id and embedded-app page (iframe host with
  back + open-in-new-tab + error/not-embeddable states).
- OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran.
- en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*,
  builtinPathLocked, embeddedFrame.*.
- scripts/src/seed.ts: drift guard throws if a seeded built-in slug
  uses a route that does not match the hardcoded SPA route.

Tests:
- New API test apps-builtin-route-lock.test.mjs (6/6 pass): reject
  built-in route change, allow non-route built-in updates, allow
  non-builtin route changes, reject built-in slug change (anti-bypass),
  reject non-http(s) externalUrl scheme + accept https, allow built-in
  same-route no-op.
- New Playwright E2E admin-app-image-external-embedded.spec.mjs
  (passes): launcher renders custom image_url, external_tab opens
  external URL via window.open, external_iframe navigates to
  /embedded/:id and renders <iframe src=externalUrl>.

Out of scope (pre-existing, not introduced here):
- Failing tests in executive-meetings-* and tsc errors in
  api-server/src/routes/executive-meetings.ts.
2026-05-12 12:40:20 +00:00
riyadhafraa e7948012f4 Task #517: App image upload, external links, built-in route lock
Admin Add/Edit App now supports:
- Custom image upload (or fall back to Lucide icon) via the existing
  ServiceImageUploader; rendered on the home launcher when set.
- Open mode picker: internal (default), external_tab (window.open),
  external_iframe (renders inside /embedded/:id). External URL input
  shown conditionally and required when an external mode is chosen.
  Internal route field is hidden entirely when an external mode is
  selected, and locked (readOnly + lock hint) when editing a built-in
  app whose path is hardcoded in the SPA.

Backend:
- apps schema gains image_url, external_url, open_mode (default
  'internal'); drizzle-kit push applied.
- New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS +
  isBuiltinAppSlug. Exposed via subpath export
  `@workspace/db/built-in-apps`; the file has zero imports so the
  browser bundle can use it without pulling in `pg`. tx-os now imports
  it directly — duplicate FE constant removed.
- Built-in slug list: services, notifications, admin, notes,
  my-orders, orders-incoming, executive-meetings (everything with a
  hardcoded <Route> in artifacts/tx-os/src/App.tsx). calendar /
  documents are seeded but admin-defined and remain editable.
- PATCH /apps/:id rejects route changes whose previous slug is
  built-in with 400 + code='builtin_route_locked'. Same-route no-op
  is allowed; non-route updates on built-ins still work.

Other:
- New SPA route /embedded/:id and embedded-app page (iframe host with
  back + open-in-new-tab + error/not-embeddable states).
- OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran.
- en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*,
  builtinPathLocked, embeddedFrame.*.
- scripts/src/seed.ts: drift guard throws if a seeded built-in slug
  uses a route that does not match the hardcoded SPA route.

Tests:
- New API test apps-builtin-route-lock.test.mjs (4/4 pass): reject
  built-in route change, allow non-route built-in updates, allow
  non-builtin route changes, allow built-in same-route no-op.
- New Playwright E2E admin-app-image-external-embedded.spec.mjs
  (passes): launcher renders custom image_url, external_tab opens
  external URL via window.open, external_iframe navigates to
  /embedded/:id and renders <iframe src=externalUrl>.

Out of scope (pre-existing, not introduced here):
- 3 failing tests in executive-meetings-* and tsc errors in
  api-server/src/routes/executive-meetings.ts.
2026-05-12 12:31:43 +00:00
riyadhafraa a794f92e61 Task #517: App image upload, external links, built-in route lock
Admin Add/Edit App now supports:
- Custom image upload (or fall back to Lucide icon) via the existing
  ServiceImageUploader; rendered on the home launcher when set.
- Open mode picker: internal (default), external_tab (window.open),
  external_iframe (renders inside /embedded/:id). External URL input
  shown conditionally and required by the form when an external mode
  is chosen.
- Route field is locked (readOnly + lock hint) when editing a built-in
  app, since those slugs are hardcoded in the SPA router.

Backend:
- apps schema gains image_url, external_url, open_mode (default
  'internal'); drizzle-kit push applied.
- New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS +
  isBuiltinAppSlug, re-exported from lib/db.
- PATCH /apps/:id rejects route changes whose previous slug is
  built-in with 400 + code='builtin_route_locked'. Same-route no-op
  is allowed; non-route updates on built-ins still work.

Other:
- New SPA route /embedded/:id and embedded-app page (iframe host with
  back + open-in-new-tab + error/not-embeddable states).
- OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran.
- en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*,
  builtinPathLocked, embeddedFrame.*.
- New tests in apps-builtin-route-lock.test.mjs (4/4 pass) covering
  reject built-in route change, allow non-route built-in updates,
  allow non-builtin route changes, allow built-in same-route no-op.

Notes / drift:
- BUILTIN_APP_SLUGS is duplicated inline in admin.tsx
  (BUILTIN_APP_SLUGS_FE) because the browser bundle cannot import
  @workspace/db (pulls pg). Comment points at the canonical source;
  drift risk filed as a follow-up.
- Pre-existing failures unrelated to this task: 3 tests in
  executive-meetings-* and tsc errors in
  api-server/src/routes/executive-meetings.ts. Out of scope.
2026-05-12 12:23:38 +00:00
riyadhafraa 84398de390 Task #511: Fully remove Chat feature
Destructive removal per user confirmation ("حذف نهائي ما يرجع").

Removed:
- API: routes/conversations.ts, schema/conversations.ts, all chat
  socket handlers in src/index.ts, /admin/users/:id/dependents/
  conversations+messages endpoints, conversation/message dependency
  counts in users/stats routes.
- Web: pages/chat.tsx, /chat route, dock chat filter, MessageSquare
  icon and messages StatCard on home, all chat-related UI in
  notifications + admin (dependency badges, delete-dialog rows,
  UserDependentConversations/Messages sections, count map keys).
- Locales: nav.chat, home.stats.messages, full chat.* block,
  admin.deleteUser conv/msgCount, admin.users.counts.conv/msg,
  admin.audit.unit.conversation_*/message_*, admin.dependents.user*.
- OpenAPI spec: tags, all /conversations/* paths, conv/msg dependent
  paths, related schemas (ConversationWithDetails, MessageWithSender,
  UserDependentConversation/MessageItem+Page, etc.), UserProfile and
  UserDeletionConflict conv/msg fields, HomeStats.unreadMessages.
  Regenerated client via orval.
- Database: dropped message_reads, messages,
  conversation_participants, conversations (CASCADE); deleted
  notifications with related_type='conversation' or type='chat';
  deleted apps row with slug='chat'; ran drizzle push-force.
- Seed: removed chat:access permission + user-role assignment +
  seeded chat app entry from scripts/src/seed.ts.
- Tests: deleted conversations-leave.test.mjs; cleaned chat refs from
  list-dependency-counts, delete-force-warnings, audit-log-coverage,
  and admin-inline-dependency-counts (e2e) — replaced chat dependents
  with note dependents where needed for force-delete coverage.

Notes preserved: notes.tsx noConversationsYet/conversationWith refer
to NOTE THREADS (not chat) and were intentionally NOT touched.

executive-meetings.ts not modified per replit.md restriction.
Pre-existing flaky test failures in executive-meetings/group/etc
suites remain unrelated to this task.
2026-05-12 10:51:31 +00:00
riyadhafraa e81ba3a4b9 Add visual evidence of correct Arabic text rendering in PDFs
Add a PNG image to artifact metadata demonstrating correct Arabic text rendering in a generated PDF, resolving concerns about character shaping and order.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 16fbddba-fcd7-4fef-9cc4-d5c46409d005
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/4ugHzxo
Replit-Helium-Checkpoint-Created: true
2026-05-11 18:30:03 +00:00
riyadhafraa f53f7307da Task #489: row-wide drag rotates meeting content; time + daily numbers anchored
Backend
- New POST /api/executive-meetings/rotate-content (zod-validated) rotates
  ONLY meeting content through fixed (start_time, end_time, daily_number)
  slots. Same-date enforced; per-meeting expectedUpdatedAt → 409 stale;
  incomplete day (missing visible row) → 400.
- ExecutiveMeetingsRotateContentBody added in lib/api-zod (manual.ts).
- 6 backend tests cover happy path, stale, different_dates, 401, 403,
  incomplete_day. Existing /swap-times tests still pass.

Frontend (artifacts/tx-os)
- Whole <tr> is now the drag handle (the dedicated GripVertical button is
  retired). useSortable is gated on canMutate; safeRowDragListeners
  filters drags whose target is an interactive descendant (button, input,
  edit/time cells, row-actions, bulk-select). useSortable `attributes`
  are spread only when canMutate so view-mode rows stay clickable
  (otherwise aria-disabled blocked the popover trigger).
- onRowDragEnd → rotateContent(fromId, toId): optimistic patch reassigns
  each chronological slot's tuple to the new occupant; rolls back + toast
  on failure.
- Quick-actions popover now contains only Postpone (#486 Move up/down
  buttons removed).

Tests (artifacts/tx-os)
- New tests/executive-meetings-row-drag.spec.mjs: drags Alpha → Charlie
  position by # cell, asserts rotate-content fires and slots stay
  anchored.
- tests/executive-meetings-row-quick-actions.spec.mjs: drops up/down
  cases, keeps Postpone + skip-surfaces + viewer.
- tests/executive-meetings-schedule-features.spec.mjs: two legacy grip
  drag tests rewritten to drag the row body and target /rotate-content
  (the legacy /reorder route + tests are intentionally untouched).

Drift / notes
- Architect flagged a medium-severity hardening note: rotate-content
  FOR UPDATE locks orderedIds but not the day-scope completeness query.
  Out of #489 scope; no follow-up created (proposeFollowUpTasks was
  already consumed on #486).
2026-05-11 12:14:08 +00:00
riyadhafraa 16a818b716 #486: Executive Meetings row click → quick-actions popover
Clicking any meeting row on the Executive Meetings schedule (gated only
on canMutate, not editMode) opens a small popover with Move up / Move
down / Postpone. Move up/down swap only the (startTime, endTime) tuple
between the clicked meeting and its chronological neighbour on the same
date — the Time column stays visually anchored to its row position.

Backend
- POST /executive-meetings/swap-times: transactional swap with FOR
  UPDATE row locking, optimistic-lock conflict shape (stale_meeting +
  conflict payload), date/time-window guards, audit logging, and
  renumberDayByStartTime + day-changed broadcast.
- Zod schema in lib/api-zod/src/manual.ts.

Frontend
- Shared lib/api-json.ts JSON helper.
- ScheduleSection.swapTimes does an optimistic (startTime, endTime)
  swap against the day query cache and rolls back on failure (mirrors
  the existing inline-edit UX).
- MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles
  (button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* /
  em-row-grip / em-row-actions data-testid prefixes do NOT open the
  popover.
- PostponeDialog reused from upcoming-meeting-alert.tsx.

Tests
- Backend swap-times: happy path, stale_meeting (409), different_dates
  (400), no_time_window (400), unauth (401), viewer-no-mutate (403),
  malformed-timestamp (400) — all 7 pass.
- Hardened expectedUpdatedAt zod schema to z.string().datetime() so
  malformed tokens fail at validation with a controlled 400 instead of
  bubbling up as a 500.
- E2E: Move up swap, edge-disable states (solo / first / middle /
  last), Postpone 5-min chip end-to-end, click-exclusion on grip /
  time cell / row-actions — all 4 pass. Each test uses its own future
  date to avoid cross-test pollution.

Code review approved on second pass. Pre-existing failures in other
suites (executive-meetings reorder, font-settings, notes-share,
service-orders) are unrelated to this task and predate it.

Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit-
mode test gaps).
2026-05-11 11:06:13 +00:00
riyadhafraa b1b77395d0 #486 Executive Meetings: row-click quick actions popover (Move up / Move down / Postpone)
Clicking any meeting row on the schedule now opens a small popover with three
quick actions: Move up, Move down, and Postpone. Gated only on canMutate (NOT
editMode) per spec.

Backend
- New POST /executive-meetings/swap-times endpoint (artifacts/api-server/src/
  routes/executive-meetings.ts). FOR UPDATE locks both rows by ascending id to
  avoid deadlocks, optimistic-lock check via expectedUpdatedAt{A,B} (returns
  409 stale_meeting + conflict.lastActor — same shape PostponeDialog
  understands), guards different_dates and no_time_window, swaps only
  (startTime, endTime), audits each row as `meeting_swap_times`, calls
  renumberDayByStartTime so the # column matches the new chronological order,
  and broadcasts emitExecutiveMeetingsDayChanged.
- New zod body schema ExecutiveMeetingsSwapTimesBody in lib/api-zod/src/manual.ts.

Frontend
- New shared lib/api-json.ts (ApiError + apiJson) extracted from upcoming-
  meeting-alert.tsx so the page can reuse the same fetch/error contract.
- upcoming-meeting-alert.tsx exports PostponeDialog so the page can mount it
  for quick-action postpone without duplicating UI.
- artifacts/tx-os/src/pages/executive-meetings.tsx: Schedule wires
  swapTimes/quickMoveUp/quickMoveDown/postpone state, computes per-day
  neighbours via meetingNumbersById, and mounts a single page-level
  PostponeDialog. MeetingRow wraps `<tr>` in a Popover/PopoverAnchor; row
  onClick opens the popover with skip rules for buttons/inputs/contenteditable
  and ARIA roles (button/checkbox/switch/combobox/dialog) plus testid
  prefixes (em-row-grip/-actions/-select, em-edit-*, em-merge-edit-*, em-time-*)
  so the time cell's inline editor and other affordances don't collide.
- en/ar locales gain executiveMeetings.quickActions.{label,moveUp,moveDown,postpone}.

Tests
- artifacts/api-server/tests/executive-meetings-swap-times.test.mjs (new):
  happy path, 409 stale_meeting (with conflict actor), 400 different_dates,
  400 no_time_window. Each scenario uses a distinct far-future date to avoid
  daily_number races with seeded data.
- artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs (new):
  drives the date input, verifies row click → popover, Move up swap reflected
  in DB, and Postpone item opens the dialog.

Code review (architect) flagged an edit-mode conflict where the time cell
(role=button div) would also bubble to the row handler; fixed by adding ARIA
role and em-time-* skip rules. Architect also flagged keyboard-trigger gap
and edit-mode test gaps — proposed as follow-ups.

Other test failures in the repo (executive-meetings reorder, font-settings,
notes-share, service-orders) pre-date this change and are unrelated.
2026-05-11 10:55:34 +00:00
riyadhafraa daa4f6c038 Task #463: @dnd-kit notes drag + reorder
- Replace HTML5+touch drag with @dnd-kit (PointerSensor distance:8,
  TouchSensor delay:200/tol:8) matching home.tsx pattern.
- Add sort_order column to notes; ORDER BY asc(sortOrder), updatedAt desc.
- New PATCH /notes/reorder endpoint: strict isPinned boolean validation,
  bucket+permission scoped, all writes in db.transaction for atomicity.
- PATCH /notes/:id stamps sort_order = min-1 on folder/pin bucket change.
- Client useReorderNotes hook with optimistic cache update.
- handleDragEnd builds reorder payload from FULL bucket (owner notes or
  shared-folder bucket via ref), not the filtered/search subset, so
  hidden siblings retain stable order.
- SharedFolderView publishes its data.notes via bucketRef when viewer
  has edit permission, enabling correct reorder in shared folders.
- Layout fix at narrow viewport: rail stacks above notes
  (flex-col md:flex-row) so iPad portrait drag has proper bbox.
- Playwright tests: notes-folders.spec.mjs both desktop pointer drag
  and touch long-press drag pass (32s).
- OpenAPI codegen skipped: notes-api.ts is hand-written.
- Out of scope (pre-existing failures): executive-meetings reorder/font,
  notes-share PATCH 403/404, groups-crud rollback.
2026-05-10 13:10:01 +00:00
riyadhafraa 1aad708cb7 notes(#454): per-recipient view/edit folder sharing
- Schema: noteFolderShares.permission ('view'|'edit', default 'view').
- Server: resolveFolderEditAccess + emitFolderChanged helpers.
  - POST/PATCH/DELETE/checklist now allow folder editors.
  - Editors stamp notes with folder owner's userId; labels validated
    against owner; PATCH editors blocked from unfile (folderId=null)
    and cross-folder moves to non-matching owners.
  - emitFolderChanged fires for owner AND editor mutations across
    create / patch (old + new folder) / delete / checklist toggle.
  - PUT /shares accepts both legacy recipientUserIds and new
    recipients:[{userId,permission}]; diffs add/update/remove/perm-flip.
  - GET /shares, /shared-with-me, /shared-notes return permission /
    myPermission / readOnly.
- Client:
  - notes-api types: FolderSharePermission, myPermission on
    SharedFolder/SharedFolderView, permission on FolderShareRecipient,
    readOnly:boolean on SharedFolderNote.
  - useUpdateFolderShares takes recipients[].
  - useCreate/Update/DeleteNote also invalidate ['note-folders'] so
    actor's shared-folder rail badge stays fresh.
  - FolderShareDialog: per-row checkbox + segmented View/Edit toggle.
  - SharedFolderView: editor mode mounts Composer + NoteCard with
    full edit/delete/archive affordances; Send hidden in editor
    context (Composer + NoteCard hideSend prop) since /send is
    owner-only.
  - folders-rail: per-folder permission badge.
  - socket: note-folder-shared also invalidates ['notes'] +
    ['note-folders'] for fanout to owner / other editors.
  - Locales: shareDescriptionPerm, permissionView/Edit, canEdit
    (en + ar).
- Pre-existing executive-meetings.ts type errors are out of scope.
2026-05-10 10:16:05 +00:00
riyadhafraa a4949983f3 Notes: live folder-sharing (read-only) — Task #445
Owner can share a whole folder with other users; recipients see it
under "Shared with me" in the folders rail and view notes read-only
(cannot edit, add, move, or delete).

- DB: new noteFolderSharesTable (folderId/recipientUserId, cascade,
  unique idx). Pushed via drizzle-kit.
- API (artifacts/api-server/src/routes/notes.ts):
  - GET/PATCH/POST /note-folders return sharedWithCount.
  - GET /note-folders/shared-with-me, GET /note-folders/:id/shares,
    PUT /note-folders/:id/shares (idempotent diff), DELETE
    /note-folders/:id/shares/:userId, GET /note-folders/:id/shared-notes
    (verifies recipient via noteFolderSharesTable, stamps readOnly).
  - Emits note-folder-shared / note-folder-unshared on share changes.
  - Existing note write paths remain owner-scoped → recipients can't
    mutate owner notes.
- Frontend types/hooks (notes-api.ts): SharedFolder, FolderShareRecipient,
  SharedFolderNote/View; useFolderShares, useUpdateFolderShares,
  useSharedWithMeFolders, useSharedFolderNotes.
- FoldersRail: Share menu item, sharedWithCount badge, "Shared with me"
  section, "shared-folder" selection kind, onShareFolder prop.
- notes.tsx: SharedFolderView (no Composer, read-only cards using
  ChecklistView), FolderShareDialog (seeds existing recipients,
  idempotent PUT on save), revocation fallback effect, dialog mount.
- use-notifications-socket.ts: subscribe to note-folder-shared /
  -unshared → invalidate shared-with-me + open shared-folder notes for
  live rail/view refresh.
- i18n: AR/EN keys for share/sharedBy/sharedByName/sharedWithCount/
  sharedWithMe/readOnly/shareRevoked/sharedEmpty/shareSaved/shareFailed.

Pre-existing failing `test` workflow (executive-meetings type errors)
is unrelated and out of scope.
2026-05-09 11:07:30 +00:00
riyadhafraa 1ffb470e8f notes: add per-note checklist (to-do list) option
Task #420 — answers the user request "وين خيار اضيف list to do?".

Schema (lib/db/src/schema/notes.ts):
- notes + note_recipients gain `kind` (varchar(16) default 'text')
  and `items` (jsonb<ChecklistItem[]> nullable). Snapshot copy on
  note_recipients keeps delivered checklists immutable across sender
  edits/deletes.
- Drizzle push applied; lib/db .d.ts rebuilt.

API (artifacts/api-server/src/routes/notes.ts):
- ChecklistItem type + bounded parser (≤200 items, id ≤64, text ≤500).
- parseNoteInput normalizes items↔kind on create and on PATCH that
  carries `kind`; PATCH handler additionally coerces items-only
  patches against the note's existing kind so a text note can never
  end up with checklist items (and vice versa).
- POST/PATCH/send/loadRecipientsForNote/received/thread responses and
  the realtime `note_received` payload all carry kind+items, with the
  thread response falling back to the recipient snapshot.

Client (artifacts/tx-os/src/lib/notes-api.ts + pages/notes.tsx):
- Note/ReceivedNote/NoteThread/SentNoteRecipient extended with
  kind+items.
- New `ChecklistEditor`, `ChecklistView`, and `KindToggle` components.
- Composer and EditNoteDialog gain the to-do toggle (ListTodo icon)
  and switch between Textarea and ChecklistEditor; saves send
  kind+items, with empty-checklist auto-discard mirroring the existing
  empty-text behaviour.
- NoteCard, inbox list, sent list, and ThreadDialog body render the
  checklist (read-only on snapshots; owner cards can toggle done via
  PATCH with stopPropagation so the edit dialog doesn't open).

i18n: notes.checklist.{toggle,addItem,itemPlaceholder,emptyHint,
removeItem,progress} added to en.json + ar.json.

Tests: new artifacts/tx-os/tests/notes-checklist.spec.mjs covers
composer→persist→reload→toggle, items-only PATCH normalization on
both kinds, and checklist delivery to recipient snapshot. All 3 pass.

Architect review (evaluate_task) flagged one real issue: items-only
PATCH normalization. Fixed in the PATCH handler and locked in by the
new normalization test.

Pre-existing executive-meetings.ts tsc errors are unchanged and
unrelated to this task.
2026-05-06 10:12:52 +00:00
riyadhafraa 6e49010940 notes: user-defined folders with drag-drop (task #413)
DB
- New `note_folders` table (per-user unique name); `notes.folder_id` nullable
  with `ON DELETE SET NULL` so deleting a folder preserves its notes.

API
- /note-folders CRUD (GET, POST, PATCH, DELETE), all user-scoped.
- /notes POST/PATCH validate folder ownership before assignment (no cross-user
  folder bleed).
- Folder noteCount is tab-aware (active vs archived) via ?archived=.
- OpenAPI spec extended with /note-folders endpoints, NoteFolder schema, and
  folderId on SentNote/ReceivedNote. Codegen regenerated for api-client-react
  and api-zod (`pnpm --filter @workspace/api-spec run codegen`).

Client
- NoteFolder type and useNoteFolders / useCreateFolder / useUpdateFolder /
  useDeleteFolder / useMoveNoteToFolder hooks (optimistic across all
  ["notes", ...] caches; rollback on error).

UI (FoldersRail next to My Notes + Archive tabs only)
- Desktop: sidebar with All / Unfiled / user folders + counts.
- Mobile: horizontal chip row (flex + overflow-x-auto) above the notes grid.
- Per-folder kebab dropdown with Rename + Delete.
- Newly-created folder is auto-selected.
- HTML5 draggable note cards on desktop; touch long-press fallback (350ms)
  for iPad/mobile via shared drop pipeline (registerFolderDropHandler +
  hit-tested `[data-folder-drop]`).
- Dev-only `window.__notesTouchTest` helper (gated to non-production builds)
  exposes the touch pipeline so e2e tests can drive it deterministically
  without synthesizing native TouchEvents.

Bilingual
- notes.folders.* keys added to en.json and ar.json (RTL inherited from
  existing dir prop wired from i18n.language).

Tests (artifacts/tx-os/tests/notes-folders.spec.mjs)
- create folder + auto-select
- drag note → folder, refresh-and-still-in-folder persistence
- filter by folder / Unfiled / All
- drag between folders (count math)
- rename via kebab
- delete a non-empty folder via kebab → notes return to Unfiled (FK SET NULL)
- cross-user folder rejection (400)
- Arabic + RTL render
- separate touch test (414×800, hasTouch+isMobile) covers chip-row layout
  and exercises the touch drop pipeline through window helper.

All API node tests + tx-os Playwright suite + tsc clean.
2026-05-06 08:05:12 +00:00
riyadhafraa 1889407565 notes: user-defined folders with drag-drop (task #413)
DB
- New `note_folders` table (per-user unique name); `notes.folder_id` nullable
  with `ON DELETE SET NULL` so deleting a folder preserves its notes.

API
- /note-folders CRUD (GET, POST, PATCH, DELETE), all user-scoped.
- /notes POST/PATCH validate folder ownership before assignment (no cross-user
  folder bleed).
- Folder noteCount is tab-aware (active vs archived) via ?archived=.
- OpenAPI spec extended with /note-folders endpoints, NoteFolder schema, and
  folderId on SentNote/ReceivedNote. Codegen regenerated for api-client-react
  and api-zod (`pnpm --filter @workspace/api-spec run codegen`).

Client
- NoteFolder type and useNoteFolders / useCreateFolder / useUpdateFolder /
  useDeleteFolder / useMoveNoteToFolder hooks (optimistic across all
  ["notes", ...] caches; rollback on error).

UI (FoldersRail next to My Notes + Archive tabs only)
- Desktop: sidebar with All / Unfiled / user folders + counts.
- Mobile: horizontal chip row (flex + overflow-x-auto) above the notes grid.
- Per-folder kebab dropdown with Rename + Delete.
- Newly-created folder is auto-selected.
- HTML5 draggable note cards on desktop; touch long-press fallback (350ms)
  for iPad/mobile via shared drop pipeline (registerFolderDropHandler +
  hit-tested `[data-folder-drop]`).
- Dev-only `window.__notesTouchTest` helper (gated to non-production builds)
  exposes the touch pipeline so e2e tests can drive it deterministically
  without synthesizing native TouchEvents.

Bilingual
- notes.folders.* keys added to en.json and ar.json (RTL inherited from
  existing dir prop wired from i18n.language).

Tests (artifacts/tx-os/tests/notes-folders.spec.mjs)
- create folder + auto-select
- drag note → folder, refresh-and-still-in-folder persistence
- filter by folder / Unfiled / All
- drag between folders (count math)
- rename via kebab
- delete a non-empty folder via kebab → notes return to Unfiled (FK SET NULL)
- cross-user folder rejection (400)
- Arabic + RTL render
- separate touch test (414×800, hasTouch+isMobile) covers chip-row layout
  and exercises the touch drop pipeline through window helper.

All API node tests + tx-os Playwright suite + tsc clean.
2026-05-06 07:55:32 +00:00
riyadhafraa 6826b475b2 notes: user-defined folders with drag-drop (task #413)
DB
- New `note_folders` table (per-user unique name); `notes.folder_id` nullable
  with `ON DELETE SET NULL` so deleting a folder preserves its notes.

API
- /note-folders CRUD (GET, POST, PATCH, DELETE), all user-scoped.
- /notes POST/PATCH validate folder ownership before assignment (no cross-user
  folder bleed).
- Folder noteCount is tab-aware (active vs archived) via ?archived=.
- OpenAPI spec extended with /note-folders endpoints, NoteFolder schema, and
  folderId on SentNote/ReceivedNote. Codegen regenerated for api-client-react
  and api-zod (`pnpm --filter @workspace/api-spec run codegen`).

Client
- NoteFolder type and useNoteFolders / useCreateFolder / useUpdateFolder /
  useDeleteFolder / useMoveNoteToFolder hooks (optimistic across all
  ["notes", ...] caches; rollback on error).

UI (FoldersRail next to My Notes + Archive tabs only)
- Desktop: sidebar with All / Unfiled / user folders + counts.
- Mobile: horizontal chip row (flex + overflow-x-auto) above the notes grid.
- Per-folder kebab dropdown with Rename + Delete.
- Newly-created folder is auto-selected.
- HTML5 draggable note cards on desktop; touch long-press fallback (350ms)
  for iPad/mobile via shared drop pipeline (registerFolderDropHandler +
  hit-tested `[data-folder-drop]`).
- Dev-only `window.__notesTouchTest` helper (gated to non-production builds)
  exposes the touch pipeline so e2e tests can drive it deterministically
  without synthesizing native TouchEvents.

Bilingual
- notes.folders.* keys added to en.json and ar.json (RTL inherited from
  existing dir prop wired from i18n.language).

Tests (artifacts/tx-os/tests/notes-folders.spec.mjs)
- create folder + auto-select
- drag note → folder, refresh-and-still-in-folder persistence
- filter by folder / Unfiled / All
- drag between folders (count math)
- rename via kebab
- delete a non-empty folder via kebab → notes return to Unfiled (FK SET NULL)
- cross-user folder rejection (400)
- Arabic + RTL render
- separate touch test (414×800, hasTouch+isMobile) covers chip-row layout
  and exercises the touch drop pipeline through window helper.

All API node tests + tx-os Playwright suite + tsc clean.
2026-05-06 07:50:39 +00:00
riyadhafraa ba0da3d26c notes: user-defined folders with drag-drop (task #413)
- DB: new note_folders table (per-user unique name) + nullable folder_id on
  notes with ON DELETE SET NULL; pushed via @workspace/db.
- API: full /note-folders CRUD with user scoping; /notes POST/PATCH validate
  folder ownership; folder noteCount is tab-aware (active vs archived) via
  ?archived= query param computed by GROUP BY (drizzle correlated subquery
  was returning 0 — replaced with two queries merged in JS).
- Client: NoteFolder type, useNoteFolders/useCreateFolder/useUpdateFolder/
  useDeleteFolder/useMoveNoteToFolder hooks (optimistic update for moves
  across all ["notes", ...] caches).
- UI: new FoldersRail (artifacts/tx-os/src/components/notes/folders-rail.tsx)
  rendered next to My Notes + Archive tabs; HTML5 draggable note cards with
  module-scoped DRAGGING_NOTE_ID fallback; visible drop highlight; rename +
  delete + create inline; folder-scoped + Unfiled filtering in notes.tsx.
- Bilingual: notes.folders.* keys added to en.json and ar.json (RTL works
  via existing dir prop wired from i18n.language).
- Test: artifacts/tx-os/tests/notes-folders.spec.mjs covers create folder,
  drag note to folder, filter by folder/Unfiled, drag between folders,
  rename, delete a non-empty folder (verifies FK SET NULL + Unfiled count),
  cross-user folder rejection (400), and Arabic+RTL rendering.

Drift from plan: none — all originally scoped work shipped. Strengthened
e2e + cross-user check + tab-aware counts added per code review feedback.
2026-05-06 07:26:11 +00:00
riyadhafraa aaadd9b520 feat(notes): make incoming-note popup attention-grabbing
Task #409: incoming notes now mirror UpcomingMeetingAlert's attention model
so recipients can't miss them.

Changes:
- DB: add `notification_sound_note` (default "knock"), `notify_notes_enabled`,
  `vibration_enabled_note` to users schema; pushed via drizzle.
- API spec: add 3 new fields to AuthUser + UpdateNotificationPreferencesBody;
  regenerated codegen.
- Auth route: include new fields in buildAuthUser + PATCH allowlist.
- Socket hook: play notification sound + vibrate on `note_received`,
  gated by mute/notifyNotesEnabled/socket warmup, with playedNoteIdsRef
  dedupe (mirrors upcoming-meeting-alert playedRef).
- IncomingNotePopupContext: enqueue() now returns boolean acceptance so
  the socket hook can suppress sound on own-note/duplicate.
- Popup visual: z-[110], ring-4 amber, shadow-2xl, animate-in zoom,
  avatar animate-ping pulse.
- Settings UI: refactored to SLOT_KEYS map, added 3rd "Notes" slot tab
  (grid-cols-3) with enabled/vibration toggles + sound picker.
- Locales en/ar: added notifSettings.slot.note, notesEnabled, vibrationNote.

Pre-existing typecheck errors in executive-meetings.ts (font settings
scope) are unrelated to this task.
2026-05-05 16:21:14 +00:00
riyadhafraa eade4643e5 Add ability to view personal notes and update note schemas
Adds a new endpoint for listing user-specific notes and refines OpenAPI schemas for better data structure definition.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 3565b59b-6890-4cf5-90a7-0c8041e15041
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/G3ZvHke
Replit-Helium-Checkpoint-Created: true
2026-05-05 15:28:41 +00:00
riyadhafraa 5b13fe16f5 Task #402: Convert Notes into in-app messaging
Original task: turn personal Notes into in-app messaging — sender composes
a note (title/content/color), picks recipient(s), Send. Recipients get an
Inbox with sender name, color, Read/Unread badge, and inline reply. Sender
sees Sent Notes with per-recipient status. Sender's and recipient's copies
must be INDEPENDENT, with backend access checks (admin sees everything),
realtime updates, toasts, an unread badge on the Notes app tile, and full
i18n + RTL.

Four review rounds were addressed in this commit:

Round 1 (independence):
- Snapshot columns (title/content/color) on note_recipients; FKs dropped
  on note_recipients.note_id and note_replies.note_id so recipient
  threads survive the sender deleting their note.
- /notes/received and /notes/:id/thread render the recipient snapshot.

Round 2:
- POST /notes/:id/reply: owner is now allowed to reply too.
- Added GET /notes/:id as alias of /notes/:id/thread.
- Added OpenAPI ops for the Notes routes; ran orval codegen.
- use-notifications-socket.ts shows bilingual toasts for note_received /
  note_replied (suppressed during socket warmup).
- home.tsx renders an unread badge on the Notes app tile.

Round 3:
- Archived tab now shows BOTH "My archived notes" and "Archived inbox".
- Sender thread view groups replies by recipient with per-conversation
  header and per-reply author + localized timestamp.
- Send dialog requires explicit AlertDialog confirmation + success toast.
- Realtime toast strings moved off hardcoded EN/AR to i18n keys.
- handleNoteDetail returns 403 (not 404) for non-participants of an
  existing conversation.
- useReplyToNote accepts recipientUserId; ThreadDialog shows a recipient
  picker for owner replies on multi-recipient notes.

Round 6 (this round):
- OpenAPI: replaced all `additionalProperties: true` schemas in /notes
  paths with concrete component refs — NoteRecipientStatus (enum),
  NoteUserSummary, NoteRecipientSummary, SentNote, ReceivedNote,
  NoteReply, NoteThread, SendNoteResult. Re-ran orval; api-zod and
  api-client-react regenerated cleanly.
- Trimmed narrative comments in artifacts/api-server/src/routes/notes.ts
  (handleNoteDetail, /sent, /received, /send, /read, /reply).
- Schema FK + enum policy: kept noteId as plain integer (no FK) on
  note_recipients/note_replies — this is required so recipient
  snapshots survive sender deletion. Status remains a varchar but is
  constrained at the API boundary by the new NoteRecipientStatus enum
  schema and TS string-literal union on both server and client.
- Migrations: this monorepo uses `drizzle-kit push` exclusively
  (lib/db has no migrations directory; drizzle.config.ts is push-only;
  package.json defines only push/push-force scripts). No migration
  files committed by design.

Round 5: admin authorization fix
- handleNoteDetail: admin bypass is now evaluated BEFORE the
  "non-participant 403" branch. When the sender has deleted the live
  note row but recipient snapshots remain, an admin GET /notes/:id now
  returns 200 with thread payload assembled from a fallback recipient
  snapshot (instead of 403).
- New regression test: "admin can read a note whose sender deleted
  their copy (recipient snapshot remains)".

Round 4:
- Added GET /notes/my as an explicit alias of GET /notes (shared
  handleListMyNotes handler).
- Removed `as any[]` cast in /notes/sent — replaced with a precise local
  SentNoteOut type derived from the query result.
- Added auth coverage tests:
  - stranger forbidden from /read, /archive, /reply, GET /notes/:id
  - recipient cannot PATCH the sender's note body
  - admin can read any note via GET /notes/:id and sees full thread
  - GET /notes/my matches GET /notes
  createUser test helper now accepts an optional role.

Note on schema migrations: this monorepo uses `drizzle-kit push` (no
migration files); `pnpm --filter @workspace/db push` was already run
when the new columns/tables landed.

Tests: 11 backend tests in notes-share.test.mjs + 1 e2e (notes-inbox)
all pass. tx-os typecheck is clean. Architect re-review: PASS.

Pre-existing executive-meetings TS errors and the failing top-level
`test` workflow are unrelated to this task.
2026-05-05 15:26:23 +00:00
riyadhafraa 65c5a172d6 Task #402: Convert Notes into in-app messaging
Original task: turn personal Notes into in-app messaging — sender composes a
note (title/content/color), picks recipient(s), Send. Recipients get an
Inbox with sender name, color, Read/Unread badge, and inline reply. Sender
sees Sent Notes with per-recipient status. Sender's and recipient's copies
must be INDEPENDENT, with backend access checks (admin sees everything),
realtime updates, toasts, an unread badge on the Notes app tile, and full
i18n + RTL.

Two prior code-review rounds were addressed in this commit:

Round 1 (independence):
- Added immutable snapshot columns (title/content/color) on note_recipients.
- Dropped the FK from note_recipients.note_id and note_replies.note_id so
  recipient threads survive the sender deleting their note.
- /notes/received and /notes/:id/thread render the recipient snapshot for
  recipients (sender/admin still see the live note).

Round 2 (validation REJECT fixes):
- POST /notes/:id/reply: owner is now allowed to reply too. Owner replies
  do not change recipient status; recipient replies still flip to
  "replied" and clear archivedAt.
- Added GET /notes/:id as an alias of /notes/:id/thread (shared handler).
- Added OpenAPI ops for /notes/sent, /notes/received, /notes/{id},
  /notes/{id}/send, /notes/{id}/read, /notes/{id}/archive,
  /notes/{id}/reply, and ran orval codegen.
- use-notifications-socket.ts now shows bilingual toasts for note_received
  and note_replied (suppressed during the socket warmup window).
- home.tsx renders an unread badge on the Notes app tile, fed by
  useReceivedNotes(false) filtered to status === "unread"; refreshes
  automatically on socket invalidation.

Tests: 7 backend tests in notes-share.test.mjs (independence,
archived-reply, owner-reply preserves recipient status, GET /notes/:id
alias, etc.) + the notes-inbox e2e all pass. tx-os typecheck is clean.

Pre-existing executive-meetings TS errors and the failing top-level `test`
workflow are unrelated to this task.
2026-05-05 15:01:18 +00:00
riyadhafraa 6352cbf844 Task #402: Convert Notes into in-app messaging (independence fix)
Original task: turn personal Notes into in-app messaging — sender composes a
note (title/content/color), picks recipient(s), Send. Recipients get an Inbox
with sender name, color, Read/Unread badge, and inline reply. Sender sees
Sent Notes with per-recipient status. Sender's and recipient's copies must
be INDEPENDENT, with proper backend access checks (admin sees everything),
realtime updates, and full i18n + RTL.

Initial implementation review FAILED because the recipient view still read
from the sender's notes table, so sender edits/deletes mutated recipient
copies. This commit completes the fix:

- Schema (lib/db/src/schema/notes.ts): added immutable snapshot columns
  (title/content/color) on note_recipients; dropped the FK on
  note_recipients.note_id and note_replies.note_id and made them plain
  integers so recipient threads survive the sender deleting their note.
- Routes (artifacts/api-server/src/routes/notes.ts):
  - /notes/received and /notes/:id/thread now serve the recipient snapshot
    (sender/admin still see the live note).
  - /notes/:id/reply derives the owner from note_recipients.senderUserId
    so it works after sender deletion, clears archivedAt, and bumps
    status to "replied".
  - /notes/sent filters out null noteIds.
- Tests (artifacts/api-server/tests/notes-share.test.mjs): added
  snapshot-independence test (sender edit + delete must not mutate
  recipient copy; thread + reply still work after sender delete) and
  archived-reply-clears-archivedAt test. All 5 backend tests pass; the
  existing notes-inbox e2e still passes.
- Architect review: PASS (conditional only on the schema migration being
  applied, which has been pushed via `pnpm --filter @workspace/db push`).

Pre-existing executive-meetings TS errors and the failing `test` workflow
are unrelated to this task.
2026-05-05 14:53:30 +00:00
riyadhafraa 89de41e949 Task #397: Per-card and bulk select+delete on Incoming Orders
Backend (artifacts/api-server):
- Added hasReceivePermission() and refactored DELETE /orders/:id into a
  shared authorizeAndDeleteOrder() helper so single and bulk paths use
  the same authorization rules.
- Added POST /orders/bulk-delete returning { deletedIds, failedIds } with
  per-id authorization, dedup, and best-effort partial success.
- Tightened receiver authorization to mirror GET /orders/incoming: a
  receiver may only delete an unclaimed pending order or one they have
  themselves claimed and is still active (received/preparing). Another
  receiver's claimed order, and any terminal order, return 403 even at
  the API layer. Code, comments and OpenAPI description now agree.

API spec (lib/api-spec/openapi.yaml):
- New /orders/bulk-delete operation with BulkDeleteServiceOrdersBody and
  BulkDeleteServiceOrdersResponse schemas. Updated DELETE /orders/{id}
  description. Regenerated react-query hooks via codegen — both
  useDeleteServiceOrder and useBulkDeleteServiceOrders are used by the UI.

UI (artifacts/tx-os/src/pages/orders-incoming.tsx):
- Per-card checkbox visible at all times (RTL-safe leading edge).
- Per-card trash icon for single delete.
- Section-scoped Select-all (مين / unclaimed) with tri-state
  all/some(indeterminate)/none and shadcn Checkbox.
- Sticky bottom bulk action bar shows selected count + Clear selection +
  destructive Delete.
- Single AlertDialog used by both per-card and bulk paths, with
  pluralized confirmation copy. Single delete calls DELETE /orders/:id;
  multi delete calls POST /orders/bulk-delete; partial-failure surfaces
  via toast.

i18n: New incomingOrders keys in ar.json and en.json (select, selectAll,
clearSelection, selectedCount, delete, deleteConfirmTitle/Body, deleted,
deleteFailed, deletePartial) with pluralization in both languages.

Tests:
- artifacts/api-server/tests/service-orders.test.mjs: receivers can
  delete pending/received/preparing; receivers cannot delete terminal
  (completed/cancelled); bulk-delete with mixed authorized / unknown /
  dedup / unauthenticated cases.
- artifacts/tx-os/tests/order-incoming-delete.spec.mjs (new Playwright):
  bulk-delete two unclaimed orders shrinks the list by 2; per-card
  trash on a claimed order deletes one. Both pass.

Pre-existing executive-meetings PDF/font test failures are unrelated.

Follow-ups proposed: #398 (Undo for incoming-order deletes), #399
(safer notification handling in bulk-delete).
2026-05-05 13:45:04 +00:00
riyadhafraa d62e67af96 Task #397: Per-card and bulk select+delete on Incoming Orders
Backend (artifacts/api-server):
- Added hasReceivePermission() helper and refactored DELETE /orders/:id
  into shared authorizeAndDeleteOrder() so single and bulk paths use the
  same authorization rules.
- Added POST /orders/bulk-delete returning { deletedIds, failedIds } with
  per-id authorization, dedup, and best-effort partial success.
- Tightened receiver authorization (per code review): receivers may only
  delete pending/received/preparing orders; terminal (completed/cancelled)
  orders remain the owner's cleanup responsibility. OpenAPI description
  and the implementation now agree.

API spec (lib/api-spec/openapi.yaml):
- New /orders/bulk-delete operation with BulkDeleteServiceOrdersBody and
  BulkDeleteServiceOrdersResponse schemas; updated DELETE /orders/{id}
  description. Regenerated react-query hooks via codegen — useBulkDelete
  ServiceOrders is now available.

UI (artifacts/tx-os/src/pages/orders-incoming.tsx):
- Selection mode toggle in the page header (RTL-safe).
- Per-card Checkbox plus a Select-all control.
- Sticky bottom action bar with destructive Delete and selected count.
- AlertDialog confirmation using pluralized i18n keys; toast surfaces
  partial-failure results when some ids couldn't be deleted.

i18n: New incomingOrders keys in ar.json and en.json (select, selectAll,
clearSelection, selectedCount, delete, deleteConfirmTitle/Body, deleted,
deleteFailed, deletePartial) with pluralization.

Tests: Added two new tests in service-orders.test.mjs covering receiver
delete on incoming queue, receiver forbidden on terminal orders, and
bulk-delete with mixed authorized / unknown / dedup / unauthenticated
cases. Both pass. Pre-existing executive-meetings PDF/font test failures
are unrelated.
2026-05-05 13:35:21 +00:00
riyadhafraa f8947c0bc7 Task #389: Notification sounds + vibration with per-user customization
DB
- Added user pref columns; vibration is per-channel
  (vibrationEnabledOrder + vibrationEnabledMeeting). Defaults: ding/chime
  sounds, vibration on, mute off. Volume uses the device system level —
  no persisted volume field.

API
- PATCH /auth/me/notification-preferences (requireAuth, whitelisted partial
  update, integer guard for volume). buildAuthUser exposes all new fields.
- OpenAPI spec updated; orval client + zod regenerated.

Frontend
- Static sound library: 8 short WAV assets under public/sounds/ + manifest
  (notification-sound-manifest.ts) mapping id -> labelKey -> URL.
- HTMLAudio-based player (notification-sounds.ts) with throttle, unlock,
  cache, and AUTOPLAY_BLOCKED_EVENT dispatch when play is denied pre-gesture.
- Settings popover: global mute + slot tabs (orders/meetings) with
  per-channel sound + per-channel vibration + per-channel toggle, sound list
  with one-tap previews. Concurrency-safe optimistic updates (monotonic seq
  counter). RTL-correct toggle knob transforms.
- QuickMuteButton: one-tap topbar mute control (Volume2/VolumeX) with
  confirmation toast.
- useAudioUnlock: unlocks audio on first interaction.
- useAutoplayHint: toasts a localized "click anywhere to enable" hint when
  the player reports a blocked play attempt.
- Socket hook plays the right per-channel sound + vibration on
  notification_created (orders, executive_meeting) when the tab is hidden.
- UpcomingMeetingAlert: plays meeting reminder once per new eligible
  meeting (deduped by meetingId, survives 30s polling refetches). Skipped
  when the tab is currently visible — sound only fires when backgrounded.
- AR/EN translations added.

Pre-existing TS errors in executive-meetings.ts (font_settings) and
pre-existing test workflow failures are unrelated.
2026-05-05 08:24:27 +00:00
riyadhafraa 4af3917026 Task #389: Notification sounds + vibration with per-user customization
DB
- Added 7 user pref columns; vibration is now per-channel
  (vibrationEnabledOrder + vibrationEnabledMeeting). Defaults: ding/chime
  sounds, vibration on, mute off, volume 70.

API
- PATCH /auth/me/notification-preferences (requireAuth, whitelisted partial
  update, integer guard for volume). buildAuthUser exposes all new fields.
- OpenAPI spec updated; orval client + zod regenerated.

Frontend
- Static sound library: 8 short WAV assets under public/sounds/ + manifest
  (notification-sound-manifest.ts) mapping id -> labelKey -> URL.
- HTMLAudio-based player (notification-sounds.ts) with throttle, unlock,
  cache, and AUTOPLAY_BLOCKED_EVENT dispatch when play is denied pre-gesture.
- Settings popover: global mute, volume, slot tabs (orders/meetings) with
  per-channel sound + per-channel vibration + per-channel toggle, sound list
  with one-tap previews. Concurrency-safe optimistic updates (monotonic seq
  counter). RTL-correct toggle knob transforms.
- QuickMuteButton: one-tap topbar mute control (Volume2/VolumeX) with
  confirmation toast.
- useAudioUnlock: unlocks audio on first interaction.
- useAutoplayHint: toasts a localized "click anywhere to enable" hint when
  the player reports a blocked play attempt.
- Socket hook plays the right per-channel sound + vibration on
  notification_created (orders, executive_meeting) when the tab is hidden.
- UpcomingMeetingAlert: plays meeting reminder once per new eligible
  meeting (deduped by meetingId, survives 30s polling refetches).
- AR/EN translations added.

Pre-existing TS errors in executive-meetings.ts (font_settings) and
pre-existing test workflow failures are unrelated.
2026-05-05 08:20:54 +00:00
riyadhafraa 242f63ab26 Task #389: Notification sounds + vibration with per-user customization
- DB: 7 new user prefs (sound per slot, per-type toggles, vibration, mute, volume).
- API: PATCH /auth/me/notification-preferences with whitelisted partial update,
  integer guard for volume, and hydrated AuthUser response. AuthUser now exposes
  the 7 new fields.
- Frontend: Web Audio synth library (8 sounds), settings popover with global
  mute/vibration/volume/per-type toggles, slot tabs, sound preview buttons,
  shift+click on bell for quick mute. Concurrency-safe optimistic updates with
  monotonic seq counter. RTL-correct toggle knob transforms.
- Socket hook plays sound on notification_created (orders/meetings only) when
  tab is hidden, respecting global mute and per-type toggles.
- Audio unlock hook mounted globally to satisfy autoplay restrictions.
- AR/EN translations added.

Pre-existing TS errors in executive-meetings.ts (font_settings) and pre-existing
test failures in test workflow are unrelated to this task.
2026-05-05 08:13:38 +00:00
riyadhafraa 014f9ecb0e Fix PDF font color not reflecting system settings via per-field merge
Root cause: resolveFontPrefsForUser() used `userRow ?? globalRow` whole-row
precedence. When an admin saved font settings with scope="global", only the
global row was updated. If the admin also had a user-scope row (created by
prior saves with the default scope="user"), ALL fields from the user-scope
row overrode the global row — including fontColor — causing the PDF to show
the old color even after changing global settings.

Schema change (executive-meetings.ts):
- Made fontFamily, fontSize, fontWeight, alignment, fontColor nullable.
  User-scope rows now store NULL for fields that inherit from global,
  and only store non-null values for fields the user explicitly overrode.

Backend fix (executive-meetings.ts):
- resolveFontPrefsForUser: per-field merge —
  user non-null → global non-null → schema default.
- PATCH handler for user-scope: after upsert, compares each field with the
  current global values. Fields matching global are set to NULL (= inherit).
  The post-nullification row is returned in the API response and audit log.
- No user-scope rows are deleted; scope isolation is preserved.

Frontend fix (executive-meetings.tsx):
- effectiveFont computed via per-field merge (u?.field ?? g?.field ?? default)
- FontSettingsResponse type updated for nullable fields (FontSettingsRow)
- Scope switching in FontSettingsSection loads the selected scope's values
  (global → globalFont ?? DEFAULT_FONT; user → effective font)
- globalFont prop threaded through SettingsSection → FontSettingsSection

Data migration: existing user-scope rows normalized via SQL — fields matching
global values set to NULL so per-field inheritance applies immediately.

Verified: TypeScript clean, e2e Playwright test passes, API tests confirm
per-field merge, nullification, and PDF color propagation.
2026-05-04 12:30:00 +00:00
riyadhafraa 57e8297464 Task #349: Executive Meetings PDF improvements
- Schema: add `font_color` (hex, default #000000) and `logo_object_path`
  to `executive_meeting_font_settings`. Pushed via drizzle-kit.

- PDF renderer:
  - Add `fontColor` to PdfFontPrefs (body cells only; header chrome
    and logo intentionally ignore it to preserve branding).
  - Add `rowColor` to PdfMeeting and a ROW_COLOR_FILL palette kept in
    lockstep with the on-screen swatches; deliberately stop painting
    the legacy `isHighlighted` overlay so the archived PDF reflects
    editorial state instead of the viewer's transient cursor.
  - Add optional `logo: Buffer`. Header now reserves a left-anchored
    logo box and centers the title in the remaining width; bad image
    bytes log + fall back to the no-logo layout instead of crashing.

- API route:
  - Extend fontSettingsSchema with strict #RRGGBB regex and
    /^/objects/<id>$/ regex for logoObjectPath.
  - resolveFontPrefsForUser now returns { font, logoObjectPath }.
  - loadLogoBytes downloads the brand asset via ObjectStorageService.
  - Logo only writable on the global-scope row.
  - PDF labels switched to "قائمة بأسماء حضور الاجتماعات" /
    "Meeting Attendance List" per the user's printed sample.

- Frontend (executive-meetings.tsx):
  - FontPrefs gains fontColor; FontSettingsResponse.global gains
    logoObjectPath; DEFAULT_FONT and effectiveFont updated.
  - buildFontStyle applies fontColor to on-screen rows.
  - FontSettingsSection: native color picker + hex text input;
    logo upload (PNG/JPEG) via @workspace/object-storage-web's
    useUpload, visible only at the global scope for admins.

- Locales: AR/EN keys for fontColor + logo.{label,upload,replace,
  remove,uploading,uploadFailed,globalOnly}.

- Tests: existing font-settings roundtrip extended with fontColor;
  new test rejects malformed fontColor and non-/objects logo paths.

Type-check clean for api-server and tx-os; new font-settings tests
pass. Other test failures in the suite pre-date this change.
2026-05-03 14:34:29 +00:00
riyadhafraa 49bf44f0a2 Add functionality to download role permission audit history as a CSV file
Introduce a new admin-only API endpoint for exporting role permission audit data to CSV, including resolved permission names and UTF-8 BOM for Excel compatibility. Frontend button and backend logic implemented to support this feature.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 0cb48020-8f0c-42bb-8fe8-d638905f7fce
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/g7BgHDL
Replit-Helium-Checkpoint-Created: true
2026-05-01 16:11:43 +00:00
riyadhafraa de7973f35b Task #293: DB CHECK constraint for executive_meetings.row_color (defense-in-depth for #288)
Mirrors the API-side row-colour whitelist at the database layer so any
out-of-band write path (manual psql, future bulk-import jobs, restored
backups) cannot smuggle an unrenderable colour past the Zod guard
introduced in #288.

Changes:

- lib/db/src/schema/executive-meetings.ts: export new shared constant
  EXECUTIVE_MEETING_ROW_COLOR_KEYS (red/amber/green/blue/violet/gray)
  + ExecutiveMeetingRowColor union type. Add a Drizzle check()
  constraint named `executive_meetings_row_color_palette_check` that
  allows NULL or any of the six keys. The CHECK uses sql.raw to inline
  the palette as quoted SQL literals (PG CHECK definitions are DDL and
  reject parameter placeholders); safe because the keys come from a
  hardcoded compile-time constant of single-word identifiers, never
  user input. Generating the literal list from the same constant
  guarantees the API guard and the DB constraint stay in sync.

- artifacts/api-server/src/routes/executive-meetings.ts: import
  EXECUTIVE_MEETING_ROW_COLOR_KEYS from @workspace/db and rewire
  rowColorSchema to z.enum(EXECUTIVE_MEETING_ROW_COLOR_KEYS).nullable().
  Deletes the local ROW_COLOR_KEYS const so the palette has exactly
  one source of truth. No behaviour change at runtime.

- artifacts/api-server/tests/executive-meetings-row-color.test.mjs:
  append a focused defense-in-depth test that bypasses the API and
  asserts (a) NULL is allowed, (b) each of the six palette keys
  round-trips on raw UPDATE, (c) off-palette UPDATEs reject with PG
  SQLSTATE 23514 referencing the constraint by name, (d) the row
  keeps its previous colour after the rejected updates, and (e) raw
  INSERT with an off-palette value is rejected the same way.

Migration applied via pnpm --filter @workspace/db push (no destructive
prompts; existing rows are NULL or valid keys from #288 so the
constraint installs cleanly). All 7 tests in the row-color file pass.
Architect review: APPROVED, no critical findings.

The single pre-existing Reorder test failure in the wider suite is
unrelated to this change (Reorder does not touch row_color).
2026-05-01 15:41:21 +00:00
riyadhafraa 6876a83bbb Audit log: filter by actor (#197)
Admins can now filter the audit log by who acted, not just by what was
acted on.

User-facing changes
- Replaced the flat actor <select> with an autocomplete combobox
  (Popover + cmdk Command) that searches by username AND display name,
  in English or Arabic.
- Added an `actorId` URL hash param that survives full reload — picks
  up alongside the existing target filter on initial mount and is
  cleared when the admin navigates to a different section.
- Each audit row's actor avatar + name is now clickable, mirroring the
  existing target chip pivot, so admins can jump from "this row" to
  "everything by this person" in one click. Rows with a null actor
  (system / deleted user) render the same avatar/name without the
  click affordance, since the API can only filter by a concrete id.
- Active actor pill renders in sky (target pill remains emerald) and
  has a clear button.
- CSV export already accepted `actorUserId` on the backend; the
  frontend export call now forwards the filter and the OpenAPI
  description has been updated to document it.

Files
- artifacts/tx-os/src/pages/admin.tsx
  - New AuditActorPicker component, hash helpers
    (parseAuditHashActor / syncAuditHashActor), pivotToActor /
    clearActorFilter handlers, sky-colored active pill, clickable
    actor in AuditLogRow.
- artifacts/api-server/tests/audit-logs-actor-filter.test.mjs (new)
  - 6 tests covering: filter narrows results, excludes other actors
    on the same target_type, combines with target filter, invalid
    /zero/negative actorUserId → 400, CSV export honors filter.
- artifacts/tx-os/src/locales/{en,ar}.json
  - actorSearch / actorEmpty / actorWithName / actorWithId /
    clearActorFilter / actorPivotAria.
- lib/api-spec/openapi.yaml
  - Audit export endpoint description now mentions
    targetType / targetId / actorUserId.

Verification
- pnpm --filter @workspace/tx-os run typecheck → clean.
- All 6 new actor-filter tests + all 7 existing target-filter tests
  pass against a live API server.
- E2E run via runTest() succeeded: opened the picker, selected an
  actor, verified the pill + reload-safe hash, cleared, pivoted via
  a row click, and confirmed CSV export honored the filter.

Notes / drift
- URL key is `actorId` (per task spec), but the React state and API
  param remain `actorUserId` to match the existing backend.
- The broader `test` workflow has pre-existing failures in
  executive-meetings.test.mjs and service-orders.test.mjs unrelated
  to this change (admin 401 / HTML-404 fallback). Captured as
  follow-up #291.

Replit-Task-Id: 0f02b232-eda3-46db-8235-98ecce2ebdb7
2026-05-01 15:26:36 +00:00
riyadhafraa ab5ec2e2e2 Add shared row highlighting to executive meeting scheduler
Implement shared row highlighting for executive meetings by adding a `rowColor` field to the database schema and API, and migrating existing per-device colors to the new shared field.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 273accfc-a301-41b9-bd20-c121cb4e79c7
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/g7BgHDL
Replit-Helium-Checkpoint-Created: true
2026-05-01 15:24:59 +00:00
riyadhafraa a72c00fe19 Task #273: 5-minute pre-meeting alert for Executive Meetings
- New `executive_meeting_alert_state` table (per-user, per-meeting) tracking
  `dismissed`/`acknowledged`. Migration via `pnpm --filter @workspace/db
  run push-force`.
- New API routes in artifacts/api-server/src/routes/executive-meetings.ts:
  GET /alert-state, POST /:id/alert-state, POST /:id/postpone-minutes,
  POST /:id/reschedule, POST /:id/cancel. All three mutation routes
  acquire a row lock with SELECT ... FOR UPDATE inside the transaction,
  compute oldValue from the locked snapshot, run conflict detection in
  the same tx, and write the audit row before commit. Cancel is
  idempotent. Postpone-minutes rejects ranges that would cross midnight
  (use reschedule for cross-day moves).
- Alert-state route uses race-safe onConflictDoNothing upsert + a
  conditional UPDATE ... RETURNING so transition audits never duplicate.
- New i18n keys `executiveMeetings.alert.*` in en.json + ar.json,
  including the cancel-confirm prompt.
- New component artifacts/tx-os/src/components/executive-meetings/
  upcoming-meeting-alert.tsx — globally mounted in App.tsx inside
  AuthProvider. Draggable with localStorage position persistence,
  RTL-aware, polls every 30s, shows start–end window, postpone-by-
  minutes chips [5,10,15,30,45,60], full reschedule sub-form, and a
  Cancel-meeting flow that requires an explicit confirm step before
  the destructive call fires (gated on confirmCancel state).
- Playwright spec executive-meetings-upcoming-alert.spec.mjs with
  6 scenarios: appear+Done, postpone-10 shifts times, cancel-with-
  confirm, dismiss audit, postpone-chip+conflict-warning toast,
  AR/RTL render. All 6 pass.
- replit.md updated with the new table, routes, and migration step.

Pre-existing tsc errors at lines 489, 605, and 2039 of
executive-meetings.ts are not touched by this task.
2026-05-01 11:56:14 +00:00
riyadhafraa 389e8b785c #262: remove Requests / Approvals / Tasks tabs from Executive Meetings
Full-stack removal of the three retired sections.

Backend
- routes/executive-meetings.ts: deleted /requests* + /tasks* handlers,
  REQUEST_ROLES / TASK_VIEW_ROLES / TASK_BROAD_VIEW_ROLES, the three
  capability flags from /me, retired imports, and dead schemas
  (detailsByType, request*Schema, taskCreateSchema, taskPatchSchema,
  dueAtSchema, dateOnly, timeHm). canApprove kept (FontSettings).
- lib/executive-meeting-notify.ts: types collapsed to ['meeting_created'].

Frontend
- pages/executive-meetings.tsx: deleted Requests/Approvals/Tasks
  sections, RequestListRow, retired SECTIONS entries, MeRoles type, and
  unused icon imports.
- hooks/use-notifications-socket.ts: dropped two retired invalidations.
- locales/{ar,en}.json: removed nav + section + 6 retired type keys.

Schema + DB
- lib/db/src/schema/executive-meetings.ts: tables/relations removed.
- scripts/cleanup-em-requests-tasks.sql: idempotent cleanup — orphan
  prefs / notifications / audit rows then DROP TABLE … CASCADE.
  Applied to dev DB; `db push` re-synced.

Tests
- Sequential `node --test --test-concurrency=1` → 226/226 pass.
- 3 pre-existing parallel-file pollution failures in the workflow
  runner are unrelated to #262 (verified by sequential run).
- Pre-existing tsc warnings at routes L509/625/L1594 untouched.
2026-05-01 08:28:11 +00:00
riyadhafraa 21c935064d #262: remove Requests / Approvals / Tasks tabs from Executive Meetings
Full-stack removal of the three retired sections — UI, locales, realtime
invalidations, backend routes, role lists, capability flags, schema
tables, notify lib, and tests.

Backend (artifacts/api-server)
- routes/executive-meetings.ts: deleted /requests* + /tasks* handler
  block, REQUEST_ROLES / TASK_VIEW_ROLES / TASK_BROAD_VIEW_ROLES,
  canSubmitRequest / canViewTasks / canViewAllTasks from /me, retired
  table imports, and dead schemas (detailsByType, requestPayloadSchemas,
  request*Schema, taskCreateSchema, taskPatchSchema, dueAtSchema,
  dateOnly, timeHm). canApprove kept (still used by FontSettings).
- lib/executive-meeting-notify.ts: EXECUTIVE_MEETING_NOTIFICATION_TYPES
  collapsed to ['meeting_created'].

Frontend (artifacts/tx-os)
- pages/executive-meetings.tsx: deleted RequestsSection /
  ApprovalsSection / TasksSection / RequestListRow, pruned SECTIONS,
  MeCapabilities / MeRoles types, isSectionVisible cases, icon imports.
- hooks/use-notifications-socket.ts: dropped the two retired query
  invalidations.
- locales/{ar,en}.json: removed nav.{requests,approvals,tasks},
  executiveMeetings.{requests,approvals,tasks} subtrees, and the 6
  retired notification.type entries.

Schema + DB
- lib/db/src/schema/executive-meetings.ts: tables + relations + types
  for requests/tasks removed.
- artifacts/api-server/scripts/cleanup-em-requests-tasks.sql:
  idempotent BEGIN/COMMIT — deletes orphan prefs / notifications /
  audit rows, then DROP TABLE … CASCADE for both retired tables.
  Applied to dev DB and `db push` re-synced.

Tests
- executive-meetings.test.mjs: deleted 9 retired blocks + 2 covered
  prefs duplicates, rewrote /me capability test to assert flags absent,
  rewrote DELETE-wipe test to use meeting_created via POST
  /api/executive-meetings, removed /requests + /tasks router.param
  entries.
- executive-meetings-notifications.test.mjs: deleted 7 blocks
  (request_*, task_*, cross-event-mute), updated before/after
  cleanup to skip dropped tables, kept setPref/clearPref helpers
  (still used by surviving meeting_created opt-out tests).

Drift / pre-existing
- 3 test failures observed under the parallel `node --test` workflow
  (meeting_created fan-out count, pref opt-out daily-number conflict,
  service-orders JSON-vs-HTML) are pre-existing parallel-file
  pollution between executive-meetings.test.mjs and
  executive-meetings-notifications.test.mjs. Verified by running
  `node --test --test-concurrency=1 'tests/**/*.test.mjs'` →
  226/226 pass. Out of scope for #262.
- Pre-existing tsc warnings at routes/executive-meetings.ts L509/625
  (boolean/number on isHighlighted) and L1594 (font-settings scope
  query) untouched.
2026-05-01 08:18:29 +00:00
riyadhafraa c0e55752a3 Task #183: clickable dependency counts on admin Apps/Services/Users
Turned the inline dependency-count badges on each admin row into
focusable, keyboard-accessible buttons that open a drill-in modal
listing the actual rows behind the count.

Backend (artifacts/api-server/src/routes):
  - apps.ts: GET /admin/apps/:id/dependents/{groups,restrictions,opens}
  - services.ts: GET /admin/services/:id/dependents/orders
  - users.ts: GET /admin/users/:id/dependents/{notes,orders,conversations,messages}
  All paginated (limit default 50, max 200; offset) returning
  {items, totalCount, limit, offset, nextOffset}. Restrictions joins
  the role names that include each required permission.

OpenAPI:
  - Added 8 path operations + 16 schemas (Item + Page) and re-ran
    `pnpm --filter @workspace/api-spec run codegen`.

Frontend (artifacts/tx-os/src/pages/admin.tsx):
  - New DependencyDrillIn component reuses DrillInShell (Escape +
    backdrop close, RTL/LTR safe) and the existing LoadMoreSection
    pagination pattern from AppOpensDrillIn.
  - Each count part in Apps, Services, and Users panels is now a
    <button> with a unique data-testid (e.g. app-counts-groups-213,
    user-counts-messages-5) and an aria-label that reads
    "View {count}".
  - AdminPage owns dependencyTarget state for Apps/Services counts;
    UsersPanel owns its own (it already encapsulates user list).

Translations:
  - Added admin.dependents.* (titles, subtitles, empty/error/load
    labels, conversation/order/message helper strings, order status
    enum) to en.json and ar.json.

Verification: - tx-os typecheck clean; api-server has only the pre-existing
    executive-meetings.ts errors (untouched).
  - e2e tested via runTest: login as admin, opened Apps panel,
    drilled into groups + opens (Load more grew 50→100), drilled into
    Tea orders, drilled into user 5 conversations and messages,
    switched to Arabic/RTL and re-opened the Groups drill-in to
    confirm Arabic rendering with no raw i18n keys.
Replit-Task-Id: fe96a05b-325f-4e1f-901b-3a2235fb24b5
2026-05-01 07:32:11 +00:00
riyadhafraa 90c319fb25 Show inline dependency counts on the Roles admin list (Task #182)
The Apps, Services, Users, and Groups admin panels already surface their
dependency counts inline so admins know what's affected before clicking.
The Roles panel previously hid this — admins had to open the delete dialog
to see how many users/groups would be affected. This change adds the same
inline display to the Roles panel for consistency.

Changes
- lib/api-spec/openapi.yaml: Added optional `userCount` and `groupCount`
  fields to the `Role` schema (matching the App pattern: optional, populated
  only by the admin list endpoint, with descriptive comments).
- artifacts/api-server/src/routes/roles.ts: GET /roles now batches two
  grouped count queries (user_roles, group_roles) and merges the counts
  into each list item — same shape as GET /apps. Empty-list short-circuits
  before running the aggregations.
- lib/api-zod/src/generated/api.ts: Regenerated via the api-spec codegen
  script (orval). ListRolesResponseItem now includes the optional counts.
- artifacts/tx-os/src/pages/admin.tsx (RolesPanel): Each role card renders
  an inline counts row using the existing `admin.roles.usersCount` /
  `admin.roles.groupsCount` translation keys (no new copy needed).
  Mirrors the Apps panel pattern: 11px muted-foreground text with bullet
  separators, only renders when at least one count is > 0, and exposes
  a `data-testid="role-counts-<id>"` for tests.

Notes / deviations
- The task description said "the role list endpoint already returns
  userCount/groupCount" but it didn't — the counts only existed on
  /roles/:id/usage. Added them to the list endpoint following the same
  pattern Apps and Groups already use.
- The pre-existing admin.roles.usersCount/groupsCount keys have no
  `_one`/`_other` plural variants; I kept it that way to stay consistent
  with the Apps panel keys (which also have no plural variants).

Verification
- `pnpm -w run typecheck` passes for tx-os and roles.ts (pre-existing
  unrelated typecheck errors in executive-meetings.ts remain — not touched
  by this change).
- e2e test (testing skill, status: success): logged in as the seeded
  admin, opened the Roles panel, verified inline counts render on the
  admin and user roles, bullet separator is present, and roles with zero
  dependencies don't render an empty counts area.

Replit-Task-Id: 8c99d912-8b3a-4e80-aca7-ec167e6e75e6
2026-05-01 06:58:52 +00:00
riyadhafraa b3ad701ba6 Task #243: Admin audit log — focused readability + actor-filter subset
Landed a tight subset of the 13-item umbrella, mirroring the proven
narrow-then-defer pattern from #242:

- #195 — Plain DELETE /api/services/:id now writes a `service.delete`
  audit row carrying nameEn + nameAr (force-with-deps still uses the
  dedicated `service.force_delete`). Added matching `service.delete`
  formatter case + EN/AR i18n keys, and surfaced nameAr on the existing
  `service.force_delete` summary.
- #197 — `actorUserId` filter for `/admin/audit-logs` and CSV export.
  openapi.yaml updated, codegen regenerated, server filter wired through
  parseFilters/buildWhere with 400-on-invalid handling, AuditLogPanel UI
  got an actor dropdown wired into params + export URL + reset, and a
  new audit-logs-actor-filter API test (4 cases) covers list narrowing,
  exclusion, invalid input, and CSV export.
- #178 — Formatter unit tests for user.delete (id-only, EN/AR display
  name resolution, force flag, force + name) and the new service.delete
  (id-only, EN/AR), 11 new cases (33/33 pass).

Skipped #194 — already implemented; users.ts DELETE persists displayName
fields and audit-summary already renders user.deleteWithName/forceDeleteWithName.

Deferred via follow-ups (no duplicate of existing #182/#183/#184):
- F1: #196 recent-activity endpoint + 5 admin panels
- F2: #205+#206+#208 permission history CSV/name resolution/timeline
- F3: #209+#210 cascade/bulk audit rows + e2e UI spec for History tabs

Validation: tx-os typecheck clean; pre-existing executive-meetings.ts
errors not regressed; all targeted server tests pass (delete-force-warnings 10,
audit-logs target-filter 7, forced-only 6, audit-log-coverage 27, new
actor-filter 4, broader audit/services sweep 40); e2e test verified actor
dropdown rendering, filter behavior, readable Arabic service.delete summary,
and CSV export honoring the filter.
2026-05-01 06:54:26 +00:00
riyadhafraa fb2d75ecd7 Task #164: Per-user notification preferences for Executive Meetings
Lets each user choose whether to receive in-app and/or email notifications
for each executive-meeting event type (meeting_created, request_submitted,
request_approved/rejected/needs_edit, task_assigned, task_completed).
Defaults to "everything on" when no preference row exists, preserving the
prior fan-out behavior for users who never visit the new UI.

Schema:
- New executive_meeting_notification_prefs table (user_id FK CASCADE,
  notification_type varchar(64), in_app bool default true, email bool
  default true, plus a unique index on (user_id, notification_type)).
- Pushed to dev DB via `pnpm --filter @workspace/db push`.

Backend:
- Exported EXECUTIVE_MEETING_NOTIFICATION_TYPES (canonical list) +
  filterRecipientsByNotificationPref(ids, type, channel) helper that
  returns only recipients whose row says the channel is on (default-on
  semantics for missing rows).
- recordExecutiveMeetingNotifications now filters recipients by
  channel="inApp" before inserting; sendExecutiveMeetingEmail filters
  by channel="email" before SMTP delivery.
- New endpoints under /executive-meetings/notification-prefs:
  GET → { types, prefs } merged with defaults.
  PUT → upserts each supplied (type, channel) pair via
  onConflictDoUpdate inside a transaction.

Frontend:
- New NotificationPrefsCard at the top of the Notifications section in
  artifacts/tx-os/src/pages/executive-meetings.tsx. Renders a Switch per
  (event type × channel) with batched save, dirty-state tracking, reset
  button, and useToast feedback.
- Translation keys for the card added to en.json and ar.json under
  executiveMeetings.notificationsPage.prefs.

Tests:
- 5 new tests in artifacts/api-server/tests/executive-meetings.test.mjs:
  GET defaults, PUT roundtrip + upsert, 400 on unknown type, in-app
  fan-out filtering (muted approver gets no row, control approver still
  does), and channel-independence (muting only the email channel leaves
  in-app delivery intact while persisting email=false in the DB row that
  sendExecutiveMeetingEmail's filter reads).
- All 36 executive-meetings tests pass. Full suite shows only one
  pre-existing flaky test elsewhere (groups-crud count assertion),
  unrelated to these changes.
- Added e2e UI test that logs in as admin, toggles a preference, saves,
  refreshes, and confirms persistence.
- After-hook cleans up new prefs rows for created users.

Follow-ups proposed: #236 (one-click reset to defaults), #237 (admin
view/override of any user's prefs).

Replit-Task-Id: 284ce15d-40d7-447e-90ca-090b44d8227b
2026-04-30 18:56:05 +00:00
riyadhafraa 60a18e1c7c Task #162: Let admins pre-set required permissions while creating an app
The "Required permissions" section was previously edit-only because
`POST /api/apps/:id/permissions` needs an app id, leaving a brief
window where a freshly created app was visible to everyone before
the admin could re-open the dialog and gate it. The Add app dialog
now lets the admin pick required permissions up front and the new
app + its `app_permissions` rows are written in a single transaction.

Changes:
- `lib/api-spec/openapi.yaml`: extended `CreateAppBody` with an optional
  `permissionIds: integer[]` field. Ran `pnpm --filter @workspace/api-spec
  run codegen` so `lib/api-zod` and `lib/api-client-react` reflect it.
- `artifacts/api-server/src/routes/apps.ts`: `POST /apps` now de-dupes
  and pre-validates `permissionIds`, returns 404 if any id is unknown
  (without creating the app), and inside one transaction inserts the
  app, the `app_permissions` rows (with `.onConflictDoNothing()` against
  the composite primary key), and a single `permission_audit` row
  (`previousIds: []`, `newIds: requestedIds`). After the transaction it
  also writes one `app.permission.add` audit_logs entry per inserted
  permission so the admin log mirrors the post-create flow.
- `artifacts/tx-os/src/pages/admin.tsx`: added `permissionIds: number[]`
  to `AppForm`, a new `NewAppPermissionsPicker` component (rendered only
  in create mode — edit mode keeps the existing `AppPermissionsEditor`
  with its impact preview) that lets admins add/remove permissions
  locally before submit, and wired `handleSaveApp` to forward the
  selected ids when creating. Existing edit path strips the field so
  the update payload remains unchanged.
- `replit.md`: documented the new picker and POST /api/apps behavior.

No impact preview is shown in the create-mode picker because a brand
new app starts with zero users seeing it, so adding permissions cannot
hide it from anyone.

Code-review follow-up: tightened input validation so non-integer or
non-positive `permissionIds` now return 400 with a clear error instead
of being silently dropped by the previous filter. The legacy single-add
endpoint already used this exact 400 message, so behavior stays
consistent across both create and update paths.

Verification:
- `pnpm --filter @workspace/api-spec run codegen` passes.
- `pnpm --filter @workspace/api-server` typechecks with no new errors
  (executive-meetings.ts errors are pre-existing and unrelated).
- Ran the existing app-permission test suites
  (`app-permission-audit.test.mjs`, `app-permissions-crud.test.mjs`,
  `app-permissions-impact.test.mjs`) directly — all 16 tests pass.
- Ran an e2e Playwright test (login as admin → Add app → pick a
  permission → save → verify the row shows 1 restriction → reopen and
  confirm the assigned permission). All steps passed.

Follow-up proposed: automated tests for the new create-with-permissions
endpoint behavior (#231).

Replit-Task-Id: c229777c-4036-4a6a-b4cb-05ccc18f6c9b
2026-04-30 18:21:21 +00:00
riyadhafraa b507b33cad Add app-permissions impact preview before tightening an app's gate
Mirrors the existing role-permissions impact preview UX for app
permissions. Admins now see how many currently-visible users would lose
access before they add a permission requirement to an app, plus the
groups (via group_apps) that offset the loss because their members keep
access regardless.

Backend
- New endpoint POST /api/apps/:id/permissions/impact-preview in
  artifacts/api-server/src/routes/apps.ts. Implements the same OR
  semantics as getVisibleAppsForUser: a user "sees" an app if they hold
  ANY required permission (direct or via a group role) OR they belong
  to a group granted the app via group_apps. Admins are excluded from
  counts since they always see every app. Short-circuits with
  noChange:true when the candidate set equals the current set.
- OpenAPI schema (lib/api-spec/openapi.yaml): adds the path,
  AppPermissionsImpactBody, AppPermissionImpactGroup,
  AppPermissionsImpact. Regenerated lib/api-client-react bindings.

Frontend
- AppPermissionsEditor (artifacts/tx-os/src/pages/admin.tsx): debounced
  (350ms) cancel-safe preview when a pending permission is selected,
  warning banner with affected/visible counts and offsetting groups,
  and a confirmation dialog when affectedUserCount > 0. Add button is
  disabled while the preview is loading or errored to keep the warning
  trustworthy.
- i18n keys added to en.json and ar.json under
  admin.appPermissions.{impactTitle, impactLoading, impactError,
  impactNone, impactSummary, impactViaGroups, confirmTitle, confirmBody,
  confirmAction}.

Tests
- artifacts/api-server/tests/app-permissions-impact.test.mjs: 7 tests
  covering noChange short-circuit, unrestricted-app tightening,
  candidate that keeps an existing permission, group_apps offset,
  unknown app (404), invalid payload (400), and admin-only enforcement.
  All 18 app-permissions tests pass.
- E2E flow verified via runTest: admin login → /admin → Apps → edit
  app → select permission → preview banner appears → Add → confirm
  dialog → cancel without writing.

Out-of-scope (filed as follow-ups #228 and #229): listing the specific
affected user IDs in the preview, and warning when REMOVING a
permission broadens access.

No deviations from the task spec.

Replit-Task-Id: 8b2ff9ea-f95e-4bdb-b268-95b5d17154ca
2026-04-30 18:03:08 +00:00
riyadhafraa a8467f810b Task #148: Make pnpm --filter @workspace/db run push work without manual SQL
## Original task
`pnpm --filter @workspace/db run push` was failing with a duplicate-key
error on `app_permissions` (and a missing FK on
`executive_meeting_notifications`) because legacy data in the dev DB
violates constraints the schema now declares. Devs had to drop into psql
to fix it, which made bootstrapping painful and left
`role_permission_audit` reliant on hand-applied SQL.

## Changes
- Added `lib/db/scripts/pre-push-cleanup.ts`, an idempotent cleanup that:
  - Collapses duplicate `app_permissions` rows to one per
    `(app_id, permission_id)` so the composite PK can be added.
  - Deletes orphan `executive_meeting_notifications` rows so the new
    `ON DELETE CASCADE` FK can be added.
  - Skips both checks when the tables don't exist yet (fresh DB
    no-op).
- Wired the script into `lib/db/package.json` so both `push` and
  `push-force` run cleanup first (`pnpm run pre-push-cleanup && drizzle-kit push ...`).
- Added `tsx` to `lib/db` devDependencies (catalog version) so the
  package can run the cleanup without leaning on another workspace.
- Updated `replit.md` Deployment / Migration Runbook to reflect that
  cleanup is now automatic — no manual SQL required in any environment.

## Verification
- `pnpm --filter @workspace/db run push` now collapses 2 duplicate
  groups + removes 1978 orphan notifications, then succeeds with
  `[✓] Changes applied`.
- Re-running push is idempotent: second run reports no duplicates and
  no orphans, then succeeds.
- `pnpm --filter @workspace/db run push-force` (used by
  `scripts/post-merge.sh`) was also verified end-to-end.
- Confirmed `app_permissions` now has the composite PK,
  `executive_meeting_notifications` has the cascade FK, and
  `role_permission_audit` matches the schema.

## Notes
- A pre-existing unrelated typecheck error in
  `artifacts/api-server/src/routes/executive-meetings.ts` (font
  settings `scope` overload) was confirmed to exist on `main` before
  any changes here and is out of scope for this task.
- Proposed follow-up #213 to move from `push`/`push-force` to
  versioned `drizzle-kit migrate` so legacy-data backfills are checked
  in instead of living in a generic pre-push script.

Replit-Task-Id: 2efc4e22-0c65-48a1-b573-319474698b96
2026-04-30 12:07:19 +00:00
riyadhafraa 2bf2f3cd3a Task #147: Add structured permission-change audit (users, groups, apps)
Mirrors the existing role-permission audit pattern with a unified
`permission_audit` table capturing actor, target, prev/new id sets, and
timestamp written in the same transaction as the change.

Schema & API
- New `permission_audit` table (target_kind, target_id, change_kind,
  actor_user_id, previous_ids[], new_ids[], created_at) with index on
  (target_kind, target_id, created_at).
- Transactional audit writes in routes/users.ts (POST/DELETE roles,
  PATCH groupIds), routes/groups.ts (PATCH + add/remove members for
  users/roles/apps), routes/apps.ts (POST/DELETE permissions).
- Cross-entity mirroring: when group membership changes via a group
  endpoint, a user.groups row is also written for each affected user
  (and vice versa via PATCH /users), so each entity's history is
  exhaustive regardless of which editor was used.
- Admin-only GET /users/:id/audit, /groups/:id/audit, /apps/:id/audit
  with limit/offset/actorUserId/from/to filters and the same response
  shape as role audit.
- OpenAPI types + codegen regenerated.

UI
- Reusable PermissionAuditHistory component in admin.tsx wired into
  UserGroupsEditor, GroupDetailEditor (new "history" tab), and the
  editing-app dialog. App history correctly resolves permission ids
  (NOT roles) via useListPermissions.
- Bilingual i18n keys added under admin.{users,groups,apps}.history*
  in en.json + ar.json.

Tests
- New backend tests: user-permission-audit, group-permission-audit,
  app-permission-audit (14 cases — transactional capture, GET filters
  & pagination, admin-only, 404 on unknown id, plus 2 new mirror
  tests covering cross-entity audit visibility). All pass; 35
  adjacent role/groups/users/audit-coverage tests still pass.

Notes
- replit.md updated to list `permission_audit` table.
- Restored opengraph.jpg (an unrelated stray binary diff).
- Code-review comments addressed: cross-entity asymmetry fixed via
  mirroring; opengraph.jpg restored.
- Follow-ups proposed: timeline UI improvements, cascade audit on
  delete/bulk paths, e2e UI test for History sections.

Replit-Task-Id: 4ba8c8de-8dfd-42c7-a3bc-964a1ed3a1a3
2026-04-30 11:58:18 +00:00
riyadhafraa 89df2385a8 Task #207: custom subheadings inside executive-meeting attendee cells
Adds a new `kind` column (`varchar(16) NOT NULL DEFAULT 'person'`) on
`executive_meeting_attendees` so users can interleave free-text section
labels with person rows in a meeting's attendee list. Subheadings are
excluded from the running attendee number and from the per-meeting
attendee count surface, but reorder and delete identically to person
rows.

DB
- New `kind` column in `lib/db/src/schema/executive-meetings.ts`,
  defaulting to `"person"`. Applied via direct SQL because
  `drizzle-kit push` trips on a pre-existing duplicate-row issue in
  `app_permissions` (already documented in replit.md).

API (artifacts/api-server/src/routes/executive-meetings.ts)
- `attendeeSchema` accepts `kind: z.enum(["person","subheading"])` with
  default `"person"`.
- All 4 insert paths round-trip `kind`: POST create, PATCH meeting
  update (attendees replacement), PUT `/attendees`, and duplicate.
- `pdf-renderer` mapper forwards `kind`. `PdfMeetingAttendee.kind`
  typed as `string | null` to match the DB column shape.

Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx)
- `AttendeeFlow` renders subheadings on a separate full-width row
  (`basis-full`, semibold, centered) and increments the running
  person index only for `kind === "person"`. Pending ghost row branches
  on `pendingKind`.
- Inline "+ subheading" chip via `onStartAdd(type, "subheading")`.
- Manage dialog: addSubheading button, subheading rows hide the title
  field, show a kind badge, and reorder/delete identically. Manage
  list summary count filters to person rows only (architect fix).
- Print page renders subheadings as `.em-print-subheading` and skips
  them in the running counter.
- New locale keys under `executiveMeetings.schedule` and
  `executiveMeetings.manage.attendees` in both `ar.json` and `en.json`.

PDF
- Subheadings print as `— label —` and never advance `personIdx`.

Tests
- New spec `executive-meetings-attendee-subheadings.spec.mjs` seeds
  mixed person+subheading rows and asserts (a) the subheading row
  renders, (b) numbering stays `1-`, `2-`, `3-` with a subheading
  wedged between persons, (c) zero-subheading meetings keep legacy
  numbering. Runs in both AR and EN. All 4 cases pass.

Code review
- Architect found one regression (Manage list summary count included
  subheadings) — fixed.
2026-04-30 11:45:59 +00:00
riyadhafraa fe736e3ff7 Task #146: Filter and paginate role permission history
Summary
- Backend: GET /api/roles/:id/audit now accepts limit (default 10, max 200),
  offset, actorUserId (0 = no filter), and from/to (YYYY-MM-DD UTC). The
  response is now a paginated envelope `{entries, totalCount, limit, offset,
  nextOffset}` instead of a bare array.
- OpenAPI: lib/api-spec/openapi.yaml updated with the new params and a new
  RolePermissionAuditList schema; client hooks regenerated via
  `pnpm --filter @workspace/api-spec run codegen`.
- Frontend: RolePermissionHistory in artifacts/tx-os/src/pages/admin.tsx is
  now self-contained — owns its own filter state, fetches the user list for
  the actor dropdown, applies actor changes immediately, and validates date
  inputs (rejecting invalid / inverted ranges).
- Pagination: switched to TRUE OFFSET PAGINATION. The first page comes from
  React Query (so live cache invalidations after a save still refresh it),
  and subsequent "Load more" clicks fetch `offset = nextOffset` imperatively
  via getRolePermissionAudit() and append the rows to local state. There is
  no client-side ceiling on how far back an admin can page; we simply stop
  showing the button when nextOffset is null. A filtersKey effect resets the
  appended pages whenever any filter (actor / from / to) changes so we never
  serve overlapping or out-of-order rows.
- i18n: added missing keys in artifacts/tx-os/src/locales/{en,ar}.json
  (historyEmptyFiltered, historyShowing, historyLoadMore, historyFilters.*,
  historyErrors.*).
- Tests: artifacts/api-server/tests/role-permission-audit.test.mjs updated
  to read the new envelope and now also covers offset-based pagination,
  actorUserId filtering (including the "0 = no filter" semantic), and
  date-range filtering (in-range / past-range / inverted / garbage). All 9
  audit tests pass; tx-os typecheck clean.
- E2E: ran the testing skill end-to-end against /admin → role edit dialog →
  history panel: created a fresh role through the UI, made 25 permission
  writes, verified 10 → 20 → 25 pagination with no duplicates and correct
  hide-on-end behaviour, filter-by-actor reset to first page, empty date
  range showed empty state, inverted dates surfaced the validation error.

Drift / notes
- Pre-existing executive-meetings tests in the `test` workflow are still
  failing — unchanged by this task.
- The Arabic language toggle isn't exposed in the page header in this build,
  so the e2e Arabic step was skipped; locale strings are in place and the
  test ids do not change with locale.
- Code review (initial pass) flagged a 200-row UI ceiling in the previous
  "growing limit" approach. Replaced with true offset pagination (described
  above) so admins can scroll back through arbitrarily long histories.

Code review follow-ups (round 2)
- Tightened parseRoleAuditUtcDate to reject impossible calendar days
  (2024-02-31, 2025-13-01, etc.) instead of silently rolling forward.
- Aligned OpenAPI: actorUserId schema is now `minimum: 0` so the spec
  matches the runtime "0 = no filter" contract; client regenerated.
- Added a targeted backend test that asserts impossible dates → 400.

Code review follow-ups (round 3)
- UX: when applied filters become invalid, the History list now hides the
  stale entries and shows the "fix filters first" hint instead, and the
  Load more button is hidden until filters are valid again. Avoids
  presenting yesterday's results as the current view.

Code review follow-ups (round 4)
- Belt-and-braces: also reset the appended history pages when the first
  page's totalCount + first-row id signature changes, so an external cache
  invalidation (e.g. another save while the dialog is still open) cannot
  leave appended pages out of sync with the refreshed first page.

Replit-Task-Id: cc3c9e83-921f-4f72-b443-79f95f6467b1
2026-04-30 10:45:07 +00:00
riyadhafraa 058cb8b817 Make forced-delete dependency chips clickable to pivot the audit log
Task #134: Admins investigating a forced deletion can now click any
dependency chip on a force_delete row to jump to a pre-filtered audit log
view of the related history.

Backend (artifacts/api-server, lib/api-spec):
- Added targetType + targetId query params to GET /api/admin/audit-logs
  and its CSV export. targetId is validated as a positive integer (400
  on bad input). Codegen regenerated for the React API client.

Frontend (artifacts/tx-os/src/pages/admin.tsx):
- Dependency chips on forced-delete rows are now real <button> elements
  with aria-labels and keyboard focus. Non-deletion rows are unchanged.
- Chip → pivot mapping: groupCount→targetType=group,
  memberCount→targetType=user, appCount→targetType=app,
  roleCount→targetType=role; cascade chips (orderCount, messageCount,
  noteCount, etc.) pivot to the parent (targetType+targetId).
- Active filter is reflected in the URL hash (deep-linkable + reload
  safe) and shown as a dismissible pill in the panel; the pill includes
  a Clear button. Switching audit sub-section drops the filter.
- Section sync logic preserves hash params and uses a one-shot
  skipNextSectionSync ref so initial deep-linked hashes aren't clobbered.
- New i18n keys in en.json and ar.json for filter labels and chip
  aria-labels.

Tests:
- New backend tests in artifacts/api-server/tests/audit-logs-target-filter.test.mjs
  cover targetType narrowing, targetType+targetId narrowing, invalid
  targetId rejection, combination with forcedOnly, and CSV export
  honoring the new filters (7 tests, all passing).
- Verified end-to-end via the browser testing skill: chip click,
  filter pill, clear, deep-link reload all behave correctly.

Pre-existing unrelated failures (not touched): two PDF archive tests in
executive-meetings.test.mjs and the matching typecheck errors in
executive-meetings.ts.

Replit-Task-Id: 46f972ef-2874-4fc3-95c5-53d0ff0732e9
2026-04-30 07:25:16 +00:00
riyadhafraa 11aaaf2abe Replace executive-meetings print-to-PDF with server-side PDF generator
The "Download PDF" button on the executive-meetings page now hits a real
backend endpoint that returns a true PDF (no more browser print dialog),
respects each user's font preferences (family, size, weight, alignment)
with proper Arabic RTL shaping, and archives every download.

The renderer maps each saved fontFamily ("system", "Cairo", "Tajawal",
"Noto Naskh Arabic", "Amiri") to a concrete pair of bundled font files
so the chosen family genuinely changes the embedded glyphs — Cairo and
Tajawal pick Noto Sans Arabic, the Naskh-style families and the system
default pick Noto Naskh Arabic, and Latin glyphs render in DejaVu Sans
across the board. Headers, body cells, and footer all flow through the
same script-aware font selection.

Backend (artifacts/api-server)
- New GET /api/executive-meetings/pdf?date=&lang= route in
  src/routes/executive-meetings.ts that fetches the day's meetings +
  attendees, renders a PDF, uploads it to object storage, writes an
  executive_meeting_pdf_archives row (date, generated_by, byte_size,
  storage_url), and streams the file back inline.
- New src/lib/pdf-renderer.ts using pdfkit + bidi-js with bundled
  Noto Naskh Arabic and DejaVu Sans fonts in assets/fonts/.
- Added byte_size column on executive_meeting_pdf_archives (also in
  lib/db schema) and rebuilt lib/db.
- Added ambient types for bidi-js; installed @swc/helpers to satisfy
  fontkit at runtime.
- build.mjs now copies pdfkit's data/ folder (Helvetica.afm, etc.)
  into dist/data so the bundled server can construct PDFDocument.

Frontend (artifacts/tx-os)
- PdfSection in src/pages/executive-meetings.tsx now renders a single
  "Download PDF" button that fetches the endpoint, builds a Blob, and
  downloads it. Removed the print/archive-creation buttons.
- Archive list shows a Download button for new /objects/... rows and a
  read-only "Legacy snapshot" badge for older print: rows.
- Added byteSize on PdfArchive + size formatting; updated en/ar locales.

Tests
- New test "PDF GET /executive-meetings/pdf returns a real PDF and
  archives it" in tests/executive-meetings.test.mjs covers: bad-date
  400, unauthenticated 401, real %PDF body + content-type/disposition,
  archive row with byteSize/generatedBy/filePath, empty-day handling,
  and font-family mapping (Cairo embeds NotoSansArabic; Noto Naskh
  Arabic embeds NotoNaskhArabic).
- All 23 executive-meetings tests pass.

Rebase
- Rebased onto main-repl/main (37255b7 "Update the website's shared
  image"). The only conflict was the binary asset
  artifacts/tx-os/public/opengraph.jpg — accepted the incoming/main
  version since it's unrelated to this PDF work.

Drift
- Kept the legacy /executive-meetings/print SPA route and the existing
  POST /pdf-archives endpoint to preserve old archive snapshots and
  the existing snapshot test. Proposed follow-up #169 to clean these
  up once stakeholders confirm.

Replit-Task-Id: 68914058-ebd6-4670-a785-c0084fe1fc94
2026-04-29 18:01:19 +00:00
riyadhafraa 8b2fe3164d Task #109: Admin UI to manage app required permissions
- Added 3 admin-only API endpoints in artifacts/api-server/src/routes/apps.ts:
  - GET    /api/apps/:id/permissions   — list permissions gating an app
  - POST   /api/apps/:id/permissions   — add a permission (idempotent via
    onConflictDoNothing() on the (app_id, permission_id) composite PK)
  - DELETE /api/apps/:id/permissions/:permissionId — remove (idempotent, 204)
- Documented the new endpoints in lib/api-spec/openapi.yaml with two new schemas
  (AddAppPermissionBody, AppPermissionLink) and re-ran codegen.
- Added a "Required permissions" section (AppPermissionsEditor) to the existing
  Edit App dialog in artifacts/tx-os/src/pages/admin.tsx, using the generated
  hooks. The section is shown only when editing an existing app (it needs an
  app id). Wired up admin.appPermissions.* i18n keys in en.json + ar.json.
- Added artifacts/api-server/tests/app-permissions-crud.test.mjs with 5 tests
  (empty list, idempotent add, 404 on unknown app/perm, idempotent delete,
  403 for non-admins). All 5 pass; related tests
  (app-permissions-unique, apps-group-visibility, list-dependency-counts) still pass.
- Verified the new admin UI end-to-end with the testing skill: admin login,
  open Edit App dialog, add/remove a required permission, and confirm the
  section is hidden in the Add App dialog.

Notes / scope:
- Pre-existing duplicate rows in app_permissions had to be deduped and
  `pnpm --filter @workspace/db run push` was run once so the composite PK
  could be added (separate task "Re-run the Drizzle schema push" already
  exists for this project-wide chore).
- No audit logging here — separate existing task already covers it.
- The "test" workflow shows a pre-existing ECONNREFUSED race; an existing
  task already tracks making the test workflow wait for the API server.

Replit-Task-Id: 912bb163-6b6f-43d3-9934-41fc97519337
2026-04-29 15:31:42 +00:00