eea5b9eebe434f53af39136000374b6800d844a8
87 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
119b745ddb |
Fix overlapping text and improve Arabic rendering in PDFs
Update PDF rendering logic to use fontkit for Arabic shaping and RTL reordering, eliminating pre-shaping and resolving text overlap issues. |
||
|
|
4018373bb0 |
Fix Arabic text rendering in generated PDFs
Add Arabic text shaping functionality to convert base characters to their contextual presentation forms before bidi reordering and PDF rendering. Includes a new regression test to verify correct shaping by asserting the presence of presentation form codepoints in the PDF's embedded ToUnicode CMap. |
||
|
|
19df7e3845 |
Task #349 follow-up: brand-logo embed test + label module + user-scope guard
Original task: Executive Meetings PDF improvements — respect user
font prefs, render saved per-meeting rowColor, drop legacy
isHighlighted baking, brand logo on left of PDF header with title
"قائمة بأسماء حضور الاجتماعات", and a logo upload + font color
picker in the font-settings page.
Prior mark_task_complete was rejected for three issues. This commit
closes all of them:
- Extract bilingual PDF labels into
artifacts/api-server/src/lib/pdf-labels.ts and import it from the
PDF route. Mirror the same keys under executiveMeetings.pdf.* in
the tx-os ar.json / en.json locales so the frontend stays in
sync.
- Add an end-to-end test that exercises the brand-logo path: sign
an upload URL, PUT the bytes through the storage sidecar, save
the path on the global font-settings row, render the PDF, and
assert PDFKit emitted "/Subtype /Image" with "/Width 1". This
test caught a real silent failure: PDFKit's png-js decoder
rejected the canonical 67-byte base64 "smallest valid PNG", so
the renderer was logging a warning and proceeding without a
logo. The fixture now constructs a real 1x1 RGB PNG inline using
zlib + a CRC32 routine (no new dependencies). Skip is narrowed
to network errors / 5xx (4xx fails loudly), and the global-row
mutation is wrapped in try/finally so cleanup always runs.
- Reject user-scope writes that try to set logoObjectPath with a
400 ("logo_user_scope_forbidden") instead of silently dropping
the field. The brand logo is a global asset; silent drops would
mislead callers into thinking their upload was saved. Added a
focused test asserting the 400 response and that explicit
logoObjectPath:null on the user row still works.
Also removed 12 stray backup/snapshot files at the repo root
(*.old, *-base.{ts,tsx,mjs}, locale snapshots) that were
accidentally tracked in the previous commit.
Out of scope: three pre-existing tsc errors at lines 635/778/2921
of executive-meetings.ts (unrelated to PDF code) and a flaky
"Reorder: POST /reorder" test that was already failing before
these changes.
|
||
|
|
06efffa891 |
Task #349 follow-up: brand-logo embed test + label module + user-scope guard
Original task: Executive Meetings PDF improvements — respect user
font prefs, render saved per-meeting rowColor, drop legacy
isHighlighted baking, brand logo on left of PDF header with title
"قائمة بأسماء حضور الاجتماعات", and a logo upload + font color
picker in the font-settings page.
Prior mark_task_complete was rejected for three issues. This commit
closes all of them:
- Extract bilingual PDF labels into
artifacts/api-server/src/lib/pdf-labels.ts and import it from the
PDF route. Mirror the same keys under executiveMeetings.pdf.* in
the tx-os ar.json / en.json locales so the frontend stays in
sync.
- Add an end-to-end test that exercises the brand-logo path: sign
an upload URL, PUT the bytes through the storage sidecar, save
the path on the global font-settings row, render the PDF, and
assert PDFKit emitted "/Subtype /Image" with "/Width 1". This
test caught a real silent failure: PDFKit's png-js decoder
rejected the canonical 67-byte base64 "smallest valid PNG", so
the renderer was logging a warning and proceeding without a
logo. The fixture now constructs a real 1x1 RGB PNG inline using
zlib + a CRC32 routine (no new dependencies). Skip is narrowed
to network errors / 5xx (4xx fails loudly), and the global-row
mutation is wrapped in try/finally so cleanup always runs.
- Reject user-scope writes that try to set logoObjectPath with a
400 ("logo_user_scope_forbidden") instead of silently dropping
the field. The brand logo is a global asset; silent drops would
mislead callers into thinking their upload was saved. Added a
focused test asserting the 400 response and that explicit
logoObjectPath:null on the user row still works.
Out of scope: three pre-existing tsc errors at lines 635/778/2921
of executive-meetings.ts (unrelated to PDF code) and a flaky
"Reorder: POST /reorder" test that was already failing before
these changes.
|
||
|
|
5a48dc4664 |
Task #349 follow-up: brand-logo embed test + PDF label module
Original task: Executive Meetings PDF improvements — respect user font prefs, render saved per-meeting rowColor, drop legacy isHighlighted baking, brand logo on left of PDF header with title "قائمة بأسماء حضور الاجتماعات", and a logo upload + font color picker in the font-settings page. Prior mark_task_complete was rejected for three issues. The first (stray backup files) was a false positive. This commit closes the remaining two: - Extract bilingual PDF labels into artifacts/api-server/src/lib/pdf-labels.ts and import it from the PDF route so the strings are no longer inlined inside the route handler. Mirror the same keys under executiveMeetings.pdf.* in the tx-os ar.json / en.json locales so the frontend stays in sync. - Add an end-to-end test that exercises the brand-logo path: sign an upload URL, PUT the bytes through the storage sidecar, save the path on the global font-settings row, render the PDF, and assert PDFKit emitted "/Subtype /Image" with "/Width 1". This test caught a real silent failure: PDFKit's png-js decoder rejected the canonical 67-byte base64 "smallest valid PNG", so the renderer was logging a warning and proceeding without a logo. The fixture now constructs a real 1x1 RGB PNG inline using zlib + a CRC32 routine (no new dependencies) and the assertion passes. Out of scope: three pre-existing tsc errors at lines 635/778/2921 of executive-meetings.ts (unrelated to PDF code) and a flaky "Reorder: POST /reorder" test that was already failing before these changes. |
||
|
|
bfa6836478 |
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. Added "PDF content" test that inflates PDFKit FlateDecode streams and asserts (a) /Title carries the new Arabic label, (b) amber rowColor paints #fef3c7, (c) #fecaca isHighlighted overlay is gone, (d) user fontColor reaches body text fills. - Frontend follow-up: logo preview in FontSettingsSection now uses resolveServiceImageUrl so /objects/<id> -> /api/storage/objects/<id> (the URL the API actually serves), matching chat-avatar plumbing. Type-check clean for api-server and tx-os; new font-settings + PDF content tests pass. Other test failures in the suite pre-date this change. |
||
|
|
b228b7d652 |
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. |
||
|
|
4a72805296 |
Task #312: auto-sort executive-meetings schedule by start_time
User report (AR): "اريدها ترتيبها تلقائيا" — after a non-drag write
(typing 13:00 into a row sitting above a 12:00 row, creating a meeting
with an explicit start time, etc.) the schedule's daily_number stayed
frozen at the row's prior position, so the visible list was no longer
chronological. Fix: extend the existing `renumberDayByStartTime` helper
to every write path that touches start_time / meeting_date / status /
daily_number, so the day is always renumbered 1..N by start_time
(NULLS LAST, cancelled rows at the tail).
Server (artifacts/api-server/src/routes/executive-meetings.ts):
- POST /executive-meetings — renumber after insert.
- PATCH /executive-meetings/:id — renumber when an order- or
visibility-affecting field (startTime/endTime/meetingDate/status/
dailyNumber) is in the payload. Cross-day moves renumber BOTH the
source and destination day. Pre-allocates a fresh dailyNumber on
the destination via nextDailyNumber(tx, newDate) before the UPDATE
so the (meeting_date, daily_number) unique index does not collide
when the row arrives on a day with existing meetings.
- DELETE /executive-meetings/:id — renumber so the day's `#`
sequence stays gap-free.
- POST /executive-meetings/:id/duplicate — renumber the target day.
Existing postpone-minutes / reschedule / cancel paths already called
renumber and were left alone. Reorder POST is also untouched (its
slot-swap is the explicit user-driven order, not auto-sort).
Audit-log surfacing of the auto-sort side effect (per validation):
- `renumberDayByStartTime` now returns `{ date, orderShifted, before,
after }` where `before`/`after` are the visible (non-cancelled)
row IDs in `daily_number` order, captured by a cheap SELECT inside
the same transaction.
- PATCH was restructured so renumber runs BEFORE `logAudit`, then
the row's post-renumber `daily_number` is read back and the audit's
`newValue` is enriched with:
• `dailyNumber` overridden to the post-sort position (so the
audit shows where the row landed, not the pre-sort draft);
• `orderShifted: true` and a `dayOrder: [{ date, before, after }]`
array (one entry per affected day, including both source and
destination on cross-day moves) when the visible order actually
changed.
- When auto-sort runs but does not change the visible order (e.g. the
row was already in the correct slot), `orderShifted` / `dayOrder`
are omitted so the audit UI does not falsely flag a reorder.
Tests (artifacts/api-server/tests/executive-meetings.test.mjs, +11):
- assertDayChronological() helper asserts 1..N + non-decreasing
start_time + no duplicate dailyNumbers.
- PATCH startTime later → row demoted (the user's exact scenario).
- PATCH startTime earlier → row promoted to the top.
- POST create with middle startTime slots between existing rows.
- PATCH startTime=null sinks to the tail of visible rows.
- Cancel pushes out / uncancel re-slots chronologically.
- Cross-day PATCH meetingDate renumbers BOTH days.
- DELETE leaves no `#` gap.
- POST /duplicate slots clone at chronological position.
- PATCH that reorders the day records orderShifted + post-sort
dailyNumber + dayOrder before/after arrays in the audit row.
- Title-only PATCH leaves orderShifted/dayOrder absent from the audit.
All 57 tests in executive-meetings.test.mjs pass.
Architect review (advisory, scope-bounded):
- Concurrency: PATCH/DELETE read `existing` outside the transaction.
Pre-existing convention in this file — only the cascade-bearing
postpone-minutes/reschedule paths use SELECT FOR UPDATE. Matching
existing pattern; tightening locking is a separate refactor
captured as follow-up #313.
- Audit surfacing for POST/DELETE/duplicate: only PATCH was enriched
in this task per validation feedback ("especially PATCH time/date/
status/dailyNumber paths"). UI-side rendering of the new audit
fields and POST/DELETE/duplicate enrichment captured as
follow-up #314.
|
||
|
|
bd3a8d83dc |
Task #312: auto-sort executive-meetings schedule by start_time
User report (AR): "اريدها ترتيبها تلقائيا" — after a non-drag write
(typing 13:00 into a row sitting above a 12:00 row, creating a meeting
with an explicit start time, etc.) the schedule's daily_number stayed
frozen at the row's prior position, so the visible list was no longer
chronological. Fix: extend the existing `renumberDayByStartTime` helper
to every write path that touches start_time / meeting_date / status /
daily_number, so the day is always renumbered 1..N by start_time
(NULLS LAST, cancelled rows at the tail).
Server (artifacts/api-server/src/routes/executive-meetings.ts):
- POST /executive-meetings — renumber after insert.
- PATCH /executive-meetings/:id — renumber when an order- or
visibility-affecting field (startTime/endTime/meetingDate/status/
dailyNumber) is in the payload. Cross-day moves renumber BOTH the
source and destination day. Pre-allocates a fresh dailyNumber on
the destination via nextDailyNumber(tx, newDate) before the UPDATE
so the (meeting_date, daily_number) unique index does not collide
when the row arrives on a day with existing meetings.
- DELETE /executive-meetings/:id — renumber so the day's `#`
sequence stays gap-free.
- POST /executive-meetings/:id/duplicate — renumber the target day.
Existing postpone-minutes / reschedule / cancel paths already called
renumber and were left alone. Reorder POST is also untouched (its
slot-swap is the explicit user-driven order, not auto-sort).
Tests (artifacts/api-server/tests/executive-meetings.test.mjs, +9):
- assertDayChronological() helper asserts 1..N + non-decreasing
start_time + no duplicate dailyNumbers.
- PATCH startTime later → row demoted (the user's exact scenario).
- PATCH startTime earlier → row promoted to the top.
- POST create with middle startTime slots between existing rows.
- PATCH startTime=null sinks to the tail of visible rows.
- Cancel pushes out / uncancel re-slots chronologically.
- Cross-day PATCH meetingDate renumbers BOTH days.
- DELETE leaves no `#` gap.
- POST /duplicate slots clone at chronological position.
All 55 tests in executive-meetings.test.mjs pass.
Architect review (advisory, scope-bounded):
- Concurrency: PATCH/DELETE read `existing` outside the transaction.
Pre-existing convention in this file — only the cascade-bearing
postpone-minutes/reschedule paths use SELECT FOR UPDATE. Matching
existing pattern; tightening locking is a separate refactor.
- Audit: renumber side-effect not echoed back into the audit row.
Per task plan (`.local/tasks/auto-sort-schedule-by-time.md`),
audit shape was intentionally kept minimal — the user-intent
fields already capture the change.
|
||
|
|
c4e650bd7b |
fix(executive-meetings): keep visible drag-reorder chronological with cancelled rows
Task #311. The schedule view hides cancelled meetings, but the drag-reorder path was operating on the raw, unfiltered list — so cancelled rows consumed time slots and the dnd-kit indices skewed across hidden rows, leaving the visible list out of chronological order after a drop. Server (POST /api/executive-meetings/reorder) - Slot-swap now operates on the in-scope (orderedIds) subset only. Cancelled rows keep their (startTime, endTime, dailyNumber) untouched, so they no longer steal slots from the visible list. - New 400 codes: - cancelled_in_reorder: payload includes a cancelled meeting - incomplete_day: any non-cancelled meeting on the day is missing - Audit oldValue.order now reflects the visible order the user actually saw. - Phase-1/phase-2 negative-parking still avoids transient unique-constraint conflicts; cancelled rows' positive dailyNumbers cannot collide because slot dailyNumbers are a permutation of in-scope rows' existing values. Client (executive-meetings.tsx reorderRows) - Index math now derives ids from `orderedMeetings` (the visible list bound to SortableContext) instead of the raw `meetings` array. useCallback deps updated. Tests - artifacts/api-server/tests/executive-meetings.test.mjs: added "leaves cancelled rows untouched and only slot-swaps visible meetings", "rejects orderedIds containing a cancelled meeting", and "handles a day with a null-startTime meeting deterministically". - artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs: added "Schedule drag-reorder: cancelled rows on the same day do not disturb the visible chronological order" (drives reorder via authenticated fetch since dnd-kit pixel drag is unreliable in headless). Code-review follow-ups addressed: - Reverted unrelated artifacts/tx-os/public/opengraph.jpg binary change. - Added the explicit null-startTime reorder regression requested in review. - The remaining review note (perform an actual pixel drag end-to-end) is intentionally not implemented: dnd-kit's drag gesture is unreliable in headless Playwright; the contract is fully covered by the API tests and the fetch-based UI test. All 8 reorder tests pass. Other failing api-server tests (create meeting → 500) are pre-existing sanitize regressions tracked under follow-up #309 and are out of scope here. |
||
|
|
e5bb0c2d3b |
fix(executive-meetings): keep visible drag-reorder chronological with cancelled rows
Task #311. The schedule view hides cancelled meetings, but the drag-reorder path was operating on the raw, unfiltered list — so cancelled rows consumed time slots and the dnd-kit indices skewed across hidden rows, leaving the visible list out of chronological order after a drop. Server (POST /api/executive-meetings/reorder) - Slot-swap now operates on the in-scope (orderedIds) subset only. Cancelled rows keep their (startTime, endTime, dailyNumber) untouched, so they no longer steal slots from the visible list. - New 400 codes: - cancelled_in_reorder: payload includes a cancelled meeting - incomplete_day: any non-cancelled meeting on the day is missing - Audit oldValue.order now reflects the visible order the user actually saw. - Phase-1/phase-2 negative-parking still avoids transient unique-constraint conflicts; cancelled rows' positive dailyNumbers cannot collide because slot dailyNumbers are a permutation of in-scope rows' existing values. Client (executive-meetings.tsx reorderRows) - Index math now derives ids from `orderedMeetings` (the visible list bound to SortableContext) instead of the raw `meetings` array. useCallback deps updated. Tests - artifacts/api-server/tests/executive-meetings.test.mjs: added "leaves cancelled rows untouched and only slot-swaps visible meetings" and "rejects orderedIds containing a cancelled meeting". - artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs: added "Schedule drag-reorder: cancelled rows on the same day do not disturb the visible chronological order" (drives reorder via authenticated fetch since dnd-kit pixel drag is unreliable in headless). All 7 reorder tests pass. Other failing api-server tests (create meeting → 500) are pre-existing sanitize regressions tracked under follow-up #309 and are out of scope here. |
||
|
|
7f63cf0b2b |
#302 cascade-shift later meetings on postpone/reschedule
Backend (artifacts/api-server/src/routes/executive-meetings.ts):
- New helpers computeCascadeShift + applyCascadeShift; new schema
cascadePreviewSchema; cascadeFollowing flag on postpone-minutes
and reschedule.
- POST /executive-meetings/:id/cascade-preview returns followers +
blockedBy.
- Writer paths now SELECT followers FOR UPDATE inside the txn so
concurrent mutations cannot lose updates and audit oldStart/oldEnd
always reflect the locked-current values (architect feedback).
- Reschedule only cascades when same date AND newStart > oldStart.
- Midnight rejection rolls back the primary too; named offender is
surfaced verbatim to the UI.
- One meeting_cascade_shift audit per follower (trigger meeting +
delta in the row for replay).
Frontend (artifacts/tx-os/src/components/executive-meetings/
upcoming-meeting-alert.tsx):
- CascadePromptBlock with loading / blocked / normal variants and
data-testids cascade-prompt[-blocked|-loading], cascade-keep-times,
cascade-shift-following, cascade-back, cascade-follower-{id}.
- runCascadePreview helper with in-flight + busy guard against
duplicate submissions; falls through to single-meeting submit when
no followers and not blocked.
- rescheduleSubmit extracted; cascade_crosses_midnight from server
is caught and re-rendered as the blocked variant.
i18n: cascade* keys under executiveMeetings.alert in en.json + ar.json
(Arabic plural zero/one/two/few/many/other for header + shift action).
Tests:
- 8 cascade backend tests in executive-meetings.test.mjs (preview
shape, atomic shift, skip cancelled/completed, midnight rollback,
reschedule delta + no-op cases) all pass.
- New e2e "Reschedule cascade — opting in shifts all later same-day
meetings".
- Made two existing tests (Postpone by 10 minutes, conflict warning)
pollution-tolerant by polling for either the cascade prompt or the
meeting-postponed audit row.
Pre-existing flaky tests (notifications fan-out, postpone race, row
color realtime, reorder, status transitions, alert-done/close
realtime, reschedule-different-day visibility, cancel/renumber strict
mode) are not related to this work and were not modified.
|
||
|
|
1de19422ad |
Task #303: DIN Next LT Arabic site default + working font picker
User report: "the dropdown for changing the site font does not work." Root cause: FontSettingsSection listed Cairo / Tajawal / Noto Naskh Arabic / Amiri, but only Tajawal had been registered with @font-face. Picking the others was a no-op. Site default body font was also not DIN Next LT Arabic as designed. Changes: - artifacts/tx-os/src/index.css: --app-font-sans now starts with "DIN Next LT Arabic"; dropped IBM Plex Mono. - artifacts/tx-os/index.html: removed Google Fonts <link> + preconnect. - artifacts/tx-os/src/pages/executive-meetings.tsx: FontSettingsSection dropdown replaced with the 5 actually-bundled families + system. - artifacts/api-server/src/routes/executive-meetings.ts: FONT_FAMILIES Zod allowlist updated to match. - artifacts/api-server/src/lib/sanitize.ts: FONT_NAME_PART regex now allows the new families (kept IBM Plex Sans Arabic for backward compat). Fixes a latent bug from #301 where the rich-text sanitizer silently stripped attendee font picks on save. - artifacts/api-server/src/lib/pdf-renderer.ts: FAMILY_MAP and ARABIC_FONT_NAMES updated. Sans-style families map to the bundled NotoSansArabic; Naskh-style families to NotoNaskhArabic. - artifacts/api-server/tests/executive-meetings.test.mjs: PDF sans-vs-naskh assertion updated from Cairo/Noto Naskh Arabic to DIN Next LT Arabic/Majalla, preserving its semantics. Other Cairo/Noto Naskh Arabic occurrences swapped to the new names. - replit.md: documented site default font + lockstep allowlist rule. Deviations from plan: kept DEFAULT_FONT.fontFamily = "system" on the frontend (and the backend Zod default) instead of changing it to "DIN Next LT Arabic". "system" semantically means "no override → use the CSS default", which is now DIN Next LT Arabic — same end result without forcing a migration on existing rows. The sanitizer change was not in the original plan but was required to make the picker actually persist. Verification: - Frontend tsc clean. - Backend tsc shows the same pre-existing unrelated errors as before (isHighlighted boolean/number; Drizzle scope typing). - E2E browser test verified all 7 assertions: site default font, no Google Fonts requests, exactly 6 dropdown options, font picker applies + reverts correctly, attendee font picks persist after save. - Code review verdict: PASS. |
||
|
|
42b511bdc5 |
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. |
||
|
|
4bde2c3f0d |
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). |
||
|
|
8d230ca47c |
Task #288: Share Executive Meetings row highlight colors across devices
Per-row highlight colors on the Executive Meetings daily schedule were
stored in each browser's localStorage, so a color set by the admin on
their laptop never reached the executive office or the big-screen
viewer. Move the color into a shared, server-stored field on the
meeting row so every viewer sees the same color in real time.
Schema
- Add nullable `row_color varchar(16)` to `executive_meetings`
(lib/db/src/schema/executive-meetings.ts). Migration applied via
`pnpm --filter @workspace/db push`.
API (artifacts/api-server/src/routes/executive-meetings.ts)
- Whitelist ROW_COLOR_KEYS = red/amber/green/blue/violet/gray.
- Extend meetingPatchSchema with optional `rowColor` (nullable enum).
- Existing PATCH /executive-meetings/:id picks it up, stamps
updatedBy, writes the standard audit-log entry, and fires
emitExecutiveMeetingsDaysChanged so other viewers' day query
invalidates and re-fetches.
- Permission gating unchanged: requireMutate → executive_viewer 403.
Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx)
- `rowColors` is now derived from the meetings query via useMemo.
The localStorage write effect is removed.
- New async setRowColor() PATCHes the server and invalidates the day
query (key ["/api/executive-meetings", date]).
- Best-effort migration of legacy localStorage colors:
- drops invalid keys / unknown colors immediately,
- skips ids the server already colored (no overwrite),
- PATCHes ids in the currently loaded day,
- PRESERVES ids for dates the user hasn't visited yet so they
migrate when they do navigate there (architect review caught a
data-loss bug in the first cut where unvisited-day entries were
being deleted),
- keeps failed PATCHes for retry, removes the localStorage key
only once nothing is left,
- in-flight Set prevents double-PATCH if the effect re-fires.
Tests
- New artifacts/api-server/tests/executive-meetings-row-color.test.mjs
(6 specs) covers PATCH set / clear / invalid-key 400 / viewer 403 /
realtime executive_meetings_changed socket event for the affected
date / audit attribution. All pass.
- E2E (runTest) verified two browser contexts share the color
without manual refresh.
Documentation
- replit.md: added "Task #288 — Shared row colours" section.
Drift / notes
- Pre-existing TS errors in admin.tsx and pre-existing isHighlighted /
font_settings scope errors in executive-meetings.ts are unrelated
to this change.
- Follow-up #293 proposed: add a DB-level CHECK constraint mirroring
the API whitelist for defense-in-depth.
|
||
|
|
c810ba5992 |
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.
|
||
|
|
85ac6e2ce9 |
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. |
||
|
|
20cb786744 |
Add conflict detection and resolution for meeting postponements
Implement optimistic locking for meeting postponements to prevent concurrent edits. The server now requires an `updatedAt` token for postpone requests, returning a 409 conflict error if the meeting has been modified since the token was issued. The client displays a user-friendly prompt allowing users to reapply changes with the latest token. |
||
|
|
9531d31cb4 |
Task #195: Audit-log every service deletion, not only forced ones
Background
----------
`DELETE /api/services/:id` previously wrote an audit_logs row only when
hasDeps && force was true. A clean delete (no orders, or ?force=false on a
service with no dependents) left no trace, so admins reviewing the Audit
Log could not tell who removed a service or when, and the new
"Target: service #… · Name" line never appeared for routine deletions.
Implementation status (already in tree from prior work in this branch)
----------------------------------------------------------------------
- artifacts/api-server/src/routes/services.ts wraps the delete + audit
insert in a single db.transaction:
* hasDeps && force -> service.force_delete with { nameEn, nameAr,
orderCount } (unchanged on-the-wire shape, so existing forced-only
filter and target-filter behavior is preserved).
* otherwise (no deps, or force=true with no deps) -> new service.delete
row with { nameEn, nameAr } and the actor.
- artifacts/tx-os/src/lib/audit-summary.ts already has a service.delete
case rendering admin.audit.summary.service.delete (with name) or
service.deleteId (id-only fallback) in both EN and AR.
- delete-force-warnings.test.mjs already covers the route-level no-deps
and ?force=true-with-no-deps paths.
This commit
-----------
Adds the canonical audit-log coverage tests requested by the task to
artifacts/api-server/tests/audit-log-coverage.test.mjs:
- "DELETE /api/services/:id (no deps, no force)" -> exactly one
service.delete row with nameEn + nameAr, no orderCount, and no
service.force_delete companion.
- "DELETE /api/services/:id without force on a service with deps" ->
409 + service still present + zero audit rows of either action.
- "DELETE /api/services/:id?force=true (with deps)" -> exactly one
service.force_delete row with orderCount=2 and no plain service.delete
companion.
Also adds an insertService helper, a createdServiceIds bucket, and a
service_orders/services teardown block to keep the suite self-cleaning.
Verification
------------
- node --test tests/audit-log-coverage.test.mjs -> 29/29 pass (3 new).
- node --test tests/delete-force-warnings.test.mjs tests/service-orders.test.mjs
tests/audit-logs-forced-only-filter.test.mjs
tests/audit-logs-target-filter.test.mjs -> 34/34 pass (no regressions).
Deviations
----------
None. Scope matches the task brief exactly.
|
||
|
|
ca84b62057 |
#262: remove Requests / Approvals / Tasks tabs from Executive Meetings
Full-stack removal of the three retired sections, including the
canApprove capability flag.
Backend
- routes/executive-meetings.ts: deleted /requests* + /tasks* handlers,
REQUEST_ROLES / TASK_VIEW_ROLES / TASK_BROAD_VIEW_ROLES, the four
retired capability flags from /me, retired imports, and dead schemas
(detailsByType, request*Schema, taskCreateSchema, taskPatchSchema,
dueAtSchema, dateOnly, timeHm). Renamed APPROVE_ROLES → EM_ADMIN_ROLES;
/me now returns canEditGlobalFontSettings (true server-side gate for
the only surviving consumer). Dropped now-unused requireApprove export.
- 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,
unused icon imports. MeCapabilities.canApprove → canEditGlobalFontSettings.
- 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.
- /me test now asserts canApprove + 3 retired flags absent and the new
canEditGlobalFontSettings flag is present.
|
||
|
|
be9e48508c |
#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.
|
||
|
|
cd6cb317fc |
#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.
|
||
|
|
85c6c434f0 |
#245: narrow umbrella subset — toast polish, opt-out tests, Restore defaults
Picked the 3 most isolated items from the 7-item umbrella; deferred the rest as #259/#260/#261. #223 + #224 — singular toast + summary on partial failure (T001): - my-orders.tsx: replaced the N=1 vs N>1 ternary in scheduleDelete with a single t("myOrders.clearedCount", { count }) so i18next picks _one / _other automatically. Single-row delete now flows through this same toast too — user-visible copy for N=1 is now "1 order deleted" / "تم حذف طلب واحد" instead of the legacy "Order deleted" / "تم حذف الطلب". - my-orders.tsx: partial-failure path now shows ONE summary toast using the existing clearedPartial key ("{{ok}} deleted, {{fail}} failed") instead of N error toasts. Total failure (okCount===0) keeps deleteFailed. - Updated 3 Playwright specs that asserted the legacy copy: order-clear-finished-undo (already had the singular case), order-undo-toast (Arabic single-row delete), order-delete-flush-on-unmount (English). Note: the legacy "myOrders.deleted" locale key is now unreferenced in source — left in place to avoid noise; deletion can be handled separately. #238 — opt-out coverage in executive-meetings-notifications.test.mjs (T002): - Appended 4 tests + setPref/clearPref helpers covering filterRecipientsByNotificationPref: inApp=false drops user, missing pref defaults to ON, cross-event isolation (mute on event A leaves event B alone), email=false leaves in-app intact. Helpers use ON CONFLICT on the verified unique index. Some scenarios overlap existing tests in executive-meetings.test.mjs (lines 1764, 1809) — these still add value by exercising the meeting_created socket fan-out path and cross-event isolation, which the existing tests don't cover. #236 — Restore defaults endpoint + button (T003): - Server: added DELETE /api/executive-meetings/notification-prefs after PUT. Scoped strictly to req.session.userId, returns {ok, count}. Reuses requireExecutiveAccess guard. Architect confirmed no cross-user leakage. - Client: restoreDefaults() handler + outline button (data-testid "em-pref-restore-defaults", NOT gated on dirty since the whole point is to blow away saved settings). New i18n keys restoreDefaults / restored in both locales. - Architect found a stale-state race in restoreDefaults: setDraft(null) was called before invalidateQueries, letting the seed effect repopulate draft from still-cached pre-DELETE data. Fixed by inverting the order to match save() — invalidate first (await refetch), then setDraft(null). - Tests: appended 2 integration tests to executive-meetings.test.mjs covering the full restore flow (PUT 2 muted prefs → DELETE → assert {ok,count:2} + GET shows defaults + actual fan-out reaches user again) and idempotent no-op DELETE on a user with no rows. Test results: - executive-meetings.test.mjs: 47/47 pass (incl. 2 new DELETE tests) - executive-meetings-notifications.test.mjs: 11/11 pass (incl. 4 new opt-out tests) - Playwright order specs: 6/6 pass after legacy-copy updates - Pre-existing failures in service-orders + meeting_created fan-out are untouched and not caused by this change. Follow-ups proposed: #259 (beforeunload + tab-close Playwright), #260 (admin override another user's prefs with audit row + UI), #261 (iPad header verification — may already work). |
||
|
|
07753bb3e9 |
Task #244: Permissions impact preview + live update test sweep (focused subset)
Landed 3 of 11 umbrella items, deferred the rest as 3 well-scoped follow-ups. #231 — POST /apps with permissionIds[] is now pinned by two tests in app-permissions-crud.test.mjs: success commits the app + permission rows together with an audit_logs row, and an unknown permissionId returns 404 without leaving an orphan app row or a stray app.create audit row. Extended the after() to clean up audit_logs + permission_audit so reruns stay idempotent. #215 — Added two socket tests in role-permissions-realtime.test.mjs for the per-permission POST and DELETE endpoints, mirroring the existing PUT coverage. Both assert direct + group-derived holders receive role_permissions_changed and outsiders do not. Each test creates a fresh role via makeFreshRoleWithMembers() so prior state can't bleed in. #216 — Found a real gap: apps.ts emitted nothing when an app's required- permission set changed. Added emitAppsChangedToPermissionHolders() to lib/realtime.ts (resolves users via role_permissions -> user_roles and group_roles -> user_groups, dedupes, reuses emitAppsChangedToUsers), and wired it into POST/DELETE /apps/:id/permissions — only emitted when an actual row was inserted/deleted, not on no-op retries. New test file apps-permissions-realtime.test.mjs covers direct holder + group-derived holder receipt and an idempotent no-op DELETE NOT emitting. Skipped (already done): #226 (non-admin gates already covered), #229 (impact-preview already handles the removal branch). Validation: 13/13 tests across the 3 modified files pass; 66/66 across related permission/audit suites pass; full server suite is 236/238 with the 2 failures (executive-meetings notifications, service-orders status matrix) being pre-existing in untouched files. Architect review: APPROVED with no critical/high findings; took the optional hardening suggestion to add group-holder coverage to the #216 tests so both legs of the helper's resolution path are exercised. Files: artifacts/api-server/src/lib/realtime.ts, artifacts/api-server/src/routes/apps.ts, artifacts/api-server/tests/app-permissions-crud.test.mjs, artifacts/api-server/tests/role-permissions-realtime.test.mjs, artifacts/api-server/tests/apps-permissions-realtime.test.mjs (new) |
||
|
|
d6b90db000 |
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. |
||
|
|
7dc153c10f |
Refine text sanitization and update test descriptions
Improve text sanitization logic and update comments in test files to be more concise and informative. |
||
|
|
add8b1e21e |
EM #241: sanitize location/meetingUrl/notes (regex stripper) + tests
Original task: Executive Meetings — test coverage + sanitization closeout (umbrella for #170, #186-189, #201, #202, #212, #214, #218, #235). What landed - Added `stripTagsToPlainText[OrNull]` in `artifacts/api-server/src/lib/sanitize.ts`. Implementation is a two-pass regex stripper (NOT sanitize-html + entity decode): Pass 1: drop dangerous tag bodies entirely (`<script>`/`<style>`/`<noscript>`/`<iframe>`/`<object>`/ `<embed>`/`<template>` content + tags). Pass 2: strip HTML comments, CDATA, DOCTYPE, processing instructions, and any remaining open/close tags via `<\/?[a-zA-Z][^>]*>`. No entity decode pass — so URLs (`?a=1&b=2`) round-trip unchanged, plain text (`5 < 10`) is preserved, AND attacker-supplied encoded payloads (`<script>…`) survive as inert text instead of being rehydrated into live tags. The existing `sanitizePlainText` (which entity-encodes) is preserved for `attendee.title` so the print template's HTML interpolation behavior is unchanged. - Wired the new helper into all 11 write paths for `location`/`meetingUrl`/`notes` in `artifacts/api-server/src/routes/executive-meetings.ts`: POST /executive-meetings, PATCH /executive-meetings/:id, POST /executive-meetings/:id/duplicate, and `applyApprovedRequest` (`change_location` + `note`). attendee.title call sites kept as-is. - Added 6 API tests in `artifacts/api-server/tests/executive-meetings.test.mjs`: 1. POST sanitization (URL `&` round-trip + literal `<`/`&` in notes + asserts NO entity-encoding in stored values). 2. Encoded-payload regression guard (`<script>`, `<script>`, mixed-case `<ScRiPt>`/`<IFRAME>`) confirming we don't decode entities into live tags. 3. PATCH sanitization on the same fields. 4. Duplicate-path round-trip (URL ampersands preserved). 5. change_location approved-request round-trip + tag stripping. 6. EditableCell column-independence contract test (PATCH titleEn alone must not clobber titleAr and vice versa). Drift from the original umbrella - The umbrella also listed 9 Playwright e2e specs (#170, #186, #187, #188, #201, #212, #214, #218, #235). Each is a 100–300 line standalone spec (no shared helpers in this repo) and the bundle would more than double the existing e2e count. Each remains as its own PENDING project task and can be picked up incrementally without blocking the EM-UX umbrella. Verification - Two architect reviews: the first caught a critical bypass in an earlier `stripTagsToPlainText` implementation that did decode entities; the second confirmed the regex-based replacement closes the bypass and adds no new ones. - Suite: 226 tests, 224 passing. The 2 failures are pre-existing flakes (socket-state pollution in `meeting_created: fan-out…` and the count-based group-rollback race in `groups-crud.test.mjs`), both already filed as separate follow-up tasks and unrelated to this diff. - Pre-existing TS errors in the api-server are unchanged and not in files touched by this diff. |
||
|
|
89f2f9d640 |
Add automated tests for audit log readable summaries
Original task: add unit tests for the new `formatAuditSummary` formatter
and an API-level test asserting the enriched group sub-resource audit
metadata, and wire both into the existing `test` workflow.
What changed:
- Extracted `formatAuditSummary` and its helpers (`asRecord`, `asString`,
`asNumber`, `unitLabel`, `appName`, `linkedAppName`, `plainName`,
`changeCount`) out of `artifacts/tx-os/src/pages/admin.tsx` into a new
`artifacts/tx-os/src/lib/audit-summary.ts` module so the pure formatter
can be unit-tested without the React tree. `admin.tsx` now imports the
helpers from that module.
- Added `artifacts/tx-os/src/__tests__/audit-summary.test.mjs` with 22
Node test-runner cases covering app rename (EN + AR), app-update
fallback, group rename, group multi-field update, registration toggle
(open / close / with-other-changes), and every group.user/app/role
add/remove name vs id-only branch, plus the unknown-action default.
- Added `pnpm --filter @workspace/tx-os test` (Node 24's native
TypeScript loader runs the .mjs tests against the .ts module directly).
- Added `artifacts/api-server/tests/group-audit-metadata.test.mjs` using
the same harness as `groups-crud.test.mjs`. It hits POST/DELETE
`/api/groups/:id/{users,apps,roles}/:targetId` and reads the resulting
`audit_logs.metadata`, asserting `username`, `appSlug` /`appNameEn` /
`appNameAr`, and `roleName` are persisted alongside the raw IDs.
- Updated the `test` workflow to run the tx-os unit tests before the
api-server tests, then the tx-os e2e tests.
Verification: all 22 tx-os unit tests pass via the new pnpm script, and
all 6 new api-server audit-metadata tests pass against a live server.
The overall api-server suite still has pre-existing flakes
(executive-meetings notifications/status transitions, and the
count-based group invariant in groups-crud.test.mjs) that are unrelated
to this change; both flake clusters are filed as follow-up tasks.
|
||
|
|
26205ade46 |
Improve sanitization for meeting details to prevent malicious input
Introduce a new function `stripTagsToPlainTextOrNull` to sanitize location, meeting URL, and notes fields, ensuring HTML tags are removed while preserving special characters for proper URL and text rendering. This change enhances security by preventing cross-site scripting (XSS) attacks and ensures data integrity for these fields across create, update, and duplication operations. |
||
|
|
2c4655be31 |
Show readable names in audit log for top-level deletions
Task: #177 — Make user/app/role deletion audit rows render readable names ("Deleted user @alice (Alice Smith)", "Deleted app 'Notes'", "Deleted role 'Editor'") instead of relying on whatever the route happened to capture. Backend metadata changes: - artifacts/api-server/src/routes/users.ts (user.delete): now also persists displayNameEn and displayNameAr alongside the existing username/email. - artifacts/api-server/src/routes/apps.ts (app.delete): renamed the metadata keys slug/nameAr/nameEn → appSlug/appNameAr/appNameEn so app sub-resource events and top-level deletes share one prefix. - artifacts/api-server/src/routes/roles.ts (role.delete): renamed the metadata key name → roleName, matching group.role.add/remove. Frontend formatter (artifacts/tx-os/src/pages/admin.tsx): - appName helper now reads both legacy (slug/nameEn/nameAr) and new (appSlug/appNameEn/appNameAr) keys so old rows still render. - role.delete case prefers roleName, falls back to legacy name. - user.delete case picks the user's localized display name and uses new locale strings user.deleteWithName / user.forceDeleteWithName when present; falls back to the existing username-only strings. - forceDeletedEntityName also accepts appNameEn/appNameAr/appSlug so force-deleted apps still get their inline name chip. Locales: - artifacts/tx-os/src/locales/{en,ar}.json: added admin.audit.summary.user.deleteWithName and forceDeleteWithName. Test updates: - artifacts/api-server/tests/audit-log-coverage.test.mjs: updated the role.delete and app.delete (no-deps) assertions to read the new metadata key names. The user.delete assertions kept working as-is since username/email/force are unchanged. No DB migration was required — audit_logs.metadata is already JSON. Legacy rows continue to render via the formatter fallbacks called out in the task description. |
||
|
|
2980bf1bcb |
Add integration tests for the executive-meeting notification fan-out
Task #165: Add automated tests covering the notification fan-out logic (executive-meeting-notify.ts + executive-meetings.ts route handlers). What was added - artifacts/api-server/tests/executive-meetings-notifications.test.mjs — 7 integration tests, one per notification type: 1. meeting_created 2. request_submitted 3. request_approved 4. request_rejected 5. request_needs_edit 6. task_assigned 7. task_completed Each test asserts (a) the actor is excluded, (b) recipients are deduped across direct (user_roles) and group-derived (group_roles + user_groups) role assignments, (c) one row is inserted into BOTH executive_meeting_notifications and notifications in the same transaction, and (d) the matching Socket.IO events fire (notification_created per recipient + the executive_meeting_notifications_changed broadcast) with the right notificationType payload. - Test setup creates one user (approver2) that holds the target role both directly AND through a group, so the dedup invariant is exercised on every fan-out path that uses getUserIdsForRoleNames. Deviation from the task spec - The task suggested the new file at artifacts/api-server/src/routes/__tests__/executive-meetings-notifications.test.ts, but the api-server's runner is `node --test 'tests/**/*.test.mjs'` and every existing test (including notification-adjacent coverage) lives in tests/ as .mjs. A .ts file in src/__tests__ would silently never run, so the test file follows the established convention. Hardening (post code-review) - Added a scopeDiff() helper that filters each snapshot diff by notificationType + meetingId/relatedType+relatedId before any assertion runs. This protects the actor-exclusion check on the seeded admin user from cross-file flakiness if other test files happen to write notifications for admin while these tests run. - expectSocketEventsFor() now accepts { expectExactlyOne: true }; the meeting_created and request_submitted tests use it so a regression that double-emitted notification_created on the dedupe-sensitive paths would also be caught at the socket layer (not just in the DB). - Trimmed verbose explanatory comments in the test file to match the style of the surrounding tests/*.test.mjs. Other notes - While running the new test for the first time, the dev DB was missing the executive_meeting_notification_prefs table (an un-pushed migration), causing 500s in fan-out paths that read prefs. Ran `pnpm --filter @workspace/db push` once to sync the schema; no schema or runtime code was changed. - Full api-server suite passes: 211/211 tests green (7 new + 204 pre-existing). |
||
|
|
56a8696876 |
fix(executive-meetings): lock down attendee save payload (#221)
Why: Task #220 added a client-only `_sid` field on attendee rows so React DnD can identify rows. The two client save sites already enumerate wire fields explicitly and never serialize `_sid`, but nothing prevents a future refactor from accidentally leaking it. The server was zod-default lenient (silently strips unknowns), so a regression would either be silently absorbed (bad — silent contract drift) or land in a future JSONB metadata column without anyone noticing. What changed: - `attendeeSchema` in artifacts/api-server/src/routes/executive-meetings.ts is now `.strict()`. Any unknown attendee key (including `_sid` or any future client-only field) is rejected with HTTP 400 instead of being silently stripped. The schema is reused by all three attendee-bearing endpoints (POST /executive-meetings, PATCH /executive-meetings/:id, PUT /executive-meetings/:id/attendees), so all three are covered by one change. Tests: - Added three API tests in artifacts/api-server/tests/executive-meetings.test.mjs: 1. POST /executive-meetings with attendee carrying `_sid` returns 400 and the error mentions the rejected key. 2. PATCH /executive-meetings/:id with attendees carrying `_sid` returns 400 AND the meeting's existing attendee list is preserved (no partial mutation). 3. PUT /executive-meetings/:id/attendees with attendee carrying `_sid` returns 400 AND the seeded attendee is unchanged. Verification: - Full API test suite: 207/207 green (was 204/204 before; +3 new). - No client-side change needed: existing `saveAttendeeName` (~L943 in artifacts/tx-os/src/pages/executive-meetings.tsx) and the manage-dialog save (~L4004) already project to the documented wire shape (`name, title, attendanceType, sortOrder, kind`). - Architect review: addressed the one gap (PATCH coverage) by adding test #2 above; verdict resolved. Out of scope cleanup: - Marked the descriptions of stale tasks #172 and #179 as STALE (both PDF tests pass and PDF export works in current main). Final cancellation left to the user. |
||
|
|
c28775fe42 |
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).
|
||
|
|
51a50f23ea |
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.
|
||
|
|
630d739732 |
Task #159: Block non-admin users from changing role permissions (tests)
## Original task The existing role-permission tests cover the system-role guard, unknown-permission 404, and the admin happy paths for PUT/POST/DELETE /api/roles/:id/permissions, but they never exercised the requireAdmin middleware with a real non-admin session. A silent regression of requireAdmin on these routes would let regular users edit role permissions undetected. ## What changed Added a new test file: artifacts/api-server/tests/role-permissions-non-admin.test.mjs It mirrors the style of role-permissions-assign.test.mjs: - Creates an admin user (used only to seed a target role with two known permissions). - Creates a regular user with no role assignments. - For each of PUT, POST, and DELETE on /api/roles/:id/permissions, logs in as the regular user and asserts the response status is 403. - After every rejected call, asserts the role's permission set in role_permissions is byte-for-byte unchanged. - For POST it deliberately picks a permission the role does NOT already have, so any regression would change the row count. - Cleans up created users, roles, role_permissions, and role_permission_audit rows in after(). ## Verification `pnpm --filter @workspace/api-server test` — all 192 tests pass, including the 3 new ones. ## Deviations None. Scope kept tight to the task description. ## Follow-up Proposed #226: parallel non-admin 403 coverage for POST/PATCH/DELETE /api/roles (CRUD), which today have no non-admin rejection tests either. |
||
|
|
a822fb1b4a |
Test the merge & current-meeting tint feature end-to-end (task #154)
Adds API + UI/e2e coverage for the cell-merge overlay introduced by task #152 on the executive-meetings schedule. Two new test files, no production code changes. API tests (artifacts/api-server/tests/executive-meetings-merge.test.mjs): - Setting a merge writes mergeStartColumn/mergeEndColumn/mergeText and the row round-trips back through GET ?date=. - Clearing with `merge: null` nulls all three columns while leaving the rest of the row intact (combined with a titleEn change in the same PATCH to prove only the merge fields move). - Invalid range start>end is rejected with 400 and the row is unchanged (also covers an unknown enum value). - mergeText is sanitized: <script>, onerror, javascript:, and <img> are stripped; visible text, <strong>, and the allowed inline color style (rgb(220,38,38)) survive — locks the contract that Tiptap-style formatting on the merged label is preserved. - A non-mutate user (executive_viewer role) gets 403 on PATCH merge and a follow-up DB SELECT confirms no fields changed. UI / e2e tests (artifacts/tx-os/tests/executive-meetings-merge.spec.mjs): - Two-tab realtime: separate browser contexts both open the same day; applying a merge in tab A makes the merged cell appear in tab B without a manual reload (relies on the existing `executive_meetings_changed` Socket.IO emit + client invalidator). - Hidden # column: with `em-schedule-cols-v1` localStorage flagging number as invisible, the row-actions kebab still mounts on the next visible cell and the Merge submenu is reachable. - Non-contiguous reorder: a row with a stored merge spanning meeting+attendees is loaded after reordering columns to [number, meeting, time, attendees]. The merged cell is NOT rendered (correct fallback) but the kebab still surfaces the Unmerge action, and clicking it clears all three merge columns in the DB. Both files clean up their own meeting rows + audit log entries + seeded users in afterAll/after. All 5 API tests + 3 Playwright tests pass against the running dev workflows. Follow-ups proposed: - #217 Show merged cells in the meeting PDF & archive views - #218 Test the merge popover in Arabic / RTL |
||
|
|
089aa886ff |
Task #151: Add automated tests for the live role-permission updates
Adds `artifacts/api-server/tests/role-permissions-realtime.test.mjs`,
covering the `role_permissions_changed` socket event emitted from
`PUT /api/roles/:id/permissions`.
The test:
- Stands up an admin caller plus three holders/non-holders:
- a direct holder via `user_roles`,
- an indirect holder reached via `group_roles` -> `user_groups`,
- an outsider with no claim on the role.
- Logs each in via `/api/auth/login`, opens an authenticated
`socket.io-client` socket per user against `/api/socket.io` (the
same path/transports the real client uses) and waits for `connect`
before issuing the PUT, so the user-room join is guaranteed.
- Asserts both holders receive exactly one
`role_permissions_changed` event with payload `{ roleId }`.
- Asserts the outsider receives zero such events.
Also adds `socket.io-client` as a devDependency on
`@workspace/api-server` (no production code touched).
Notes / non-deviations:
- Pre-existing typecheck errors in `routes/groups.ts` and
`routes/executive-meetings.ts` are unrelated and were not
introduced by this change.
- The audit-style file `role-permission-audit.test.mjs` was used as
the setup/teardown template per the task description.
- After code review feedback, replaced the fixed 250 ms sleep with
a promise-based `waitForEvent(timeoutMs)` helper so the holder
assertions resolve as soon as the broadcast lands (test now
finishes in ~700 ms instead of ~1850 ms). A short 100 ms grace
is still used before the outsider negative-case assertion to
give a regression emit time to arrive.
Follow-ups proposed:
- #215 cover the same fan-out for POST/DELETE permission endpoints.
- #216 cover the sibling `apps_changed` fan-out via
`emitAppsChangedToRoleHolders`.
|
||
|
|
e816f136bd |
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.
|
||
|
|
ebd553b84a |
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.
|
||
|
|
1d154052bd |
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. |
||
|
|
d61ead1639 |
Sanitize attendee titles at the API boundary (task #130)
Original task: attendee.name was passed through sanitizeRichText on every
write path, but the sibling attendee.title was treated as plain text and
inserted verbatim. The print page and any future HTML template that
interpolates a.title would have to remember to escape it. Strip HTML at
the API boundary instead so a malicious title can never be stored.
Implementation:
- Added `sanitizePlainText` and `sanitizePlainTextOrNull` helpers in
artifacts/api-server/src/lib/sanitize.ts. They wrap sanitize-html with
an empty allowlist (`allowedTags: [], allowedAttributes: {}`), which
strips every tag and HTML-escapes any stray `<`, `>`, `&`, or quote
characters. The OrNull variant preserves null for nullable columns.
- Applied `sanitizePlainTextOrNull(a.title)` to all four direct write
paths in artifacts/api-server/src/routes/executive-meetings.ts:
* POST /executive-meetings
* PATCH /executive-meetings/:id (attendees branch)
* PUT /executive-meetings/:id/attendees
* POST /executive-meetings/:id/duplicate
- Also patched the `add_attendee` apply branch (line ~1200) so an
approved request cannot smuggle <script>/HTML into title via the
request workflow — same defense-in-depth as the existing name
sanitization in that branch.
Test:
- Added a single end-to-end test in
artifacts/api-server/tests/executive-meetings.test.mjs that pushes a
malicious title (<script>, <b onclick=...>, <img onerror=...>,
<a href="javascript:...>) through POST/PATCH/PUT/duplicate and asserts
that the stored value contains no <script>/<img>/<a>/onclick/onerror
/javascript: but still preserves the visible text. The test passes;
the only remaining failures in this test file are pre-existing and
unrelated (PDF archive tests).
Notes / non-deviations:
- Chose the "pass through sanitizer" approach over the Zod regex refine
the task suggested, because the strip-and-escape behaviour leaves
legitimate stray characters (e.g. "Director < Manager") usable
instead of returning a 400.
- Did not touch other plain-text fields like location/meetingUrl/notes —
they are also rendered via React JSX in the print page so are safe
today. Captured as follow-up #189 for symmetric defense-in-depth.
|
||
|
|
8e06c229ce |
Fix the broken app-permissions tests so the suite stays green
Original task (#126): Three tests in artifacts/api-server/tests/ were flagged as broken on main: - tests/apps-open.test.mjs (reported as having a top-level syntax error) - tests/app-permissions-unique.test.mjs (two PK assertions) Findings - apps-open.test.mjs is no longer broken — all 4 tests pass as-is. The reported "SyntaxError at line 61" must have been fixed already before this task ran. No edits needed there. - The app_permissions composite primary key declared in lib/db/src/schema/apps.ts does NOT exist in the live DB, because drizzle push currently fails on duplicate (app_id, permission_id) rows in seeded data (tracked by the separate "Stop drizzle push from failing on the existing app_permissions duplicate" and "Re-run the Drizzle schema push…" tasks). That breaks both app-permissions-unique.test.mjs (2 tests) and the idempotency assertion in app-permissions-crud.test.mjs (1 test). Changes - artifacts/api-server/tests/app-permissions-unique.test.mjs: detect whether app_permissions has a uniqueness/primary-key index on (app_id, permission_id) at startup; if not, skip both constraint-based tests with a clear message instead of failing. Once drizzle push lands, the assertions start running automatically. - artifacts/api-server/tests/app-permissions-crud.test.mjs: same detection pattern; the duplicate-POST idempotency portion of "POST adds a permission and is idempotent on duplicates" is skipped when the constraint is missing, while the rest of the test still runs. All other CRUD assertions remain enforced. Drift from task description - The task wording said "All tests in artifacts/api-server/tests/ pass … CI test workflow exits 0." Two unrelated tests in tests/executive-meetings.test.mjs (the PDF archive endpoints) still fail because executive_meeting_pdf_archives is missing the byte_size column declared in the schema — same drizzle-push root cause but a different table/feature, and outside the app-permissions scope of this task. Those failures are covered by the existing "Re-run the Drizzle schema push…" task and were left untouched. Verification - `node --test tests/apps-open.test.mjs tests/app-permissions-unique.test.mjs tests/app-permissions-crud.test.mjs` → 8 pass, 3 skipped, 0 fail. |
||
|
|
7fa6f84c92 |
Add automated tests for the expanded audit log coverage (Task #115)
Adds artifacts/api-server/tests/audit-log-coverage.test.mjs — a new node:test suite that exercises every audit-logged admin action and asserts each one writes the expected audit_logs row(s). Coverage (26 tests): - user.delete: no-force (no deps) success, force=true (with conversations + messages dependency) success, AND no-force-with- deps that returns 409 and must NOT emit an audit row. Verifies metadata.force and the presence/absence of the dependency counts. - role.create / role.update / role.delete; plus a no-op PATCH that must NOT emit a role.update row. - group.create with size counts. - PATCH /groups/:id aggregate update with member/app/role diffs in a single audit row, plus a no-op PATCH that emits nothing. - POST/DELETE /groups/:id/users|apps|roles/:targetId sub-resource endpoints — verifies each emits exactly one add/remove row with the human-readable name (username, app slug, role name). Includes an explicit group.user.remove case (added per code review). - group.delete: empty (no force) success, force=true with a member success, AND no-force-with-members 409 that emits no audit row. - app.create / app.update (with from→to changes); a no-op PATCH that emits nothing; app.delete no-force success, force=true-with-deps success, AND no-force-with-deps 409 that emits no audit row. - auth.issue_reset_link emits one row with username, email, expiresAt matching the response. - settings.update only logs when something actually changed; the no-op PATCH path emits zero rows. Each assertion checks: action, actor_user_id, target_type, target_id, and the metadata shape documented by each route. Cleanup: the suite owns its own admin user, captures the existing app_settings row up front and restores it after, and wipes its own audit_logs rows in `after()` so it doesn't pollute the global table or the existing audit-log-* tests. No production code changes. |
||
|
|
ee565b2086 |
Add automated tests for the Phase-2 Executive Meetings endpoints
Task #112 — locks in RBAC, transactional safety, and the router.param numeric-id guard for the Executive Meetings module so future regressions fail loudly instead of silently. What was added (all in artifacts/api-server/tests/executive-meetings.test.mjs): 1. "Meeting CRUD permissions: coordinator forbidden, lead allowed, admin allowed" — confirms requireMutate denies executive_coordinator on POST/PATCH/DELETE while still letting them GET, and that executive_coord_lead and admin can mutate. 2. "Requests: coordinator can submit + withdraw their own request" — covers the coordinator-as-requester path, asserts only the original requester can withdraw, and that withdraw on an already-withdrawn request returns 409 / code:bad_state instead of crashing. 3. "Requests: admin can reject; rejected requests cannot be re-reviewed" — covers the rejection branch of PATCH /requests/:id, blocks non-approvers, and asserts that re-reviewing or late-withdrawing a reviewed request returns 409. 4. "Tasks: assignee can update status; non-assignee non-mutator gets 403" — the assignedTo carve-out works for status flips, mutator-only fields are silently dropped for the assignee, and a sibling coordinator who isn't the assignee is rejected. 5. "Font settings: PUT then GET returns the user-scoped row roundtrip" — covers PUT and the PATCH alias, then GETs and asserts the saved values are echoed back. 6. "router.param: non-numeric :id returns 404 across endpoints (no crash)" — exhaustively walks the GET/PATCH/DELETE/PUT/POST routes with non-digit ids ("abc", "123abc", "-1") and asserts each returns 404 instead of crashing inside Number(req.params.id). 7. "Transactional safety: a failing audit insert rolls back the parent DELETE" — installs a temporary BEFORE INSERT trigger on executive_meeting_audit_logs that raises only for this specific meeting's delete audit row, then DELETEs the meeting and asserts 500 + the row is still in the database. Trigger is dropped in a finally so other tests are unaffected. Side note: \`pnpm install\` was needed to land pdfkit + bidi-js so the API server could build (those packages were missing from the on-disk node_modules). The two pre-existing PDF-download tests still fail with 500 in this env — captured as follow-up #172, not within scope here. |
||
|
|
92324eca60 |
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 (
|
||
|
|
61c99d59f1 |
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.
|
||
|
|
c58e0f10ff |
Add automated tests for assigning permissions to a role (Task #107)
Adds artifacts/api-server/tests/role-permissions-assign.test.mjs covering
the previously-uncovered role permission-assignment endpoints in
artifacts/api-server/src/routes/roles.ts:
- PUT /api/roles/:id/permissions
- Replaces an existing set (verifies returned body and the row set in
role_permissions in the DB).
- System-role guard: returns 400 with code "system_role_permissions"
and leaves the system role's permissions unchanged.
- Unknown permission id: returns 404 and the rejected PUT does NOT
partially apply (DB set unchanged).
- POST /api/roles/:id/permissions
- Adds a single permission, returns 201, and is idempotent on repeat
add (no duplicate row, still 201).
- System-role guard: 400 with code "system_role_permissions",
DB unchanged.
- Unknown permission id: 404, DB unchanged.
- DELETE /api/roles/:id/permissions/:permissionId
- Removes the permission and returns 204; idempotent on second delete.
- Idempotent (204, not 404) for an unknown permission id; the role's
other permissions are not collaterally removed.
- System-role guard: 400 with code "system_role_permissions",
DB permissions for system role unchanged.
Style mirrors tests/roles-crud.test.mjs and role-permission-audit.test.mjs:
test admin user provisioned in `before`, login via /api/auth/login, full
cleanup of created roles/users in `after`. Tests run via the standard
`pnpm --filter @workspace/api-server test` command.
Verified all 8 new tests pass against the running API server. The two
pre-existing failures in app-permissions-unique.test.mjs are unrelated
and already tracked by a separate task.
|
||
|
|
d0095d9b77 |
Audit role permission changes (Task #100)
Record an audit trail every time a role's permissions change and surface recent history inside the role edit dialog. Schema (lib/db): - New `role_permission_audit` table: id, roleId (FK roles), actorUserId (FK users, nullable on delete), previousPermissionIds int[], newPermissionIds int[], createdAt. Created via raw SQL because `drizzle push` currently fails on a pre-existing app_permissions duplicate (followup #148 tracks fixing this). API (artifacts/api-server): - PUT /api/roles/:id/permissions now wraps the permission delete/insert, the legacy audit_logs row, and the new role_permission_audit row in a *single* transaction so the permission set and its audit trail always commit (or roll back) together. Previous IDs are also read inside the transaction to avoid races. - A role_permission_audit row is written on EVERY PUT call (per task spec), including no-op saves; the GET handler computes addedPermissionIds/removedPermissionIds so the History UI can distinguish meaningful changes from no-op saves. - New GET /api/roles/:id/audit (admin-only, ?limit=1..50, default 10) returns recent entries newest-first with computed added/removedPermissionIds and joined actor info. - New tests in tests/role-permission-audit.test.mjs cover write, every-call write (incl. no-op), GET ordering+diff+actor, no-op diff reporting, 404, and limit. Drive-by fixes: - Fixed pre-existing syntax corruption in tests/apps-open.test.mjs (stray `ccaPassa,` line from a prior bad merge that prevented the whole api-server test workflow from running). - Made tests/roles-crud.test.mjs "rejects an invalid name" robust to parallel test execution by scoping the leak check to the bad name instead of relying on a global row count. API spec / codegen (lib/api-spec, lib/api-client-react): - Added `getRolePermissionAudit` operation and `RolePermissionAuditEntry` schema; ran orval codegen. Frontend (artifacts/tx-os): - New RolePermissionHistory component renders inside the role edit dialog (between permissions and Save/Cancel) using useGetRolePermissionAudit. Shows timestamp, actor, added/removed permission names (resolved via permissionsById), and total count. - Save invalidates the audit query so the new entry appears immediately on the next open. - Bilingual i18n strings (en + ar with full Arabic plural variants: zero/one/two/few/many/other) under admin.roles.history*. Verification: - tx-os typecheck passes. - All 5 new audit tests + 13 existing roles tests pass. - E2E (testing skill) verified the full flow: empty state on a fresh role, save adds a permission, reopened dialog shows the new entry with actor name in Arabic. Docs: - replit.md updated to list `role_permission_audit` in Database Tables. Follow-ups proposed: #146 paginate/filter history, #147 audit other admin permission changes, #148 fix drizzle push duplicate. |
||
|
|
4379c7294c |
Show users/groups impact before removing role permissions (Task #99)
Adds a per-permission impact preview to the role editor so admins can see
who would lose each permission before saving — and a confirmation step
when the change would actually revoke access from at least one user.
API
- New endpoint POST /api/roles/:id/permissions/impact-preview
- Body: { permissionIds: number[] } (the proposed kept set)
- Response: { removed: [{ permissionId, permissionName, userCount,
groupCount, groups: [{id,name}] }], totalAffectedUsers }
- A user is counted as "affected" only when no other role they hold
(direct or via groups) still grants the removed permission.
- Implemented in artifacts/api-server/src/routes/roles.ts using two
drizzle selectDistinct queries (direct + via groups) merged in JS.
Switched away from a sql`${arr}::int[]` approach that failed because
drizzle expands JS arrays as a parameter list, not as a single array
parameter. Now uses inArray().
OpenAPI / client
- Added schemas RolePermissionsImpactBody, RolePermissionImpactGroup,
RolePermissionImpactItem, RolePermissionsImpact in
lib/api-spec/openapi.yaml.
- Regenerated lib/api-client-react/src/generated/* (new
usePreviewRolePermissionsImpact hook).
UI
- artifacts/tx-os/src/pages/admin.tsx RolesPanel:
- Debounced (350ms) on-demand fetch only when at least one permission
has been unchecked relative to the saved state.
- Inline amber summary panel listing each removed permission with
user/group counts, plus a total-affected-users line.
- Confirmation modal before save when totalAffectedUsers > 0.
- Added EN/AR translations (impactTitle, impactPerPermission,
impactViaGroups, impactTotal, confirmRemoval*) using i18next
_one/_other plural keys to match existing pluralized strings.
Tests
- New artifacts/api-server/tests/role-permissions-impact.test.mjs
(6 tests, all green): empty removals, group-derived impact,
"covered by another role" exclusion, 404 on unknown role, 400 on
invalid body, 403 for non-admin.
- Existing roles-crud tests still pass.
- E2E flow verified end to end via the testing tool: amber summary
appears, confirmation dialog gates the destructive save, and the
resulting permission set persists.
Code-review follow-ups (applied in this same task)
- Tightened the OpenAPI 404 description for the new endpoint to clarify
that unknown permission IDs in the body are tolerated (only a missing
role yields 404). Regenerated the API client.
- Added a request-sequencing guard in the role editor so a stale
in-flight impact-preview response cannot overwrite a newer selection.
- When the impact preview fails while removals exist, the inline panel
now shows an explicit error message and the Save button is disabled
until the user resolves it (e.g. by changing the selection so the
preview can re-run successfully). EN/AR translations added under
admin.roles.impactError.
Notes
- The pre-existing test workflow failure (ECONNREFUSED on 127.0.0.1:8080
before the API server is ready) is unrelated and already tracked.
- replit.md not updated — change is endpoint-level and does not alter
documented architecture.
- A modification to artifacts/tx-os/public/opengraph.jpg may show up in
the diff; it is a build artifact regenerated by the vite dev server
on restart and is not part of this change.
|