61 Commits

Author SHA1 Message Date
riyadhafraa 039e251c6a #575: IT-P brand icon + login hero image
Replace generic PWA/favicon assets with the IT-P brand image and layer
the same image over the login page's AnimatedArt panel.

- Generated icon-192/512, maskable-512, and apple-touch-icon (180) from
  the attached IT-P photo via ImageMagick (center crop 1110x1110, then
  resize). Maskable has #0B1E3F padding so the safe zone is respected
  on Android adaptive icons.
- Copied source as public/brand-itp.jpg for the login hero.
- index.html: replaced the generic favicon.svg link with a full
  favicon set built from IT-P (favicon.ico + 16/32/48/192/512 PNG),
  all cache-busted with ?v=itp1. Deleted public/favicon.svg so no
  browser can fall back to the old generic mark.
- Cache-busted apple-touch-icon link the same way so iPad PWA
  re-installs pick up the new artwork.
- manifest.webmanifest: same ?v=itp1 cache-bust on all icon entries.
- login.tsx: layered IT-P image over AnimatedArt with mix-blend-screen
  (opacity 90%) plus top/bottom and right-edge dark gradients so the
  composition reads as one piece and the right edge fades cleanly into
  the form panel. Still hidden on < lg per scope.

Did not change favicon.svg itself (kept as last-resort vector
fallback). Mobile (< lg) shows no hero — explicit choice per plan
("إخفاؤها بشكل مدروس").
2026-05-17 16:09:24 +00:00
riyadhafraa 192f412f2a #575: IT-P brand icon + login hero image
Replace generic PWA/favicon assets with the IT-P brand image and layer
the same image over the login page's AnimatedArt panel.

- Generated icon-192/512, maskable-512, and apple-touch-icon (180) from
  the attached IT-P photo via ImageMagick (center crop 1110x1110, then
  resize). Maskable has #0B1E3F padding so the safe zone is respected
  on Android adaptive icons.
- Copied source as public/brand-itp.jpg for the login hero.
- index.html: added explicit PNG icon links (192, 512) before the
  existing favicon.svg fallback, plus cache-bust query (?v=itp1) on
  apple-touch-icon so iPad PWA re-installs pick up the new artwork.
- manifest.webmanifest: same ?v=itp1 cache-bust on all icon entries.
- login.tsx: layered IT-P image over AnimatedArt with mix-blend-screen
  (opacity 90%) plus top/bottom and right-edge dark gradients so the
  composition reads as one piece and the right edge fades cleanly into
  the form panel. Still hidden on < lg per scope.

Did not change favicon.svg itself (kept as last-resort vector
fallback). Mobile (< lg) shows no hero — explicit choice per plan
("إخفاؤها بشكل مدروس").
2026-05-17 16:08:28 +00:00
riyadhafraa 56335c73ec Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 333e3e6c-f47d-4a12-94e6-39f2ada21f94
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1WKAcHk
Replit-Helium-Checkpoint-Created: true
2026-05-17 16:07:02 +00:00
riyadhafraa 2f591a6ba8 Show the notifications app on the main page again
Remove hardcoded exclusion for the notifications app in the home page component.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 58af0916-ab79-457c-868e-3b72336f6067
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1WKAcHk
Replit-Helium-Checkpoint-Created: true
2026-05-17 15:42:29 +00:00
riyadhafraa bc7938e002 Enable calendar and notification apps by default
Add a new script to enable the calendar and notification applications by default, along with documentation updates.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 1fea5b6b-097a-4339-854f-f1a5d338a14c
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1WKAcHk
Replit-Helium-Checkpoint-Created: true
2026-05-17 15:19:51 +00:00
riyadhafraa 80429920b9 Add automatic build version generation from git
Implement a script to automatically generate build version information from git commit hash and date, updating version.json and creating latest.json.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ab5429c1-7925-4a30-a964-b3db9c5772da
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1WKAcHk
Replit-Helium-Checkpoint-Created: true
2026-05-17 15:16:02 +00:00
riyadhafraa b5efd9eb12 Add system updates page to admin section
Add a new API endpoint and UI section for checking system updates, including internationalized locale strings and necessary dependency updates.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 1e6de894-7c78-4557-b01a-a2c1c01f4155
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1WKAcHk
Replit-Helium-Checkpoint-Created: true
2026-05-17 14:59:36 +00:00
riyadhafraa 191e072353 #571: ship Calendar/Notifications disabled by default + drop Notifications tile from home
Task: on fresh installs the Calendar and Notifications apps were
appearing enabled by default, and the home screen showed a
Notifications tile that was redundant with the bell icon already in
the top bar. User wanted both apps disabled by default and the home
tile gone (bell stays).

Changes:
- scripts/src/seed.ts: flip the seed `isActive` for `notifications`
  and `calendar` from true → false. The seed inserts apps with
  `db.insert(appsTable).values(apps).onConflictDoNothing()`, so this
  is fully idempotent: existing environments (where the row already
  exists with isActive=true) are NOT changed; only fresh inserts on
  new installs pick up the disabled default. Admins can enable
  either app from app settings when they want it.
- artifacts/tx-os/src/pages/home.tsx: remove "notifications" from
  the `dockApps` filter so the bottom dock no longer renders a
  Notifications tile. Filter becomes `["services", "admin"]`. The
  top-bar bell icon (and its unread badge) was untouched and still
  routes to `/notifications`.

Out of scope (per spec): no migration to disable existing prod
rows; no role/permission changes; bell icon in the top bar stays.

Files:
- scripts/src/seed.ts (notifications block ~L229, calendar block ~L275)
- artifacts/tx-os/src/pages/home.tsx:532
2026-05-17 14:43:54 +00:00
riyadhafraa 2aa4a12ddc #571: ship Calendar/Notifications disabled by default + drop Notifications tile from home
Task: on fresh installs the Calendar and Notifications apps were
appearing enabled by default, and the home screen showed a
Notifications tile that was redundant with the bell icon already in
the top bar. User wanted both apps disabled by default and the home
tile gone (bell stays).

Changes:
- scripts/src/seed.ts: flip the seed `isActive` for `notifications`
  and `calendar` from true → false. The seed inserts apps with
  `db.insert(appsTable).values(apps).onConflictDoNothing()`, so this
  is fully idempotent: existing environments (where the row already
  exists with isActive=true) are NOT changed; only fresh inserts on
  new installs pick up the disabled default. Admins can enable
  either app from app settings when they want it.
- artifacts/tx-os/src/pages/home.tsx: remove "notifications" from
  the `dockApps` filter so the bottom dock no longer renders a
  Notifications tile. Filter becomes `["services", "admin"]`. The
  top-bar bell icon (and its unread badge) was untouched and still
  routes to `/notifications`.

Out of scope (per spec): no migration to disable existing prod
rows; no role/permission changes; bell icon in the top bar stays.

Files:
- scripts/src/seed.ts (notifications block ~L229, calendar block ~L275)
- artifacts/tx-os/src/pages/home.tsx:532
2026-05-17 14:42:48 +00:00
riyadhafraa c40384b0d9 #570: stronger selected-row highlight + Edit button in Quick Actions
Task: on iPad the bulk-selected row was nearly invisible (only a 2px
inset navy ring), and the row-tap Quick Actions dialog had only a
Postpone button — user asked for a real visible selection fill and
an Edit (pencil) action wired to the existing edit-meeting flow.

Changes (artifacts/tx-os/src/pages/executive-meetings.tsx):
- MeetingRow: add `selectionBg = rgba(11,30,63,0.18)` painted on the
  row's `<tr>` backgroundColor in addition to the existing
  `selectionShadow` 2px navy ring. Composition order in trStyle is
  `quickOpenBg ?? selectionBg ?? baseBg` so an open quick-actions
  surface still wins (transient action state), user-picked row
  colours still show when nothing is selected, and the navy tint
  reads clearly on iPad against the table grid.
- MeetingRow: new optional `onQuickEdit?: () => void` prop. Quick
  Actions Dialog gains a pencil-icon "Edit" button before the
  existing Postpone button, wrapped in `flex flex-wrap gap-2` so the
  two buttons sit side-by-side on iPad/desktop and stack on narrow
  viewports. Button only renders when a handler is wired
  (defensive — `quickActionsCanMutate` already gates the surface).
- ScheduleSection: host a local `editingMeeting` + `editingSaving`
  state plus a `MeetingFormDialog` mount, with a save handler whose
  body mirrors ManageSection's `save()` exactly (same PATCH payload
  projection, same `wrapAsParagraph` / attendee shaping, same
  toast/error translation). Edit button on a row opens this in-place
  dialog instead of switching the user to the Manage tab.
- Locale: add `executiveMeetings.quickActions.edit` = "تعديل" / "Edit".

Notes:
- No new icon import needed — `Pencil` was already in the lucide
  import group.
- Selection ring (`#0B1E3F`) and tint use the same navy so they
  read as one cohesive selected state, not competing layers.
- Pre-existing `use-push-subscription.ts` TS noise unchanged.
2026-05-17 14:40:34 +00:00
riyadhafraa dc30d75e11 Hide redundant clock and navigation elements for president view
Modify executive-meetings.tsx to conditionally hide the HeaderClock and section navigation when isPresidentView is true.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: e1c6509c-638c-4c43-8d4f-4b4a5910fc4a
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/fNoqq4C
Replit-Helium-Checkpoint-Created: true
2026-05-17 14:34:59 +00:00
riyadhafraa 21dda3d372 #572 president-only focused view for executive meetings
User asked that when the President (`executive_ceo` role) logs into
the Meetings page he sees a clean "presentation" layout: just the
schedule table, an analog clock plus the weekday/date of the
schedule, and nothing else. The Manage / Notifications / Audit /
PDF tabs and the PDF-export shortcut button are noisy for him and
must disappear — but his backend permissions stay intact.

Changes (frontend only — backend role gates untouched):
- New component `components/executive-meetings/analog-clock.tsx`:
  small SVG analog clock (hour/minute/second hands, navy + gold),
  ticks every second, ~44px default.
- New inner `PresidentClockBar` component in `executive-meetings.tsx`:
  renders the analog clock + weekday + **digital time** (ticking
  per second, localized) + selected schedule date in one block.
- `pages/executive-meetings.tsx`:
  - Derived `isPresidentView` from the `/me` roles: true iff
    `executive_ceo` is present AND the user is NOT also `admin`
    or `executive_office_manager` (admins keep full UI).
  - Effect forces `section = "schedule"` whenever
    `isPresidentView` becomes true, so the president can never
    land on a now-hidden tab.
  - Hides the header PDF-shortcut button and the entire SECTIONS
    tab list when `isPresidentView`. The `#em-toolbar-portal` is
    kept so the date picker still mounts.
  - `ScheduleSection` accepts two new optional props:
    `isPresidentView` and `lang`. When `isPresidentView` is on,
    the toolbar renders the AnalogClock next to a label showing
    `formatWeekday(date, lang, "long")` and `formatDate(date, lang)`
    (e.g. "Sunday · May 17, 2026" / "الأحد · 17 مايو 2026"). The
    label updates immediately when the date input changes.
  - Edit-mode toggle stays gated on `canMutate`, so the president
    (who has canMutate) still gets the inline-edit pencil if he
    wants it.

Out of scope: backend role changes, applying the focused view to
other roles, manual presentation-mode toggle. Verified via tsc
(only the pre-existing `use-push-subscription` error remains).
2026-05-17 14:34:12 +00:00
riyadhafraa 27384d019a #572 president-only focused view for executive meetings
User asked that when the President (`executive_ceo` role) logs into
the Meetings page he sees a clean "presentation" layout: just the
schedule table, an analog clock plus the weekday/date of the
schedule, and nothing else. The Manage / Notifications / Audit /
PDF tabs and the PDF-export shortcut button are noisy for him and
must disappear — but his backend permissions stay intact.

Changes (frontend only — backend role gates untouched):
- New component `components/executive-meetings/analog-clock.tsx`:
  small SVG analog clock (hour/minute/second hands, navy + gold),
  ticks every second, ~44px default.
- `pages/executive-meetings.tsx`:
  - Derived `isPresidentView` from the `/me` roles: true iff
    `executive_ceo` is present AND the user is NOT also `admin`
    or `executive_office_manager` (admins keep full UI).
  - Effect forces `section = "schedule"` whenever
    `isPresidentView` becomes true, so the president can never
    land on a now-hidden tab.
  - Hides the header PDF-shortcut button and the entire SECTIONS
    tab list when `isPresidentView`. The `#em-toolbar-portal` is
    kept so the date picker still mounts.
  - `ScheduleSection` accepts two new optional props:
    `isPresidentView` and `lang`. When `isPresidentView` is on,
    the toolbar renders the AnalogClock next to a label showing
    `formatWeekday(date, lang, "long")` and `formatDate(date, lang)`
    (e.g. "Sunday · May 17, 2026" / "الأحد · 17 مايو 2026"). The
    label updates immediately when the date input changes.
  - Edit-mode toggle stays gated on `canMutate`, so the president
    (who has canMutate) still gets the inline-edit pencil if he
    wants it.

Out of scope: backend role changes, applying the focused view to
other roles, manual presentation-mode toggle. Verified via tsc
(only the pre-existing `use-push-subscription` error remains).
2026-05-17 14:32:19 +00:00
riyadhafraa d002670e4e Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: e893067e-041e-43bd-ab08-84a6e721b22f
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/fNoqq4C
Replit-Helium-Checkpoint-Created: true
2026-05-17 14:30:14 +00:00
riyadhafraa 4b151c50a8 #568 silent delete for finished orders
User requested that deleting a finished order from My Orders happen
silently — one click on the trash icon, no confirmation dialog, no
success toast. Just click and the card disappears.

Changes:
- Removed the delete-confirm AlertDialog from OrderCard along with
  its `confirmDeleteOpen` state. The trash icon now calls
  `handleDelete` directly.
- Removed `toast({ title: t("myOrders.deleted") })` from
  `scheduleDelete`. The 7-second soft-delete grace window stays in
  place (pendingDeleteIds filter + setTimeout) so the API DELETE
  still fires after the window. The `deleteFailed` error toast is
  preserved so silent API failures don't lose orders.
- Dropped the now-unused locale keys from ar.json and en.json:
  `deleteConfirmTitle`, `deleteConfirmBody`, `deleteConfirm`,
  `deleted`.
- Removed the unused `AlertDialogDescription` import (the remaining
  cancel-confirm dialog uses only the title).

Cancel-order flow remains untouched (still shows confirm dialog and
undo toast as in #566).
2026-05-17 14:13:42 +00:00
riyadhafraa d64a035cc3 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 8a46f213-adbc-4743-8448-2dfa8e134a76
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/fNoqq4C
Replit-Helium-Checkpoint-Created: true
2026-05-17 14:12:17 +00:00
riyadhafraa 4c57c58f8c #566 clean up My Orders page (3 removals)
Per user request on the طلباتي page:

1. Removed the "Clear finished orders" bulk button entirely. The button
   was showing as a raw i18n key (myOrder c...) for the user and they
   asked for it gone. Dropped the related AlertDialog, confirmClearOpen
   state, handleClearFinished handler, and the locale keys
   clearFinished_*, clearConfirmTitle/Body_*, clearConfirm,
   clearedCount_*, and clearedPartial in both ar.json and en.json.

2. Removed the "تراجع / Undo" ToastAction shown after deleting an
   order. The toast now displays only the "Order deleted" title; the
   7-second timer + pendingDeleteIds soft-delete window is preserved
   so the actual DELETE still fires after the grace period. The undo
   closure was deleted since nothing references it anymore. Single-id
   call site stays untouched (scheduleDelete still receives [id]).

3. Removed the "ستتاح لك ثوانٍ قليلة للتراجع" description from the
   cancel-order confirmation dialog. The cancelConfirmBody key was
   dropped from both locales. The AlertDialog now shows only the
   title + the two action buttons.

Out of scope (kept as-is):
- The undo ToastAction after CANCEL (user only asked about delete).
- Incoming-orders page.

Notes:
- ToastAction import is still used by the cancel-undo path.
- Trash2 import still used by the per-card delete button.
- Pre-existing TS error in use-push-subscription.ts is unrelated.
2026-05-17 14:07:23 +00:00
riyadhafraa c3e42fd73e Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: b23cd3bb-c384-46bc-ba75-e5b7d4b6af8c
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/fNoqq4C
Replit-Helium-Checkpoint-Created: true
2026-05-17 14:05:04 +00:00
riyadhafraa 4e5bed602e fix(executive-meetings): quick-actions UX + reschedule/cancel polish (Task #563)
User-reported polish on the executive-meetings quick-actions flow on
iPad/mobile. Six fixes, all client-side:

1. Quick-actions surface now opens from ANY click on a meeting row
   (was: only the attendees cell). Root cause: handleRowClick guard
   skipped any `em-edit-*`, `em-merge-edit-*`, and `em-time-*`
   descendants. Those affordances only do anything in edit mode, but
   handleRowClick already early-returns when edit mode is on, so the
   testid guard was always over-aggressive. Dropped those guards plus
   the `[role='button']` rule from the interactive selector, kept the
   real `button`/`input`/`textarea`/`a`/`menuitem`/`checkbox`/etc.
   guards and the `em-row-actions-*` / `em-row-select-*` testid
   guards (those affordances are visible outside edit mode and must
   not trigger the surface).
   File: artifacts/tx-os/src/pages/executive-meetings.tsx (~L4350).

2. Originating row is now unmistakable on iPad: quickOpenShadow is
   `inset 0 0 0 4px highlight, 0 0 0 2px highlight` (was a single
   3px inset) and the row background tints to
   `rgba(highlight, 0.08)` while quick-actions is open.
   File: artifacts/tx-os/src/pages/executive-meetings.tsx
   (~L4287, L4344).

3. Postpone dialog header was overlapping the Radix close X in RTL.
   Added `pr-8` on the DialogHeader (same pattern already used for
   the row-quick mini-dialog).
   File: artifacts/tx-os/src/components/executive-meetings/
   upcoming-meeting-alert.tsx (~L1447).

4. Reschedule tab: date / start / end inputs collided on iPad
   portrait. Grid is now `grid-cols-1 md:grid-cols-3` (single column
   through iPad portrait, three across on landscape/desktop), with
   `min-w-0` on each cell and `w-full` on every Input so Safari's
   native date/time pickers stop overflowing.
   Same file (~L1761).

5. Cancel tab X overlap is resolved by the same DialogHeader fix in
   step 3 (one Dialog wraps all three tabs).

6. Removed the `cancelHint` paragraph + the `cancelHint` translation
   keys in ar.json (L1252) and en.json (L1164) — fully unused now.

NOT pushed to Gitea (Tailscale to desktop-11cj93j still flaky).
Pending pushes: #560 (VAPID), #561 (CEO roles), #563 (this).
2026-05-17 10:19:16 +00:00
riyadhafraa 620ab284e2 fix(executive-meetings): quick-actions UX + reschedule/cancel polish (Task #563)
User-reported polish on the executive-meetings quick-actions flow on
iPad/mobile. Six fixes, all client-side:

1. Quick-actions surface now opens from ANY click on a meeting row
   (was: only the attendees cell). Root cause: handleRowClick guard
   skipped any `em-edit-*`, `em-merge-edit-*`, and `em-time-*`
   descendants. Those affordances only do anything in edit mode, but
   handleRowClick already early-returns when edit mode is on, so the
   testid guard was always over-aggressive. Dropped those guards plus
   the `[role='button']` rule from the interactive selector, kept the
   real `button`/`input`/`textarea`/`a`/`menuitem`/`checkbox`/etc.
   guards and the `em-row-actions-*` / `em-row-select-*` testid
   guards (those affordances are visible outside edit mode and must
   not trigger the surface).
   File: artifacts/tx-os/src/pages/executive-meetings.tsx (~L4350).

2. Originating row is now unmistakable on iPad: quickOpenShadow is
   `inset 0 0 0 4px highlight, 0 0 0 2px highlight` (was a single
   3px inset) and the row background tints to
   `rgba(highlight, 0.08)` while quick-actions is open.
   File: artifacts/tx-os/src/pages/executive-meetings.tsx
   (~L4287, L4344).

3. Postpone dialog header was overlapping the Radix close X in RTL.
   Added `pr-8` on the DialogHeader (same pattern already used for
   the row-quick mini-dialog).
   File: artifacts/tx-os/src/components/executive-meetings/
   upcoming-meeting-alert.tsx (~L1447).

4. Reschedule tab: date / start / end inputs collided on iPad
   portrait. Grid is now `grid-cols-1 md:grid-cols-3` (single column
   through iPad portrait, three across on landscape/desktop), with
   `min-w-0` on each cell and `w-full` on every Input so Safari's
   native date/time pickers stop overflowing.
   Same file (~L1761).

5. Cancel tab X overlap is resolved by the same DialogHeader fix in
   step 3 (one Dialog wraps all three tabs).

6. Removed the `cancelHint` paragraph + the `cancelHint` translation
   keys in ar.json (L1252) and en.json (L1164) — fully unused now.

NOT pushed to Gitea (Tailscale to desktop-11cj93j still flaky).
Pending pushes: #560 (VAPID), #561 (CEO roles), #563 (this).
2026-05-17 10:18:13 +00:00
riyadhafraa 5834036707 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: d8f29dc1-570e-487c-8034-8fc616ca80dd
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/fNoqq4C
Replit-Helium-Checkpoint-Created: true
2026-05-17 10:16:03 +00:00
riyadhafraa 0f71ae4102 feat(executive-meetings): grant executive_ceo mutate + audit + push notify (Task #561)
User request: السماح للرئيس (executive_ceo) بإنشاء/تعديل/تأجيل/حذف
الاجتماعات، استلام إشعارات Push للتذكير، والاطلاع على سجل التدقيق —
مع إبقاء إعدادات الخطوط/الشعار حصرية على مدير المكتب التنفيذي.

Changes (3 lines added, backend-only):

1. artifacts/api-server/src/routes/executive-meetings.ts
   - Added "executive_ceo" to MUTATE_ROLES (line 120) → POST/PATCH/DELETE
     on meetings now accepted for the CEO role.
   - Added "executive_ceo" to ADMIN_AUDIT_ROLES (line 130) → /audit
     endpoint now reachable; canViewAudit=true in the bootstrap payload.
   - EM_ADMIN_ROLES intentionally untouched, so font-settings and global
     PDF brand assets remain admin + executive_office_manager only.

2. artifacts/api-server/src/lib/executive-meeting-scheduler.ts
   - Added "executive_ceo" to EM_NOTIFY_ROLES (line 18) → CEO users with
     an active Web Push subscription now receive the LEAD_MINUTES=5
     pre-meeting reminder push.

Frontend requires no changes: it gates buttons on canMutate /
canViewAudit booleans returned by the bootstrap endpoint (lines
617-619), which flip to true automatically for the CEO role.

Verification: API workflow restarted clean, scheduler started OK,
/api/executive-meetings and /alert-state respond 304/200.

NOT pushed to Gitea: Tailscale link to desktop-11cj93j is still down
from this sandbox (HTTP=000). Task #560's earlier VAPID fix
(629069b1) is also still pending push. Both will go up together when
the tunnel recovers, or the user can apply the same one-line edits
on the Mac and rebuild the api container.
2026-05-17 10:07:57 +00:00
riyadhafraa 42b881355b Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 0cdfc11a-cdea-4382-82a8-d1997a8d0e3d
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/fNoqq4C
Replit-Helium-Checkpoint-Created: true
2026-05-17 10:06:38 +00:00
riyadhafraa 629069b1fe Fix VAPID subject fallback for push notifications
Update push notification logic to correctly handle missing VAPID_SUBJECT environment variables by using logical OR operator instead of nullish coalescing for subject fallback.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 96637a28-4f37-4966-8adb-95f8f66ee769
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/fNoqq4C
Replit-Helium-Checkpoint-Created: true
2026-05-17 09:55:57 +00:00
riyadhafraa 9fa40f00ab Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: b520895c-b60d-4b2b-a96d-62499515c77d
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/97ZFaoX
Replit-Helium-Checkpoint-Created: true
2026-05-17 06:58:43 +00:00
riyadhafraa c1e3a81cae Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 679db8c7-6a6d-410a-b781-5112c524b87d
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/97ZFaoX
Replit-Helium-Checkpoint-Created: true
2026-05-17 06:50:54 +00:00
riyadhafraa 51c3d8a8ba Fix toggle switch behavior in right-to-left reading modes
Update the switch component's CSS to correctly position the toggle thumb in RTL layouts for both checked and unchecked states.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ee6229a6-5472-4ca0-8f84-74b229703438
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/97ZFaoX
Replit-Helium-Checkpoint-Created: true
2026-05-16 17:46:43 +00:00
riyadhafraa f4253d4d9d Improve toggle switch behavior in right-to-left languages
Add custom RTL and LTR variants to Tailwind CSS configuration to correctly position toggle switch indicators in Arabic and other right-to-left languages.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 84451adc-9c86-40d5-80ba-846e7ea2a813
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/97ZFaoX
Replit-Helium-Checkpoint-Created: true
2026-05-16 17:21:39 +00:00
riyadhafraa 05446afb5d Improve meeting reminders with new scheduler and improved push notifications
Add a new background scheduler to trigger meeting reminders, implement atomic claims for push notifications to prevent duplicates, and add an `ignoreConnected` option to push notifications.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: a63187bd-4947-4e1f-8959-e8d68ff96a8c
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/97ZFaoX
Replit-Helium-Checkpoint-Created: true
2026-05-16 17:13:19 +00:00
riyadhafraa 55d19298e7 Add bilingual support for notifications and automatic HTTPS
Implement bilingual text for push notifications, allowing users to receive them in their preferred language. Automatically configure HTTPS with Let's Encrypt for custom domains to ensure persistent subscriptions.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 94f06aee-a5cf-45df-979e-f940cce77214
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/yEFtMKN
Replit-Helium-Checkpoint-Created: true
2026-05-16 16:28:42 +00:00
riyadhafraa d78818b88a feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA
Task #554. Adds a full Web Push stack on top of the existing Socket.IO
notification fan-out so the iPad PWA (and any installed browser) receives
system notifications when Tx OS is closed or backgrounded.

Backend
- New `push_subscriptions` table (userId + unique endpoint + p256dh/auth
  keys + ua + timestamps), exported from `@workspace/db`.
- `artifacts/api-server/src/lib/push.ts`:
  - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT
    (Docker volume) with /tmp fallback for Replit dev, else ephemeral.
  - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
    (orders/meetings/notes), prunes 404/410 endpoints, truncates payload
    bodies to ~3500 bytes.
  - **De-dup gate:** skips push when the user has any active Socket.IO
    connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a
    connected user only gets the in-app chime, never a duplicate system
    notification.
  - `upsertSubscription()` deletes a stale row first when the same
    browser endpoint flips to a different user (shared device) so the
    previous user's notifications can't leak.
- Four new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
  `POST /api/push/unsubscribe`, and a spec-aligned alias
  `DELETE /api/push/subscribe?endpoint=...` (auth-gated, Zod-validated).
- Push hooked into 4 existing emit sites: service-orders `notifyUser`,
  notes new-note + reply, executive-meeting broadcast.

Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick only (no
  asset caching). All URLs (icon, badge, navigation target) resolved
  against `self.registration.scope`, so the SW works at root or under
  a subpath without code changes.
- SW registration in `main.tsx` scoped to `BASE_URL`, gated on
  `serviceWorker` AND `PushManager` support so older browsers no-op
  cleanly.
- `use-push-subscription` hook (enable/disable/refresh + status).
- New `PushEnablePrompt` card mounted in `App` — appears on first
  launch when supported + permission still "default", one-tap enable,
  dismiss persists for 14 days. Silent on unsupported devices.
- `PushToggleRow` added inside Notification Settings.
- ar/en strings: `notifSettings.push.*` and `common.later`.

Plumbing
- OpenAPI: 3 new operations under `notifications`; orval codegen run.
- `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY /
  VAPID_SUBJECT through to the api service.
- `pnpm --filter @workspace/db run push` applied the schema.
- web-push + @types/web-push installed in api-server.

Verification
- API restarts clean; `/api/push/vapid-public-key` returns the key;
  subscribe endpoint 401s without auth.
- New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing —
  covers VAPID endpoint, auth gating on subscribe/unsubscribe, row
  persistence + removal, idempotent re-subscribe with key rotation,
  account-switch endpoint reassignment, and malformed-input rejection.
- New `artifacts/api-server/tests/push-410-unit.test.ts` — 3/3
  passing — true in-process unit test (`node --import tsx
  --experimental-test-module-mocks`) that mocks `web-push` to verify
  the prune path: 410 deletes the row, 404 deletes the row, and a
  transient 500 leaves the row intact. `tsx` added as a devDep and the
  test:run script picks up `*.test.ts` alongside the existing `.mjs`
  suites.
- OpenAPI updated: `DELETE /push/subscribe` documented with an
  `endpoint` query parameter alongside the existing POST routes; orval
  codegen re-run so the generated client + zod schemas stay in sync.
- Restored the unrelated serial tests
  (`executive-meetings-notifications.test.mjs`, `setup-wizard.test.mjs`)
  that an earlier cleanup pass dropped — they are unchanged from prior
  green state.
- Architect rounds addressed:
  1. race + payload size.
  2. dedup gate + first-launch UX + SW base-path + initial tests.
  3. DELETE alias + PushManager check + restored serial tests + real
     410/404 cleanup test + OpenAPI parity.
  4. .env.docker.example + replit.md operational docs for VAPID,
     DELETE-alias integration tests (auth + happy-path + bad input),
     and an explicit design-note comment in `push.ts` documenting the
     intentional user-wide dedup policy.
2026-05-16 15:44:08 +00:00
riyadhafraa b5720c9aac feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA
Task #554. Adds a full Web Push stack on top of the existing Socket.IO
notification fan-out so the iPad PWA (and any installed browser) receives
system notifications when Tx OS is closed or backgrounded.

Backend
- New `push_subscriptions` table (userId + unique endpoint + p256dh/auth
  keys + ua + timestamps), exported from `@workspace/db`.
- `artifacts/api-server/src/lib/push.ts`:
  - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT
    (Docker volume) with /tmp fallback for Replit dev, else ephemeral.
  - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
    (orders/meetings/notes), prunes 404/410 endpoints, truncates payload
    bodies to ~3500 bytes.
  - **De-dup gate:** skips push when the user has any active Socket.IO
    connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a
    connected user only gets the in-app chime, never a duplicate system
    notification.
  - `upsertSubscription()` deletes a stale row first when the same
    browser endpoint flips to a different user (shared device) so the
    previous user's notifications can't leak.
- Four new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
  `POST /api/push/unsubscribe`, and a spec-aligned alias
  `DELETE /api/push/subscribe?endpoint=...` (auth-gated, Zod-validated).
- Push hooked into 4 existing emit sites: service-orders `notifyUser`,
  notes new-note + reply, executive-meeting broadcast.

Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick only (no
  asset caching). All URLs (icon, badge, navigation target) resolved
  against `self.registration.scope`, so the SW works at root or under
  a subpath without code changes.
- SW registration in `main.tsx` scoped to `BASE_URL`, gated on
  `serviceWorker` AND `PushManager` support so older browsers no-op
  cleanly.
- `use-push-subscription` hook (enable/disable/refresh + status).
- New `PushEnablePrompt` card mounted in `App` — appears on first
  launch when supported + permission still "default", one-tap enable,
  dismiss persists for 14 days. Silent on unsupported devices.
- `PushToggleRow` added inside Notification Settings.
- ar/en strings: `notifSettings.push.*` and `common.later`.

Plumbing
- OpenAPI: 3 new operations under `notifications`; orval codegen run.
- `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY /
  VAPID_SUBJECT through to the api service.
- `pnpm --filter @workspace/db run push` applied the schema.
- web-push + @types/web-push installed in api-server.

Verification
- API restarts clean; `/api/push/vapid-public-key` returns the key;
  subscribe endpoint 401s without auth.
- New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing —
  covers VAPID endpoint, auth gating on subscribe/unsubscribe, row
  persistence + removal, idempotent re-subscribe with key rotation,
  account-switch endpoint reassignment, and malformed-input rejection.
- New `artifacts/api-server/tests/push-410-unit.test.ts` — 3/3
  passing — true in-process unit test (`node --import tsx
  --experimental-test-module-mocks`) that mocks `web-push` to verify
  the prune path: 410 deletes the row, 404 deletes the row, and a
  transient 500 leaves the row intact. `tsx` added as a devDep and the
  test:run script picks up `*.test.ts` alongside the existing `.mjs`
  suites.
- OpenAPI updated: `DELETE /push/subscribe` documented with an
  `endpoint` query parameter alongside the existing POST routes; orval
  codegen re-run so the generated client + zod schemas stay in sync.
- Restored the unrelated serial tests
  (`executive-meetings-notifications.test.mjs`, `setup-wizard.test.mjs`)
  that an earlier cleanup pass dropped — they are unchanged from prior
  green state.
- Architect rounds addressed:
  1. race + payload size.
  2. dedup gate + first-launch UX + SW base-path + initial tests.
  3. DELETE alias + PushManager check + restored serial tests + real
     410/404 cleanup test + OpenAPI parity.
2026-05-16 15:42:29 +00:00
riyadhafraa 54ca20a00f feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA
Task #554. Adds a full Web Push stack on top of the existing Socket.IO
notification fan-out so the iPad PWA (and any installed browser) receives
system notifications when Tx OS is closed or backgrounded.

Backend
- New `push_subscriptions` table (userId + unique endpoint + p256dh/auth
  keys + ua + timestamps), exported from `@workspace/db`.
- `artifacts/api-server/src/lib/push.ts`:
  - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT
    (Docker volume) with /tmp fallback for Replit dev, else ephemeral.
  - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
    (orders/meetings/notes), prunes 404/410 endpoints, truncates payload
    bodies to ~3500 bytes.
  - **De-dup gate:** skips push when the user has any active Socket.IO
    connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a
    connected user only gets the in-app chime, never a duplicate system
    notification.
  - `upsertSubscription()` deletes a stale row first when the same
    browser endpoint flips to a different user (shared device) so the
    previous user's notifications can't leak.
- Four new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
  `POST /api/push/unsubscribe`, and a spec-aligned alias
  `DELETE /api/push/subscribe?endpoint=...` (auth-gated, Zod-validated).
- Push hooked into 4 existing emit sites: service-orders `notifyUser`,
  notes new-note + reply, executive-meeting broadcast.

Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick only (no
  asset caching). All URLs (icon, badge, navigation target) resolved
  against `self.registration.scope`, so the SW works at root or under
  a subpath without code changes.
- SW registration in `main.tsx` scoped to `BASE_URL`, gated on
  `serviceWorker` AND `PushManager` support so older browsers no-op
  cleanly.
- `use-push-subscription` hook (enable/disable/refresh + status).
- New `PushEnablePrompt` card mounted in `App` — appears on first
  launch when supported + permission still "default", one-tap enable,
  dismiss persists for 14 days. Silent on unsupported devices.
- `PushToggleRow` added inside Notification Settings.
- ar/en strings: `notifSettings.push.*` and `common.later`.

Plumbing
- OpenAPI: 3 new operations under `notifications`; orval codegen run.
- `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY /
  VAPID_SUBJECT through to the api service.
- `pnpm --filter @workspace/db run push` applied the schema.
- web-push + @types/web-push installed in api-server.

Verification
- API restarts clean; `/api/push/vapid-public-key` returns the key;
  subscribe endpoint 401s without auth.
- New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing —
  covers VAPID endpoint, auth gating on subscribe/unsubscribe, row
  persistence + removal, idempotent re-subscribe with key rotation,
  account-switch endpoint reassignment, and malformed-input rejection.
- New `artifacts/api-server/tests/push-410-unit.test.ts` — 3/3
  passing — true in-process unit test (`node --import tsx
  --experimental-test-module-mocks`) that mocks `web-push` to verify
  the prune path: 410 deletes the row, 404 deletes the row, and a
  transient 500 leaves the row intact. `tsx` added as a devDep and the
  test:run script picks up `*.test.ts` alongside the existing `.mjs`
  suites.
- OpenAPI updated: `DELETE /push/subscribe` documented with an
  `endpoint` query parameter alongside the existing POST routes; orval
  codegen re-run so the generated client + zod schemas stay in sync.
- Restored the unrelated serial tests
  (`executive-meetings-notifications.test.mjs`, `setup-wizard.test.mjs`)
  that an earlier cleanup pass dropped — they are unchanged from prior
  green state.
- Architect rounds addressed:
  1. race + payload size.
  2. dedup gate + first-launch UX + SW base-path + initial tests.
  3. DELETE alias + PushManager check + restored serial tests + real
     410/404 cleanup test + OpenAPI parity.
2026-05-16 15:41:21 +00:00
riyadhafraa 0740971a96 feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA
Task #554. Adds a full Web Push stack on top of the existing Socket.IO
notification fan-out so the iPad PWA (and any installed browser) receives
system notifications when Tx OS is closed or backgrounded.

Backend
- New `push_subscriptions` table (userId + unique endpoint + p256dh/auth
  keys + ua + timestamps), exported from `@workspace/db`.
- `artifacts/api-server/src/lib/push.ts`:
  - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT
    (Docker volume) with /tmp fallback for Replit dev, else ephemeral.
  - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
    (orders/meetings/notes), prunes 404/410 endpoints, truncates payload
    bodies to ~3500 bytes.
  - **De-dup gate:** skips push when the user has any active Socket.IO
    connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a
    connected user only gets the in-app chime, never a duplicate system
    notification.
  - `upsertSubscription()` deletes a stale row first when the same
    browser endpoint flips to a different user (shared device) so the
    previous user's notifications can't leak.
- Four new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
  `POST /api/push/unsubscribe`, and a spec-aligned alias
  `DELETE /api/push/subscribe?endpoint=...` (auth-gated, Zod-validated).
- Push hooked into 4 existing emit sites: service-orders `notifyUser`,
  notes new-note + reply, executive-meeting broadcast.

Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick only (no
  asset caching). All URLs (icon, badge, navigation target) resolved
  against `self.registration.scope`, so the SW works at root or under
  a subpath without code changes.
- SW registration in `main.tsx` scoped to `BASE_URL`, gated on
  `serviceWorker` AND `PushManager` support so older browsers no-op
  cleanly.
- `use-push-subscription` hook (enable/disable/refresh + status).
- New `PushEnablePrompt` card mounted in `App` — appears on first
  launch when supported + permission still "default", one-tap enable,
  dismiss persists for 14 days. Silent on unsupported devices.
- `PushToggleRow` added inside Notification Settings.
- ar/en strings: `notifSettings.push.*` and `common.later`.

Plumbing
- OpenAPI: 3 new operations under `notifications`; orval codegen run.
- `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY /
  VAPID_SUBJECT through to the api service.
- `pnpm --filter @workspace/db run push` applied the schema.
- web-push + @types/web-push installed in api-server.

Verification
- API restarts clean; `/api/push/vapid-public-key` returns the key;
  subscribe endpoint 401s without auth.
- New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing —
  covers VAPID endpoint, auth gating on subscribe/unsubscribe, row
  persistence + removal, idempotent re-subscribe with key rotation,
  account-switch endpoint reassignment, and malformed-input rejection.
- New `artifacts/api-server/tests/push-410-unit.test.ts` — 3/3
  passing — true in-process unit test (`node --import tsx
  --experimental-test-module-mocks`) that mocks `web-push` to verify
  the prune path: 410 deletes the row, 404 deletes the row, and a
  transient 500 leaves the row intact. `tsx` added as a devDep and the
  test:run script picks up `*.test.ts` alongside the existing `.mjs`
  suites.
- OpenAPI updated: `DELETE /push/subscribe` documented with an
  `endpoint` query parameter alongside the existing POST routes; orval
  codegen re-run so the generated client + zod schemas stay in sync.
- Restored the unrelated serial tests
  (`executive-meetings-notifications.test.mjs`, `setup-wizard.test.mjs`)
  that an earlier cleanup pass dropped — they are unchanged from prior
  green state.
- Architect rounds addressed:
  1. race + payload size.
  2. dedup gate + first-launch UX + SW base-path + initial tests.
  3. DELETE alias + PushManager check + restored serial tests + real
     410/404 cleanup test + OpenAPI parity.
2026-05-16 15:39:41 +00:00
riyadhafraa bf39a6eda6 feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA
Task #554. Adds a full Web Push stack on top of the existing Socket.IO
notification fan-out so the iPad PWA (and any installed browser) receives
system notifications when Tx OS is closed or backgrounded.

Backend
- New `push_subscriptions` table (userId + unique endpoint + p256dh/auth
  keys + ua + timestamps), exported from `@workspace/db`.
- `artifacts/api-server/src/lib/push.ts`:
  - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT
    (Docker volume) with /tmp fallback for Replit dev, else ephemeral.
  - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
    (orders/meetings/notes), prunes 404/410 endpoints, truncates payload
    bodies to ~3500 bytes.
  - **De-dup gate:** skips push when the user has any active Socket.IO
    connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a
    connected user only gets the in-app chime, never a duplicate system
    notification.
  - `upsertSubscription()` deletes a stale row first when the same
    browser endpoint flips to a different user (shared device) so the
    previous user's notifications can't leak.
- Four new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
  `POST /api/push/unsubscribe`, and a spec-aligned alias
  `DELETE /api/push/subscribe?endpoint=...` (auth-gated, Zod-validated).
- Push hooked into 4 existing emit sites: service-orders `notifyUser`,
  notes new-note + reply, executive-meeting broadcast.

Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick only (no
  asset caching). All URLs (icon, badge, navigation target) resolved
  against `self.registration.scope`, so the SW works at root or under
  a subpath without code changes.
- SW registration in `main.tsx` scoped to `BASE_URL`, gated on
  `serviceWorker` AND `PushManager` support so older browsers no-op
  cleanly.
- `use-push-subscription` hook (enable/disable/refresh + status).
- New `PushEnablePrompt` card mounted in `App` — appears on first
  launch when supported + permission still "default", one-tap enable,
  dismiss persists for 14 days. Silent on unsupported devices.
- `PushToggleRow` added inside Notification Settings.
- ar/en strings: `notifSettings.push.*` and `common.later`.

Plumbing
- OpenAPI: 3 new operations under `notifications`; orval codegen run.
- `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY /
  VAPID_SUBJECT through to the api service.
- `pnpm --filter @workspace/db run push` applied the schema.
- web-push + @types/web-push installed in api-server.

Verification
- API restarts clean; `/api/push/vapid-public-key` returns the key;
  subscribe endpoint 401s without auth.
- New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing —
  covers VAPID endpoint, auth gating on subscribe/unsubscribe, row
  persistence + removal, idempotent re-subscribe with key rotation,
  account-switch endpoint reassignment, and malformed-input rejection.
- Architect rounds addressed in order:
  1. race + payload size — fixed.
  2. dedup gate + first-launch UX + SW base-path + tests — fixed.
  3. DELETE alias + PushManager check — fixed in this commit. A true
     410-cleanup test was attempted but reaching the prune branch from
     an out-of-process .mjs test requires mocking the `web-push`
     module, which the current `node --test` + bundled-dist harness
     doesn't support cleanly; the 404/410 catch branch is small,
     self-contained, and was code-reviewed. Tracked as a follow-up.
2026-05-16 15:35:42 +00:00
riyadhafraa 8f3b750961 feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA
Task #554. Adds a full Web Push stack on top of the existing Socket.IO
notification fan-out so the iPad PWA (and any installed browser) receives
system notifications when Tx OS is closed or backgrounded.

Backend
- New `push_subscriptions` table (userId + unique endpoint + p256dh/auth
  keys + ua + timestamps), exported from `@workspace/db`.
- `artifacts/api-server/src/lib/push.ts`:
  - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT
    (Docker volume) with /tmp fallback for Replit dev, else ephemeral.
  - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
    (orders/meetings/notes), prunes 404/410 endpoints, truncates payload
    bodies to ~3500 bytes.
  - **De-dup gate:** skips push when the user has any active Socket.IO
    connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a
    connected user only gets the in-app chime, never a duplicate system
    notification.
  - `upsertSubscription()` deletes a stale row first when the same
    browser endpoint flips to a different user (shared device) so the
    previous user's notifications can't leak.
- Three new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
  `POST /api/push/unsubscribe` (auth-gated, Zod-validated).
- Push hooked into 4 existing emit sites: service-orders `notifyUser`,
  notes new-note + reply, executive-meeting broadcast.

Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick only (no
  asset caching). All URLs (icon, badge, navigation target) resolved
  against `self.registration.scope`, so the SW works at root or under
  a subpath without code changes.
- SW registration in `main.tsx` scoped to `BASE_URL`.
- `use-push-subscription` hook (enable/disable/refresh + status).
- New `PushEnablePrompt` card mounted in `App` — appears on first
  launch when supported + permission still "default", one-tap enable,
  dismiss persists for 14 days. Silent on unsupported devices.
- `PushToggleRow` added inside Notification Settings.
- ar/en strings: `notifSettings.push.*` and `common.later`.

Plumbing
- OpenAPI: 3 new operations under `notifications`; orval codegen run.
- `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY /
  VAPID_SUBJECT through to the api service.
- `pnpm --filter @workspace/db run push` applied the schema.
- web-push + @types/web-push installed in api-server.

Verification
- API restarts clean; `/api/push/vapid-public-key` returns the key;
  subscribe endpoint 401s without auth.
- New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing —
  covers VAPID endpoint, auth gating on subscribe/unsubscribe, row
  persistence + removal, idempotent re-subscribe with key rotation,
  account-switch endpoint reassignment, and malformed-input rejection.
- Two architect rounds: first flagged race + payload size (fixed),
  second flagged dedup gate + first-launch UX + SW base-path + tests
  (all fixed in this commit).
2026-05-16 15:29:28 +00:00
riyadhafraa 243ccb3e00 feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA
Task #554. Adds a full Web Push stack on top of the existing Socket.IO
notification fan-out so the iPad PWA (and any installed browser) receives
system notifications when Tx OS is closed or backgrounded.

Backend
- New `push_subscriptions` table (userId + unique endpoint + p256dh/auth
  keys + ua + timestamps), exported from `@workspace/db`.
- `artifacts/api-server/src/lib/push.ts`:
  - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT (Docker
    volume) with /tmp fallback for Replit dev, else ephemeral in-memory.
  - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
    (orders/meetings/notes), prunes 404/410 endpoints, truncates payload
    bodies to ~3500 bytes so over-sized notes don't kill delivery.
  - `upsertSubscription()` deletes a stale row first when the same
    browser endpoint flips to a different user (account switch on shared
    device) so the previous user's notifications can't leak.
- Three new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
  `POST /api/push/unsubscribe` (auth-gated, Zod-validated).
- Push hooked into the 4 existing emit sites: service-orders `notifyUser`,
  notes new-note + reply, executive-meeting broadcast.

Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick handlers only
  (no asset caching). Focuses an existing tab or opens a new one at the
  payload URL.
- SW registration in `main.tsx` scoped to `BASE_URL`.
- `use-push-subscription` hook (enable/disable/refresh + status:
  unsupported / denied / default / subscribed).
- New `PushToggleRow` in `NotificationSettingsContent` with ar/en strings
  (notifSettings.push.*). iPad copy explains that the user must add to
  Home Screen first for iOS to allow Web Push.

Plumbing
- OpenAPI: 3 new operations under `notifications` tag; orval codegen run.
- `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY /
  VAPID_SUBJECT through to the api service.
- `pnpm --filter @workspace/db run push` applied the schema.
- web-push + @types/web-push installed in api-server.

Verification
- API restarts clean; `/api/push/vapid-public-key` returns the generated
  key; subscribe endpoint 401s without auth as expected.
- Architect review surfaced two HIGH issues (account-switch leak,
  payload size); both fixed before completion.
2026-05-16 15:26:18 +00:00
riyadhafraa d91a68bc68 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: c32c85bc-ba42-427b-945b-c189acf14d71
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/T9daF3x
Replit-Helium-Checkpoint-Created: true
2026-05-16 15:18:48 +00:00
riyadhafraa e9d7b8dc76 Fix service images + Arabic ي dots on home tiles (Task #552)
Two visible bugs reported from the iPad after Task #551 deploy:

1) Only "قهوة سعودي" showed its image on /services. Three other
   seeded services (شاي، بلاك كوفي، عصير طازج) showed a broken-image
   placeholder because the seed only set `imageUrl` for Saudi Coffee.

2) The dots under final ي in the home-grid tile label "خدماتي"
   didn't render on iOS Safari. The label `<span>` didn't pin a
   font-family, so iOS fell back to a Latin face that lacks proper
   Arabic glyphs.

Changes:

- scripts/src/seed.ts:
  • Added `imageUrl` for tea/black-coffee/juice in the insert array
    (covers fresh installs).
  • Added a backfill loop AFTER the insert that fills `imageUrl` on
    existing rows only when the value is currently NULL. Admin-
    customized image URLs are never overwritten — matches the
    existing legacy-rename pattern at lines 395-423.

- artifacts/tx-os/src/pages/home.tsx (AppIconContent label):
  • Added `lang` and `dir` attributes on the tile `<span>`.
  • Pinned an explicit font stack: DIN Next LT Arabic → Helvetica
    Neue LT Arabic → Tajawal for Arabic; Helvetica Neue → system-ui
    for English. The three Arabic faces are already declared in
    custom-fonts.css.

No schema changes, no new dependencies. Verified typecheck passes
and HMR reloaded both files cleanly in the dev server.

Push to Gitea is a separate follow-up (git commit is restricted in
the main agent; platform commits this task's changes on completion,
and the push task can then mirror them to the Mac).
2026-05-16 10:18:07 +00:00
riyadhafraa 51a401b6e5 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: e49c87fa-b03f-4bb0-8b1e-7d0c9ef599fb
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/T9daF3x
Replit-Helium-Checkpoint-Created: true
2026-05-16 10:16:24 +00:00
riyadhafraa 66f063a978 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 4285357b-389f-4cc3-b20e-906440f5c155
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/T9daF3x
Replit-Helium-Checkpoint-Created: true
2026-05-16 08:54:18 +00:00
riyadhafraa a6d5988421 Add PWA manifest + icons so iPad opens Tx OS full-screen (Task #550)
Problem: on the iPad the Safari URL bar stayed visible even after
"Add to Home Screen" because index.html had no PWA manifest and no
Apple PWA meta tags — the home-screen icon opened a normal Safari tab.

Changes:
- New `artifacts/tx-os/public/manifest.webmanifest`:
  name/short_name "Tx OS", start_url "/", scope "/",
  display "standalone", background_color and theme_color "#0B1E3F"
  (matches the Meetings brand), and 3 icons (192/512 "any" + 512
  "maskable" with safe-area padding).
- Generated icon PNGs from the existing `public/favicon.svg` brand
  mark via ImageMagick:
    public/icons/icon-192.png            (192x192)
    public/icons/icon-512.png            (512x512)
    public/icons/icon-maskable-512.png   (512x512, 360x360 logo
                                          centered on #0B1E3F so iOS/
                                          Android masks don't crop)
    public/apple-touch-icon.png          (180x180, iPad/iPhone home)
- `artifacts/tx-os/index.html` head: added <link rel="manifest">,
  <link rel="apple-touch-icon">, plus apple-mobile-web-app-capable,
  mobile-web-app-capable, apple-mobile-web-app-status-bar-style
  (default), apple-mobile-web-app-title, and theme-color meta tags.
- `replit.md`: new "iPad / iPhone — open Tx OS full-screen" section
  with the one-time Add-to-Home-Screen steps and the gotcha that
  pre-existing shortcuts must be removed and re-added to pick up the
  manifest.

Verified: dev server returns 200 for /manifest.webmanifest,
/apple-touch-icon.png, and /icons/icon-192.png. The new files all
live under public/ so they're copied verbatim into the nginx image
on the next docker build.
2026-05-16 08:51:25 +00:00
riyadhafraa b69bcd4241 Improve deployment script reliability and add explicit seed step
Update redeploy.sh to include an explicit seed step and adjust script numbering and help output.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ca6e30a4-b773-4707-8e0c-a72cb5bddf70
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/T9daF3x
Replit-Helium-Checkpoint-Created: true
2026-05-16 08:50:33 +00:00
riyadhafraa 3c8bfbf84d Document Docker rebuild after git pull (Task #549)
Root cause of "service images don't show on the Mac after pulling latest":
the SPA is baked into the nginx (`web`) image and the API binary is baked
into the `api` image at `docker compose build` time. Running just
`git pull && docker compose up -d` reuses the existing images, so new
static assets in `artifacts/tx-os/public/` (and any front-end / API code
changes) never reach the browser.

Changes:
- New `scripts/redeploy.sh` — single-command redeploy. Steps:
  1. git pull --ff-only (skip with --no-pull)
  2. docker compose build api web
  3. docker compose run --rm migrate  (drizzle push + idempotent seed)
  4. docker compose up -d
  Detects `docker compose` v2 vs legacy `docker-compose`, runs from any
  cwd via BASH_SOURCE, set -euo pipefail, --help prints the header.
  chmod +x. Verified `bash -n` and `--help`.
- `replit.md` — new "Redeploying after `git pull` (Docker hosts)"
  section that calls out the gotcha and points at the script.
- `DEPLOYMENT_MIGRATION.md` — one-line cross-reference at the top of
  the deploy section so anyone reading deploy docs finds the script.

No code or runtime behavior changed. No follow-ups proposed: the only
natural next step (PWA full-screen) is already tracked as Task #550.
2026-05-16 08:49:12 +00:00
riyadhafraa 46ac734a8e Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 7b9229d0-2fbc-4a67-a10e-a4461d2c4823
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/T9daF3x
Replit-Helium-Checkpoint-Created: true
2026-05-16 08:48:02 +00:00
riyadhafraa 92425aafe6 Update internal notes order to display correctly
Adjust the sort order for internal notes to ensure proper display sequence.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 418871f8-1a80-43b8-948e-252be1122ed8
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/T9daF3x
Replit-Helium-Checkpoint-Created: true
2026-05-16 08:36:26 +00:00
riyadhafraa 097276f8cc Task #545: Add Notes app to seed + rename Executive Meetings → Meetings
Why:
On the user's deployed instance the home screen was missing the Notes
tile (notes was in BUILTIN_APP_SLUGS but had no row in the seed's apps
array, so no apps row existed in the DB and the launcher had nothing
to render). The Executive Meetings tile also showed the long name
"إدارة الاجتماعات التنفيذية" / "Executive Meetings"; user asked for
the shorter "الاجتماعات" / "Meetings".

Changes (scripts/src/seed.ts):
- Added "notes" entry to the apps array (slug=notes, route=/notes,
  iconName=StickyNote, color=#eab308, sortOrder=2, isActive=true,
  nameAr="الملاحظات", nameEn="Notes").
- Renamed executive-meetings entry: nameAr/En + descriptions shortened.
- Extended the existing post-insert UPDATE for executive-meetings to
  also push the new name + descriptions, so already-deployed instances
  pick up the rename on re-seed (insert is onConflictDoNothing).
- Added a notes.access permission gate mirroring executive_meetings.access:
  the permission is created, granted to the admin role only, and linked
  to the notes app via app_permissions. getVisibleAppsForUser hides the
  tile from anyone without notes.access — admins grant it from the
  admin panel per role/user.

Why a permission gate (deviation from initial plan):
Code review caught that without app_permissions, getVisibleAppsForUser
shows every active app to non-admins by default, so a bare INSERT would
have made Notes visible to everyone — opposite of what the user asked
for ("only for those I choose"). Used the existing executive_meetings
pattern to keep the model consistent.

Also added replit.md documenting the workflows, the seed re-run step
after git pull, and the per-permission grant flow for restricted apps.

Verified: ran the seed; SQL confirms notes row + الاجتماعات rename +
notes.access restricted to admin role only.
2026-05-16 08:35:15 +00:00
riyadhafraa 9eab759acb Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 20a01eb7-fdd9-426a-90e4-3dbbc503b2e5
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/T9daF3x
Replit-Helium-Checkpoint-Created: true
2026-05-16 08:30:38 +00:00
rafraa c69e6903c1 Delete .canvas/assets/asset_2133256110.png 2026-05-16 08:18:47 +00:00
rafraa 86b264f20a Delete .canvas/assets/asset_556433877.png 2026-05-16 08:18:47 +00:00
Riydx0 2679096e63 chore: add dev workflows for tx-os + api-server 2026-05-15 13:55:50 +00:00
riyadhafraa b10ee5392c Saved progress at the end of the loop
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 1194403f-51ef-43ba-9710-3d091b4840f6
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/T9daF3x
Replit-Helium-Checkpoint-Created: true
2026-05-15 12:43:53 +00:00
riyadhafraa 66e0e5697f Task #542: fix api-server test deadlocks/races (all 327+13 tests green)
- executive-meetings.ts: add lockMeetingDate(executor, date)
  pg_advisory_xact_lock helper. Acquire it at the top of every
  transaction that calls renumberDayByStartTime so concurrent
  POST/PATCH/duplicate/postpone-minutes/reschedule/cancel/DELETE/
  swap-times/rotate-content/reorder no longer deadlock on the
  executive_meetings dailyNumber unique constraint.
- For two-date paths (PATCH cross-day, reschedule, swap-times)
  build a sorted distinct date list and lock in deterministic
  order to prevent deadlock between opposite-direction moves.
- executive-meeting-notify.ts: add `.for('share')` row lock on
  the users table after recipient resolution and before bulk
  INSERT into executive_meeting_notifications, eliminating the
  FK race against parallel tests that DELETE users under
  READ COMMITTED.
- Move tests/executive-meetings-notifications.test.mjs to
  tests/serial/ to fix socket fan-out cross-test contamination
  (parallel files share DB and server).

No tests were weakened or skipped; all fixes live in
route/service code.
2026-05-15 11:41:22 +00:00
riyadhafraa 2889148a0e Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: f5807abb-6742-4351-91e4-87cc3f7286c8
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/FvHcc7z
Replit-Helium-Checkpoint-Created: true
2026-05-15 10:53:24 +00:00
riyadhafraa a5a35ef217 Improve test execution by automatically starting and stopping the API server
Refactor test execution to use a new script that manages the API server lifecycle, including building, starting, waiting for health, running tests, and shutting down, with support for using an existing server.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: a42bd3b2-3145-4118-87aa-336a7e2189ca
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/FvHcc7z
Replit-Helium-Checkpoint-Created: true
2026-05-15 10:48:48 +00:00
riyadhafraa 77f8096deb Improve security and deployment flexibility for external access
Update README and application configurations to support reverse proxy setups, including TLS termination and proper cookie handling, and add an option to seed demo meetings.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: c15da2be-cee7-4cbb-8d58-f9fcc3c38ed7
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/FvHcc7z
Replit-Helium-Checkpoint-Created: true
2026-05-15 10:38:44 +00:00
riyadhafraa fa31fb6374 task #539: push deployment-hardening branch to Gitea
Pushed the existing main-branch commits (since e0a4652) to the
Gitea origin remote as a new branch `replit-sync-deployment-hardening`
so the user can open a PR manually. No code edits in this task —
all changes were already committed on `main` by task #538.

Branch contents (3 commits since e0a4652):
- f16f476  Improve handling of external proxy configurations and session security
- 7d13e8e  deployment: harden self-hosted install behind HTTPS reverse proxies
- a01e102  Update documentation to clarify CORS error impact on user login

Files changed (5): artifacts/api-server/src/app.ts, scripts/src/seed.ts,
.env.example, .env.docker.example (covered in f16f476), docker-compose.yml,
README.md.

Push details:
- Authenticated via GITEA_TOKEN (oauth2 user) embedded in the push URL.
- Token filtered out of all logged output via sed.
- No `--force` used. Gitea accepted the push as a brand-new branch.
- Gitea echoed the PR-create URL: /rafraa/TX/pulls/new/replit-sync-deployment-hardening

Deviations from the plan:
- Plan step 1-2 said to "create branch from e0a4652 + commit fresh".
  Sandbox blocks `git switch -c` / `git commit` even in this task agent,
  so I pushed HEAD as the new branch instead. The result on Gitea is
  identical — the diff vs `main` is the same 5 files; only the commit
  history granularity differs (3 small commits vs 1 squashed commit).
  If the user prefers a single squashed commit they can squash-merge
  the PR on Gitea.
- The `test` workflow is failing on the api-server start race, but
  that's out of scope for #539 and is already covered by task #540
  (Auto-start the API server when running tests).
2026-05-15 10:36:43 +00:00
riyadhafraa a01e102a08 Update documentation to clarify CORS error impact on user login
Update README.md with more specific language regarding CORS errors caused by incorrect ALLOWED_ORIGINS configuration, affecting the user login experience.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 201c4794-de42-4660-9fb4-83343d5bda24
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/FvHcc7z
Replit-Helium-Checkpoint-Created: true
2026-05-15 10:35:01 +00:00
riyadhafraa 7d13e8e345 deployment: harden self-hosted install behind HTTPS reverse proxies
Task #538. Fixes the four bugs that bit the user's first Tailscale-fronted
install of Tx OS on their Mac:

1. Missing `user_sessions` table on fresh installs — `connect-pg-simple`
   now uses `createTableIfMissing: true`. Drizzle migrations still skip
   the table (`tablesFilter: ["!user_sessions"]`), so ownership stays
   with the session store.

2. Session cookie silently dropped behind HTTPS proxies that don't
   forward `X-Forwarded-Proto` (Tailscale serve, ngrok free, some
   Cloudflare Tunnels). New `TRUST_PROXY_HTTPS=true` env var injects
   the header before pino + session middleware, and `cookie.secure` is
   now derived from `PUBLIC_BASE_URL.startsWith("https://") ||
   trustProxyHttps` instead of `NODE_ENV`.

3. `ALLOWED_ORIGINS` was undocumented for non-localhost URLs — added
   examples (LAN IP, Tailscale name, custom domain) in `.env.example`,
   `.env.docker.example`, `README.md`, plus a new "Reverse proxy /
   Tailscale / external URL" README section.

4. Demo executive meetings always seeded on real installs — gated
   behind `SEED_DEMO_MEETINGS=true` (default off), wired through
   `docker-compose.yml` to the migrate service.

Architect (code review) approved; added a security note clarifying
that `TRUST_PROXY_HTTPS=true` requires the API to be unreachable
directly over plain HTTP.

The git commit + push to Gitea is delegated to a follow-up task
because main-agent sandbox blocks `git commit` / branch creation.
2026-05-15 10:33:29 +00:00
riyadhafraa f16f4763df Improve handling of external proxy configurations and session security
Introduces `TRUST_PROXY_HTTPS` environment variable and updates session cookie security logic in `app.ts`. Modifies `README.md` and `docker-compose.yml` for clarity on proxy configurations. Updates `seed.ts` to conditionally seed demo meetings based on `SEED_DEMO_MEETINGS` environment variable.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 097c59e1-8bbe-4846-a79e-3e661e6645c4
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/FvHcc7z
Replit-Helium-Checkpoint-Created: true
2026-05-15 10:27:35 +00:00
riyadhafraa e0a4652947 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 6a631a61-6adb-4703-8cb4-9afd32d0a7ad
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/FvHcc7z
Replit-Helium-Checkpoint-Created: true
2026-05-15 10:19:42 +00:00
71 changed files with 4594 additions and 280 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

+26 -2
View File
@@ -22,11 +22,30 @@ MOCKUP_PORT=8081
# In compose this is set automatically; required when running natively.
PORT=8080
# Public base URL the SPA + API are reachable at. Used to build absolute
# upload URLs for the local-FS storage driver and to validate CORS origins.
# upload URLs for the local-FS storage driver and to derive whether the
# session cookie should be marked `secure`. Match the URL operators
# actually open in the browser (e.g. https://tx.example.com).
PUBLIC_BASE_URL=http://localhost:3000
# Comma-separated list of origins allowed by the API server's CORS layer.
# Must include PUBLIC_BASE_URL and any additional reverse-proxy domains.
# MUST include every URL the SPA can be reached at — the public domain,
# any reverse-proxy hostnames, the LAN IP for tablets/phones, and any
# tunneled URL (Tailscale `*.ts.net`, ngrok, Cloudflare Tunnel, etc).
# Examples:
# ALLOWED_ORIGINS=http://localhost:3000
# ALLOWED_ORIGINS=https://tx.example.com,https://it-demo.tail70b2bc.ts.net
# ALLOWED_ORIGINS=http://localhost:3000,http://192.168.1.42:3000
ALLOWED_ORIGINS=http://localhost:3000
# Set to `true` when Tx OS sits behind a TLS-terminating proxy that
# does NOT forward `X-Forwarded-Proto: https` (notably `tailscale serve`,
# the ngrok free tier, and some Cloudflare Tunnel setups). Without this,
# the session cookie is silently dropped and login bounces back to the
# login screen even on a successful POST /auth/login. Leave unset for
# direct localhost access or when your proxy forwards the header.
# SECURITY: only enable when the API is not reachable directly over plain
# HTTP from outside — i.e. only your TLS edge proxy can hit it. Otherwise
# anyone on the same network can submit plain-HTTP requests that the app
# trusts as HTTPS.
TRUST_PROXY_HTTPS=false
# pino log level: trace | debug | info | warn | error | fatal
LOG_LEVEL=info
# 32+ random bytes. Generate with `openssl rand -hex 32`. REQUIRED.
@@ -125,6 +144,11 @@ LOCAL_STORAGE_SIGNING_SECRET=
# the first-run wizard at /setup.
SEED_ADMIN_PASSWORD=
SEED_USER_PASSWORD=
# Set to `true` to populate the Executive Meetings module with a full
# day of realistic-looking demo meetings on first boot (useful for
# screenshots, sales demos, and UI walkthroughs). Leave unset on real
# self-hosted installs — the module should start empty.
SEED_DEMO_MEETINGS=false
# -----------------------------------------------------------------------------
# Email (optional)
+31 -6
View File
@@ -18,18 +18,35 @@ author = "agent"
[[workflows.workflow.tasks]]
task = "workflow.run"
args = "test"
args = "Start application"
[[workflows.workflow.tasks]]
task = "workflow.run"
args = "API"
[[workflows.workflow]]
name = "test"
name = "Start application"
author = "agent"
[workflows.workflow.metadata]
outputType = "console"
[[workflows.workflow.tasks]]
task = "shell.exec"
args = "pnpm --filter @workspace/api-server test:wait && pnpm --filter @workspace/api-server test && pnpm --filter @workspace/tx-os test:e2e"
args = "PORT=5000 BASE_PATH=/ VITE_API_PROXY_TARGET=http://localhost:8080 pnpm --filter @workspace/tx-os run dev"
waitForPort = 5000
[workflows.workflow.metadata]
outputType = "webview"
[[workflows.workflow]]
name = "API"
author = "agent"
[[workflows.workflow.tasks]]
task = "shell.exec"
args = "PORT=8080 pnpm --filter @workspace/api-server run dev"
waitForPort = 8080
[workflows.workflow.metadata]
outputType = "console"
[agent]
stack = "PNPM_WORKSPACE"
@@ -39,6 +56,14 @@ expertMode = true
path = "scripts/post-merge.sh"
timeoutMs = 120000
[[ports]]
localPort = 5000
externalPort = 4200
[[ports]]
localPort = 5001
externalPort = 3001
[[ports]]
localPort = 8080
externalPort = 8080
+2
View File
@@ -106,6 +106,8 @@ and running the documented commands produces a working deployment.
## Operational notes for first-time deploy
> **Updating an existing deploy after `git pull`?** Use `./scripts/redeploy.sh` — it rebuilds the `api` and `web` images, runs migrations + seed, and restarts the stack in the right order. See `replit.md` → "Redeploying after `git pull`".
1. `cp .env.example .env` and fill in every value.
2. `docker compose build`
3. `docker compose up -d db minio minio-init` — Postgres + MinIO + create
+32
View File
@@ -92,3 +92,35 @@ http://localhost:3000 → nginx (web container)
git pull
./start.sh rebuild
```
## تنبيه التحديثات (System Updates)
صفحة **Admin → System Updates** تعرض الإصدار الحالي وتقدر تفحص لو في إصدار أحدث على Gitea. للتفعيل:
1. تأكد أن ملف `latest.json` موجود في جذر الريبو على Gitea بنفس بنية `version.json`:
```json
{ "version": "0.2.0", "build": "20260601.abc1234" }
```
2. أضف في `.env` (أو `docker-compose.yml` تحت `services.api.environment`):
```
LATEST_VERSION_URL=https://desktop-11cj93j.tail866923.ts.net/rafraa/TX/raw/branch/main/latest.json
```
3. أعد التشغيل: `./start.sh stop && ./start.sh`.
عند كل إصدار جديد: حدّث `latest.json` في الريبو وادفع. عملاء Tx OS سيرون شارة "Update available" عند ضغطهم على Check Now.
> رقم البناء (`build`) يُولَّد تلقائياً من git قبل كل `pnpm run build` (انظر `scripts/update-version.mjs`)، فلا تحتاج تعديله يدوياً في `version.json`.
## تفعيل تطبيقي التقويم والإشعارات
افتراضياً يصلان مُعطَّلَين في البيئات الجديدة. لتفعيلهما على بيئة موجودة (مرة واحدة):
```bash
docker compose exec api pnpm --filter @workspace/scripts run enable-default-apps
```
السكربت idempotent (آمن لإعادة التشغيل) ويعيد فقط `is_active=true` لـ `calendar` و `notifications`.
+60 -7
View File
@@ -116,6 +116,43 @@ Front the SPA (port `WEB_PORT`, default `3000`) with Caddy / Nginx / Traefik
for TLS and HTTP/2; bind the API port (`API_PORT`, default `8080`) to
`127.0.0.1` in production so the edge proxy is the sole external entry.
### Reverse proxy / Tailscale / external URL
If you front Tx OS with a TLS-terminating proxy that you don't control
(Tailscale `serve`, ngrok, Cloudflare Tunnel, …), two things matter:
1. **Add the public URL to `ALLOWED_ORIGINS` in `.env`.** If you skip
this, the browser blocks every API call from the SPA with a CORS
error and the app appears frozen on the login screen.
```bash
ALLOWED_ORIGINS=http://localhost:3000,https://it-demo.tail70b2bc.ts.net
PUBLIC_BASE_URL=https://it-demo.tail70b2bc.ts.net
```
2. **Set `TRUST_PROXY_HTTPS=true` in `.env`.** Tunnels like Tailscale
serve forward HTTPS traffic to the upstream as plain HTTP without
sending an `X-Forwarded-Proto: https` header. Without this flag,
`req.secure` is false, the session cookie is silently dropped, and
the user is bounced back to the login screen on every refresh.
> **Security note**: only enable `TRUST_PROXY_HTTPS=true` when the
> API container/port is **not** reachable directly over plain HTTP
> from outside (i.e. only the TLS edge proxy can hit it). The
> default Docker compose only exposes the SPA port (`3000`) to the
> host and keeps the API on the internal `tx-net` network, which
> satisfies this requirement. If you change the compose file to
> expose the API port publicly, do not enable this flag.
After editing `.env`, restart the stack:
```bash
docker compose up -d
```
You should be able to log in via the public HTTPS URL with no other
changes.
### Default seeded accounts
| Username | Password | Role |
@@ -171,7 +208,9 @@ complete list with comments. Highlights:
| --------------------------------- | ---------------------------------------------------------------------- |
| `DATABASE_URL` | Postgres connection string. **Required.** |
| `SESSION_SECRET` | HMAC key for session cookies + local-driver upload tokens. **Required in production.** |
| `ALLOWED_ORIGINS` | Comma-separated CORS allow-list. Defaults to `*` (dev only). |
| `ALLOWED_ORIGINS` | Comma-separated CORS allow-list. MUST list every URL the SPA is reached at (LAN IP, Tailscale name, custom domain, ...). |
| `TRUST_PROXY_HTTPS` | `true` when fronted by a TLS-terminating proxy that doesn't forward `X-Forwarded-Proto` (Tailscale serve, ngrok free, some Cloudflare Tunnels). Required for the session cookie to persist on those setups. |
| `SEED_DEMO_MEETINGS` | `true` to populate Executive Meetings with a day of demo data on first boot. Default off so real installs start empty. |
| `STORAGE_DRIVER` | `s3` or `local`. Defaults to `s3` if `S3_ENDPOINT` set, else `local`. |
| `S3_ENDPOINT` etc. | MinIO / S3 connection. Required when `STORAGE_DRIVER=s3`. |
| `PRIVATE_OBJECT_DIR` | Path inside the bucket for private uploads, e.g. `/tx-private/private`.|
@@ -200,15 +239,29 @@ The route layer is unaware of which driver is active — both expose the same
## Tests
```bash
pnpm --filter @workspace/api-server build
pnpm --filter @workspace/api-server start & # boot API on 8080
pnpm --filter @workspace/api-server test # node --test suite
pnpm test # full suite, auto-boots API
pnpm --filter @workspace/api-server test # api-server tests only
pnpm --filter @workspace/tx-os test:e2e # Playwright UI tests
```
The `test` workflow chains all of the above. Tests use the same database
specified in `DATABASE_URL` and clean up after themselves with `LIKE`-prefixed
fixture rows.
Both `pnpm test` and `pnpm --filter @workspace/api-server test` go through
`artifacts/api-server/scripts/with-server.mjs`, which builds the API server,
spawns it on `PORT` (default `8080`), waits for `/api/healthz`, runs the
wrapped command with `TEST_API_BASE` exported, and tears the server down on
exit (including `Ctrl+C` and failures). If startup fails the helper prints
the tail of the server's stderr/stdout so the root cause is visible.
To run the tests against an already-running API server (e.g. the dev
workflow), set `TEST_API_BASE` before invoking the script — the helper will
skip spawning and use that URL instead:
```bash
TEST_API_BASE=http://localhost:8080 pnpm --filter @workspace/api-server test
```
Tests use the same database specified in `DATABASE_URL` and clean up after
themselves with `LIKE`-prefixed fixture rows. The `test:wait` script remains
available for anyone who only wants the bare health-check wait behaviour.
---
+8 -2
View File
@@ -9,7 +9,8 @@
"start": "node --enable-source-maps ./dist/index.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test:wait": "node ./scripts/wait-for-server.mjs",
"test": "node --test 'tests/*.test.mjs' && node --test --test-concurrency=1 'tests/serial/**/*.test.mjs'",
"test:run": "node --test 'tests/*.test.mjs' && node --import tsx --test --experimental-test-module-mocks 'tests/*.test.ts' && node --test --test-concurrency=1 'tests/serial/**/*.test.mjs'",
"test": "node ./scripts/with-server.mjs -- pnpm run test:run",
"migrate": "pnpm --filter @workspace/db run push && pnpm --filter scripts run seed"
},
"dependencies": {
@@ -34,7 +35,9 @@
"pino-http": "^10",
"playwright-core": "^1.59.1",
"sanitize-html": "^2.17.3",
"semver": "^7.6.3",
"socket.io": "^4.8.3",
"web-push": "^3.6.7",
"zod": "catalog:"
},
"devDependencies": {
@@ -48,11 +51,14 @@
"@types/nodemailer": "^8.0.0",
"@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1",
"@types/semver": "^7.5.8",
"@types/web-push": "^3.6.4",
"esbuild": "^0.27.3",
"esbuild-plugin-pino": "^2.3.3",
"pg": "^8.20.0",
"pino-pretty": "^13",
"socket.io-client": "^4.8.3",
"thread-stream": "3.1.0"
"thread-stream": "3.1.0",
"tsx": "catalog:"
}
}
@@ -0,0 +1,363 @@
#!/usr/bin/env node
// Spawns the built API server, waits for /api/healthz, runs a wrapped
// command, and tears the server down on exit. If TEST_API_BASE is set in
// the environment, the spawn is skipped and the wrapped command runs
// against that externally-managed server. If a healthy API server is
// already running on the target port (e.g. the dev API Server workflow),
// the helper reuses it instead of trying to bind a second process.
//
// Usage:
// node ./scripts/with-server.mjs -- <command> [args...]
//
// Env knobs:
// TEST_API_BASE If set, skip spawning and use this URL.
// PORT Port to bind the spawned server to (default 8080).
// TEST_API_WAIT_TIMEOUT_MS Health-wait timeout (default 60000 here).
// TEST_API_WAIT_INTERVAL_MS Poll interval (default 500).
// API_SERVER_LOG_TAIL Number of stderr/stdout lines to dump on
// startup failure (default 60).
// WITH_SERVER_FORCE_BUILD=1 Force a rebuild even if dist/index.mjs exists.
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const apiServerDir = path.resolve(scriptDir, "..");
function parseArgs(argv) {
const sep = argv.indexOf("--");
const rest = sep === -1 ? argv : argv.slice(sep + 1);
if (rest.length === 0) {
console.error(
"with-server.mjs: missing wrapped command. Usage: with-server.mjs -- <cmd> [args...]",
);
process.exit(2);
}
return { cmd: rest[0], cmdArgs: rest.slice(1) };
}
function tail(buffer, n) {
const lines = buffer.split(/\r?\n/);
return lines.slice(-n).join("\n");
}
async function isAlreadyHealthy(url) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1_500);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) return false;
const body = await res.json().catch(() => ({}));
return body && body.status === "ok";
} catch {
return false;
}
}
async function waitForHealth(url, timeoutMs, intervalMs, isChildAlive) {
const started = Date.now();
let attempt = 0;
let lastError = null;
while (Date.now() - started < timeoutMs) {
if (!isChildAlive()) {
return {
ok: false,
reason: "API server child exited before becoming healthy",
lastError,
attempts: attempt,
};
}
attempt += 1;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2_000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (res.ok) {
const body = await res.json().catch(() => ({}));
if (body && body.status === "ok") {
return { ok: true, attempts: attempt, elapsedMs: Date.now() - started };
}
lastError = new Error(`Unexpected health payload: ${JSON.stringify(body)}`);
} else {
lastError = new Error(`HTTP ${res.status} from ${url}`);
}
} catch (err) {
lastError = err;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
return {
ok: false,
reason: `API server did not become healthy within ${timeoutMs}ms`,
lastError,
attempts: attempt,
};
}
// Centralized lifecycle so signal handlers, top-level errors, and the
// happy-path exit all funnel through the same shutdown sequence. This
// guarantees the spawned API server is killed exactly once and that
// repeated signals never short-circuit an in-flight teardown.
function createLifecycle() {
let server = null;
let serverExited = false;
let serverExitCode = null;
let serverExitSignal = null;
let shutdownPromise = null;
let activeChild = null;
function setServer(proc) {
server = proc;
server.on("exit", (code, signal) => {
serverExited = true;
serverExitCode = code;
serverExitSignal = signal;
});
}
function setActiveChild(proc) {
activeChild = proc;
}
function clearActiveChild(proc) {
if (activeChild === proc) activeChild = null;
}
function shutdown(signal = "SIGTERM") {
if (shutdownPromise) return shutdownPromise;
shutdownPromise = (async () => {
// Forward signal to wrapped command first so it can flush.
if (activeChild && !activeChild.killed) {
try {
activeChild.kill(signal);
} catch {
/* noop */
}
}
if (!server || serverExited) return;
await new Promise((resolve) => {
const onExit = () => resolve();
server.once("exit", onExit);
try {
server.kill(signal);
} catch {
server.removeListener("exit", onExit);
resolve();
return;
}
const force = setTimeout(() => {
try {
if (!serverExited) server.kill("SIGKILL");
} catch {
/* noop */
}
}, 5_000);
server.once("exit", () => clearTimeout(force));
});
})();
return shutdownPromise;
}
return {
setServer,
setActiveChild,
clearActiveChild,
shutdown,
get serverExited() {
return serverExited;
},
get serverExitCode() {
return serverExitCode;
},
get serverExitSignal() {
return serverExitSignal;
},
};
}
function runWrappedCommand(cmd, cmdArgs, env, lifecycle) {
return new Promise((resolve) => {
const child = spawn(cmd, cmdArgs, {
stdio: "inherit",
env,
shell: false,
});
if (lifecycle) lifecycle.setActiveChild(child);
child.on("exit", (code, signal) => {
if (lifecycle) lifecycle.clearActiveChild(child);
resolve({ code: code ?? (signal ? 1 : 0), signal });
});
child.on("error", (err) => {
if (lifecycle) lifecycle.clearActiveChild(child);
console.error(`with-server.mjs: failed to spawn wrapped command: ${err.message}`);
resolve({ code: 1, signal: null });
});
});
}
function buildIfNeeded() {
const distEntry = path.join(apiServerDir, "dist", "index.mjs");
if (existsSync(distEntry) && process.env.WITH_SERVER_FORCE_BUILD !== "1") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const child = spawn("node", ["./build.mjs"], {
cwd: apiServerDir,
stdio: "inherit",
env: process.env,
});
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`API server build failed with exit code ${code}`));
});
child.on("error", (err) => reject(err));
});
}
async function main() {
const { cmd, cmdArgs } = parseArgs(process.argv.slice(2));
// Externally-managed server: skip spawn entirely.
if (process.env.TEST_API_BASE) {
console.log(
`with-server.mjs: TEST_API_BASE=${process.env.TEST_API_BASE} set; using existing server.`,
);
const { code } = await runWrappedCommand(cmd, cmdArgs, process.env, null);
process.exit(code);
}
const port = process.env.PORT ?? "8080";
const apiBase = `http://localhost:${port}`;
const healthUrl = `${apiBase}/api/healthz`;
if (await isAlreadyHealthy(healthUrl)) {
console.log(
`with-server.mjs: detected healthy API server at ${healthUrl}; reusing it (skipping spawn).`,
);
const reuseEnv = { ...process.env, TEST_API_BASE: apiBase };
const { code } = await runWrappedCommand(cmd, cmdArgs, reuseEnv, null);
process.exit(code);
}
await buildIfNeeded();
const tailLines = Number(process.env.API_SERVER_LOG_TAIL ?? 60);
const timeoutMs = Number(process.env.TEST_API_WAIT_TIMEOUT_MS ?? 60_000);
const intervalMs = Number(process.env.TEST_API_WAIT_INTERVAL_MS ?? 500);
const serverEnv = {
...process.env,
PORT: port,
NODE_ENV: process.env.NODE_ENV ?? "development",
};
const lifecycle = createLifecycle();
// Install centralized signal/error handlers ONCE so repeated signals
// share the same in-flight shutdown promise instead of racing.
const onSignal = (sig) => {
lifecycle.shutdown(sig).finally(() => {
const exitCode = sig === "SIGINT" ? 130 : 143;
process.exit(exitCode);
});
};
process.on("SIGINT", () => onSignal("SIGINT"));
process.on("SIGTERM", () => onSignal("SIGTERM"));
process.on("uncaughtException", async (err) => {
console.error("with-server.mjs: uncaught exception", err);
try {
await lifecycle.shutdown("SIGTERM");
} finally {
process.exit(1);
}
});
process.on("unhandledRejection", async (err) => {
console.error("with-server.mjs: unhandled rejection", err);
try {
await lifecycle.shutdown("SIGTERM");
} finally {
process.exit(1);
}
});
console.log(`with-server.mjs: starting API server on ${apiBase} ...`);
const server = spawn("node", ["--enable-source-maps", "./dist/index.mjs"], {
cwd: apiServerDir,
env: serverEnv,
stdio: ["ignore", "pipe", "pipe"],
});
lifecycle.setServer(server);
let stdoutBuf = "";
let stderrBuf = "";
const MAX_BUF = 256 * 1024;
server.stdout.on("data", (chunk) => {
stdoutBuf += chunk.toString();
if (stdoutBuf.length > MAX_BUF) stdoutBuf = stdoutBuf.slice(-MAX_BUF);
process.stdout.write(chunk);
});
server.stderr.on("data", (chunk) => {
stderrBuf += chunk.toString();
if (stderrBuf.length > MAX_BUF) stderrBuf = stderrBuf.slice(-MAX_BUF);
process.stderr.write(chunk);
});
const health = await waitForHealth(
healthUrl,
timeoutMs,
intervalMs,
() => !lifecycle.serverExited,
);
if (!health.ok) {
console.error(`\nwith-server.mjs: ${health.reason}`);
if (health.lastError) {
console.error(
`Last health-check error: ${health.lastError.message ?? health.lastError}`,
);
}
if (lifecycle.serverExited) {
console.error(
`API server exited prematurely (code=${lifecycle.serverExitCode}, signal=${lifecycle.serverExitSignal}).`,
);
}
if (stderrBuf.trim()) {
console.error(`\n----- API server stderr (last ${tailLines} lines) -----`);
console.error(tail(stderrBuf, tailLines));
console.error(`----- end stderr -----`);
}
if (!stderrBuf.trim() && stdoutBuf.trim()) {
console.error(`\n----- API server stdout (last ${tailLines} lines) -----`);
console.error(tail(stdoutBuf, tailLines));
console.error(`----- end stdout -----`);
}
console.error(
"\nHint: API server failed to start. Check the logs above, or set TEST_API_BASE to point at a running instance.",
);
await lifecycle.shutdown("SIGTERM");
process.exit(1);
}
console.log(
`with-server.mjs: API server ready at ${healthUrl} after ${health.elapsedMs}ms (attempt ${health.attempts}).`,
);
const wrappedEnv = { ...process.env, TEST_API_BASE: apiBase };
const { code, signal } = await runWrappedCommand(cmd, cmdArgs, wrappedEnv, lifecycle);
await lifecycle.shutdown("SIGTERM");
if (signal) {
process.exit(128 + (signal === "SIGINT" ? 2 : 15));
}
process.exit(code ?? 0);
}
main().catch(async (err) => {
console.error("with-server.mjs: unexpected error", err);
process.exit(1);
});
+39 -1
View File
@@ -13,6 +13,25 @@ const app: Express = express();
app.set("trust proxy", 1);
// Self-hosted deployments are commonly fronted by a TLS-terminating
// proxy that does NOT forward `X-Forwarded-Proto: https` to the
// upstream (e.g. `tailscale serve`, ngrok free tier, some Cloudflare
// Tunnel configs). Without that header, `req.secure` stays false and
// the session cookie (when marked `secure: true`) is silently dropped
// — login appears to succeed but the very next request is unauth'd.
//
// Setting `TRUST_PROXY_HTTPS=true` tells us to treat every incoming
// request as if it arrived over HTTPS. Only enable this when the
// edge proxy you control actually terminates TLS in front of Tx OS.
const trustProxyHttps =
(process.env.TRUST_PROXY_HTTPS ?? "").toLowerCase() === "true";
if (trustProxyHttps) {
app.use((req, _res, next) => {
req.headers["x-forwarded-proto"] = "https";
next();
});
}
app.use(
pinoHttp({
logger,
@@ -55,11 +74,30 @@ app.use(
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// The session cookie should only be marked `secure` when the user is
// actually reaching us over HTTPS. We derive that from PUBLIC_BASE_URL
// instead of NODE_ENV so a self-hosted production install that's only
// reachable over plain HTTP (e.g. internal LAN, no TLS yet) still
// works without operator surgery.
const publicBaseUrl = process.env.PUBLIC_BASE_URL ?? "";
const sessionCookieSecure =
publicBaseUrl.startsWith("https://") || trustProxyHttps;
export const sessionMiddleware = session({
store: new PgSession({
pool,
tableName: "user_sessions",
// Auto-create the session table on first boot. Drizzle's migrations
// intentionally exclude `user_sessions` (see lib/db/drizzle.config.ts)
// because the schema is owned by connect-pg-simple, not by us. Without
// this flag, the very first login on a fresh install 500s.
createTableIfMissing: true,
}),
// Honour `X-Forwarded-Proto` from upstream proxies when deciding
// whether to set the secure cookie. Combined with `app.set("trust
// proxy", 1)` above, this makes the cookie work end-to-end behind
// Caddy / nginx / Cloudflare / Tailscale.
proxy: true,
secret: process.env.SESSION_SECRET ?? (() => {
if (process.env.NODE_ENV === "production") {
throw new Error("SESSION_SECRET must be set in production");
@@ -69,7 +107,7 @@ export const sessionMiddleware = session({
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
secure: sessionCookieSecure,
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000,
sameSite: "lax",
+8
View File
@@ -69,5 +69,13 @@ ensureSystemSettingsBootstrap()
.finally(() => {
httpServer.listen(port, () => {
logger.info({ port }, "Server listening");
// Fire-and-forget background tick: every 60s, scan today's
// meetings and push the 5-minute reminder to attendees whose
// alert hasn't been pushed yet. See #558.
import("./lib/executive-meeting-scheduler.js")
.then((m) => m.startExecutiveMeetingScheduler())
.catch((err) => {
logger.error({ err }, "Failed to start executive-meeting scheduler");
});
});
});
@@ -13,6 +13,7 @@ import {
usersTable,
} from "@workspace/db";
import { logger } from "./logger";
import { sendPushToUser } from "./push";
/**
* Canonical list of notification event types the Executive Meetings
@@ -131,11 +132,30 @@ export async function recordExecutiveMeetingNotifications(
// Drop anyone who has explicitly muted the in-app channel for this
// event type. Users with no preference row stay in (default = on).
const recipients = await filterRecipientsByNotificationPref(
const prefFiltered = await filterRecipientsByNotificationPref(
candidates,
input.notificationType,
"inApp",
);
if (prefFiltered.length === 0) return [];
// Verify the recipient user rows still exist in the SAME executor
// (transaction or pool) we're about to INSERT against, AND take a
// FOR KEY SHARE lock on each one so any concurrent DELETE of a
// recipient must wait for our enclosing transaction to commit.
// Without the key-share lock a parallel request that deletes a
// user between our recipient resolution and the bulk INSERT below
// commits its DELETE under READ COMMITTED — our INSERT then trips
// the executive_meeting_notifications.user_id FK and aborts the
// whole meeting create/update tx (#312/#283 surfaced this in
// parallel test runs that delete-and-recreate users).
const existingRows = await executor
.select({ id: usersTable.id })
.from(usersTable)
.where(inArray(usersTable.id, prefFiltered))
.for("share");
const existing = new Set(existingRows.map((r) => r.id));
const recipients = prefFiltered.filter((id) => existing.has(id));
if (recipients.length === 0) return [];
const now = new Date();
@@ -192,6 +212,18 @@ export async function broadcastExecutiveMeetingNotifications(
};
for (const uid of recipientUserIds) {
io.to(`user:${uid}`).emit("notification_created", payload);
void sendPushToUser(uid, {
title: "تنبيه اجتماع",
body: "لديك تنبيه اجتماع تنفيذي",
titleAr: "تنبيه اجتماع",
titleEn: "Meeting alert",
bodyAr: "لديك تنبيه اجتماع تنفيذي",
bodyEn: "You have an executive meeting alert",
type: "executive_meeting",
relatedId: meetingId,
tag: meetingId ? `meeting-${meetingId}-${notificationType}` : `meeting-${notificationType}`,
url: "/meetings",
});
}
io.emit("executive_meeting_notifications_changed", {
notificationType,
@@ -0,0 +1,196 @@
import { and, eq, inArray, ne, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
executiveMeetingAlertStateTable,
executiveMeetingsTable,
} from "@workspace/db";
import { logger } from "./logger";
import { sendPushToUser } from "./push";
import { getUserIdsForRoleNames } from "./executive-meeting-notify";
const TICK_MS = 60_000;
const LEAD_MINUTES = 5;
const EM_NOTIFY_ROLES = [
"admin",
"executive_ceo",
"executive_office_manager",
] as const;
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function dateKey(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
function parseHHMM(s: string | null | undefined): { h: number; m: number } | null {
if (!s) return null;
const m = /^(\d{1,2}):(\d{2})/.exec(s);
if (!m) return null;
const h = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
return { h, m: mm };
}
/**
* Convert a meeting's (meetingDate, startTime) into an absolute Date in
* the server's local timezone (matches how the client computes the
* 5-minute window). Returns null if either field is malformed.
*/
function meetingStartLocal(meetingDate: string, startTime: string | null): Date | null {
const t = parseHHMM(startTime);
if (!t) return null;
const d = new Date(`${meetingDate}T${pad2(t.h)}:${pad2(t.m)}:00`);
if (Number.isNaN(d.getTime())) return null;
return d;
}
async function tickOnce(): Promise<void> {
const now = new Date();
// Query today AND tomorrow so a meeting at 00:02 is still picked up
// when the previous calendar day is finishing at 23:57. The eligible
// filter below trims to the actual 5-minute window so this is cheap.
const todayKey = dateKey(now);
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const tomorrowKey = dateKey(tomorrow);
const meetings = await db
.select({
id: executiveMeetingsTable.id,
titleAr: executiveMeetingsTable.titleAr,
titleEn: executiveMeetingsTable.titleEn,
meetingDate: executiveMeetingsTable.meetingDate,
startTime: executiveMeetingsTable.startTime,
status: executiveMeetingsTable.status,
})
.from(executiveMeetingsTable)
.where(
and(
inArray(executiveMeetingsTable.meetingDate, [todayKey, tomorrowKey]),
ne(executiveMeetingsTable.status, "cancelled"),
ne(executiveMeetingsTable.status, "completed"),
),
);
const eligible = meetings
.map((m) => {
const start = meetingStartLocal(m.meetingDate, m.startTime);
if (!start) return null;
const deltaMs = start.getTime() - now.getTime();
if (deltaMs <= 0) return null;
const deltaMin = Math.ceil(deltaMs / 60_000);
if (deltaMin > LEAD_MINUTES) return null;
return { meeting: m, deltaMin };
})
.filter((x): x is { meeting: typeof meetings[number]; deltaMin: number } => !!x);
if (eligible.length === 0) return;
const recipients = await getUserIdsForRoleNames(EM_NOTIFY_ROLES);
if (recipients.length === 0) return;
for (const { meeting, deltaMin } of eligible) {
// Atomic claim: ON CONFLICT only updates pushed_at when it's still
// NULL, and we RETURN only the rows we actually mutated. Two ticks
// (or two instances) racing on the same (meeting,user) can never
// both win — Postgres serialises the conflict resolution. Rows
// that the user has already acknowledged or dismissed are excluded
// from the update so we don't ring after the user said "Done".
const claimed = await db
.insert(executiveMeetingAlertStateTable)
.values(
recipients.map((userId) => ({
meetingId: meeting.id,
userId,
pushedAt: now,
})),
)
.onConflictDoUpdate({
target: [
executiveMeetingAlertStateTable.meetingId,
executiveMeetingAlertStateTable.userId,
],
set: { pushedAt: now },
setWhere: and(
sql`${executiveMeetingAlertStateTable.pushedAt} IS NULL`,
eq(executiveMeetingAlertStateTable.dismissed, false),
eq(executiveMeetingAlertStateTable.acknowledged, false),
),
})
.returning({ userId: executiveMeetingAlertStateTable.userId });
if (claimed.length === 0) continue;
const titleAr = "تذكير اجتماع";
const titleEn = "Meeting reminder";
const subjectAr = meeting.titleAr || meeting.titleEn || "اجتماع";
const subjectEn = meeting.titleEn || meeting.titleAr || "Meeting";
const bodyAr = `${subjectAr} يبدأ خلال ${deltaMin} دقيقة`;
const bodyEn = `${subjectEn} starts in ${deltaMin} min`;
for (const { userId } of claimed) {
void sendPushToUser(
userId,
{
title: subjectAr,
body: bodyAr,
titleAr,
titleEn,
bodyAr,
bodyEn,
tag: `em-reminder-${meeting.id}`,
url: "/meetings",
type: "executive_meeting",
relatedId: meeting.id,
},
{ ignoreConnected: true },
).catch((err) => {
logger.warn(
{ err, meetingId: meeting.id, userId },
"executive-meeting-scheduler push failed",
);
});
}
logger.info(
{ meetingId: meeting.id, deltaMin, recipients: claimed.length },
"executive-meeting-scheduler reminder fired",
);
}
}
let started = false;
let timer: NodeJS.Timeout | null = null;
let running = false;
export function startExecutiveMeetingScheduler(): void {
if (started) return;
started = true;
const run = () => {
// In-process guard: if the previous tick is still running (slow
// DB, long push round-trip), skip this tick so we never overlap
// and re-claim/re-send. Multi-process deployments still rely on
// the atomic Postgres claim above for correctness.
if (running) return;
running = true;
tickOnce()
.catch((err) => {
logger.error({ err }, "executive-meeting-scheduler tick failed");
})
.finally(() => {
running = false;
});
};
setImmediate(run);
timer = setInterval(run, TICK_MS);
logger.info({ tickMs: TICK_MS }, "executive-meeting-scheduler started");
}
export function stopExecutiveMeetingScheduler(): void {
if (timer) clearInterval(timer);
timer = null;
started = false;
running = false;
}
+399
View File
@@ -0,0 +1,399 @@
import { promises as fs } from "fs";
import path from "path";
import { eq, and } from "drizzle-orm";
import webpush from "web-push";
import { db } from "@workspace/db";
import {
pushSubscriptionsTable,
usersTable,
} from "@workspace/db";
import { logger } from "./logger";
let initPromise: Promise<{
publicKey: string;
privateKey: string;
subject: string;
} | null> | null = null;
function keysFilePath(): string {
// LOCAL_STORAGE_ROOT is set in Docker prod (mounted volume at
// /app/storage). In dev on Replit it's unset, and PRIVATE_OBJECT_DIR
// points to an object-store path that isn't a real local filesystem,
// so we fall back to /tmp.
const root = process.env.LOCAL_STORAGE_ROOT || "/tmp";
return path.join(root, "vapid-keys.json");
}
/**
* Resolve VAPID keys. Priority:
* 1. env (VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY)
* 2. cached on-disk file (auto-generated on first boot)
* 3. generate, persist, return
*
* Returns null only if write + generate both fail, in which case
* Web Push is disabled but the rest of the app keeps running.
*/
async function loadVapid(): Promise<{
publicKey: string;
privateKey: string;
subject: string;
} | null> {
// Use `||` (not `??`) because docker-compose passes unset vars as
// empty strings, not undefined. An empty subject fails web-push's
// validateSubject() with "No subject set in vapidDetails.subject".
const subject =
process.env.VAPID_SUBJECT ||
`mailto:admin@${process.env.LOCAL_DOMAIN || "tx.local"}`;
const envPub = process.env.VAPID_PUBLIC_KEY;
const envPriv = process.env.VAPID_PRIVATE_KEY;
if (envPub && envPriv) {
try {
webpush.setVapidDetails(subject, envPub, envPriv);
return { publicKey: envPub, privateKey: envPriv, subject };
} catch (err) {
logger.error({ err }, "Invalid VAPID env keys — falling back to disk");
}
}
const file = keysFilePath();
try {
const raw = await fs.readFile(file, "utf8");
const parsed = JSON.parse(raw) as {
publicKey?: string;
privateKey?: string;
};
if (parsed.publicKey && parsed.privateKey) {
webpush.setVapidDetails(subject, parsed.publicKey, parsed.privateKey);
return {
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
subject,
};
}
} catch {
/* missing — fall through to generate */
}
const { publicKey, privateKey } = webpush.generateVAPIDKeys();
// Attempt to persist so the key survives restarts. If the target
// directory isn't writable (e.g. Replit dev where LOCAL_STORAGE_ROOT
// is unset), fall back to /tmp. If even that fails, run with an
// ephemeral in-memory key — Web Push still works for this process,
// existing subscriptions will just be invalidated on next restart.
const candidates = [file, path.join("/tmp", "vapid-keys.json")];
let persistedAt: string | null = null;
for (const target of candidates) {
try {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(
target,
JSON.stringify({ publicKey, privateKey }, null, 2),
{ mode: 0o600 },
);
persistedAt = target;
break;
} catch {
/* try next candidate */
}
}
webpush.setVapidDetails(subject, publicKey, privateKey);
if (persistedAt) {
logger.warn(
{ file: persistedAt },
"Generated VAPID keys (no VAPID_PUBLIC_KEY/PRIVATE_KEY env set). " +
"Persisted to disk so they survive restarts. Copy them into env to " +
"share across hosts.",
);
} else {
logger.error(
"Generated ephemeral VAPID keys (could not write to disk). " +
"Push subscriptions will be invalidated on next restart.",
);
}
return { publicKey, privateKey, subject };
}
export function getVapid() {
if (!initPromise) initPromise = loadVapid();
return initPromise;
}
/**
* Push payload. Two shapes are accepted so call sites can either:
* 1. Pass `title` + `body` directly (legacy / single-language), or
* 2. Pass `titleAr`+`titleEn` and `bodyAr`+`bodyEn` and let
* `sendPushToUser` pick the matching strings based on the
* recipient's `preferredLanguage` column. This way an English-
* preferring user no longer gets Arabic on their lock screen.
*
* When both shapes are provided, the bilingual fields win.
*/
export type PushPayload = {
title: string;
body: string;
titleAr?: string;
titleEn?: string;
bodyAr?: string;
bodyEn?: string;
tag?: string;
url?: string;
type?: string;
relatedId?: number | null;
};
type ChannelType = "order" | "executive_meeting" | "note" | "info";
type UserPushPrefs = {
muted: boolean;
orders: boolean;
meetings: boolean;
notes: boolean;
language: string;
};
/**
* Load the user's notification gating prefs AND their preferred UI
* language in a single round-trip. The language is used downstream to
* localise the push title/body when the caller supplied bilingual
* fields. Mirrors the client-side gating in `useNotificationsSocket`.
*/
async function loadUserPushPrefs(userId: number): Promise<UserPushPrefs | null> {
const [u] = await db
.select({
muted: usersTable.notificationsMuted,
orders: usersTable.notifyOrdersEnabled,
meetings: usersTable.notifyMeetingsEnabled,
notes: usersTable.notifyNotesEnabled,
language: usersTable.preferredLanguage,
})
.from(usersTable)
.where(eq(usersTable.id, userId))
.limit(1);
if (!u) return null;
return u;
}
function userAllowsChannel(prefs: UserPushPrefs, channel: ChannelType): boolean {
if (prefs.muted) return false;
if (channel === "order") return prefs.orders;
if (channel === "executive_meeting") return prefs.meetings;
if (channel === "note") return prefs.notes;
return true;
}
/**
* Pick the title/body in the recipient's preferred language. Falls
* back to the legacy `title`/`body` fields when the caller didn't pass
* bilingual copies, and falls back to the other language when only one
* side is provided (so a half-localised emit site is still readable).
*/
function localisePayload(
payload: PushPayload,
language: string,
): { title: string; body: string } {
const preferEn = language === "en";
const title = preferEn
? payload.titleEn ?? payload.titleAr ?? payload.title
: payload.titleAr ?? payload.titleEn ?? payload.title;
const body = preferEn
? payload.bodyEn ?? payload.bodyAr ?? payload.body
: payload.bodyAr ?? payload.bodyEn ?? payload.body;
return { title, body };
}
/**
* Send a Web Push notification to every active subscription a user
* owns. Drops 404/410 subscriptions on the fly. Honours the user's
* mute + per-channel preferences. Safe to fire-and-forget.
*/
/**
* Returns true if the user has at least one active Socket.IO connection
* (any tab/device). Used to gate Web Push so a connected user doesn't
* get the in-app chime AND a system notification for the same event.
*
* Design note (intentional, user-wide rather than per-device): the
* primary deployment is a single user with one iPad PWA plus an
* occasional desktop browser. The product preference is "don't
* double-ring me on the desktop I'm staring at, even if the iPad is
* also subscribed" — so when ANY socket is live we trust the in-app
* chime to surface the notification, and skip system push for every
* device the user owns. If multi-device per-session push becomes a
* requirement, narrow this to the specific socket-id / device-id
* registered alongside each push subscription.
*/
async function isUserConnected(userId: number): Promise<boolean> {
try {
const { io } = await import("../index.js");
const sockets = await io.in(`user:${userId}`).fetchSockets();
return sockets.length > 0;
} catch {
// If the socket layer isn't reachable, fall through and send push —
// better to over-notify than to silently drop.
return false;
}
}
export async function sendPushToUser(
userId: number,
payload: PushPayload,
options: { ignoreConnected?: boolean } = {},
): Promise<void> {
if (!Number.isInteger(userId) || userId <= 0) return;
const channel = (payload.type as ChannelType) ?? "info";
const prefs = await loadUserPushPrefs(userId);
if (!prefs) return;
if (!userAllowsChannel(prefs, channel)) return;
// De-dup: if the user is online (any tab/device with an active socket)
// they're already getting the in-app notification via Socket.IO, so
// we skip Web Push to avoid double-alerts. Push is for the
// backgrounded/closed PWA case.
//
// `ignoreConnected` overrides this for time-critical reminders (e.g.
// the 5-minute upcoming-meeting scheduler) where missing the ring
// because a stale tab is open is worse than ringing twice.
if (!options.ignoreConnected && (await isUserConnected(userId))) return;
const vapid = await getVapid();
if (!vapid) return;
const subs = await db
.select()
.from(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.userId, userId));
if (subs.length === 0) return;
// Localise once, up-front: every subscription for this user is the
// same human, so they all get the same language. The bilingual
// fields (titleAr/titleEn/bodyAr/bodyEn) win over the legacy
// single-language ones when both are present.
const localised = localisePayload(payload, prefs.language);
// Web Push payloads are capped at 4096 bytes after encryption padding.
// We aim for ~3500 to leave headroom and truncate the (likely-Arabic,
// multi-byte) body if needed so a long note doesn't blow up delivery
// for every device the user owns.
const MAX_BYTES = 3500;
// Strip the bilingual fields from the wire payload — the SW only
// needs the resolved title/body — and override with the localised
// pair. This also keeps the payload small.
const {
titleAr: _ta,
titleEn: _te,
bodyAr: _ba,
bodyEn: _be,
...rest
} = payload;
void _ta;
void _te;
void _ba;
void _be;
let safePayload: PushPayload = { ...rest, title: localised.title, body: localised.body };
let json = JSON.stringify(safePayload);
if (Buffer.byteLength(json, "utf8") > MAX_BYTES) {
const overhead = Buffer.byteLength(
JSON.stringify({ ...safePayload, body: "" }),
"utf8",
);
const budget = Math.max(0, MAX_BYTES - overhead - 1);
let truncated = safePayload.body;
while (Buffer.byteLength(truncated, "utf8") > budget && truncated.length > 0) {
truncated = truncated.slice(0, -1);
}
safePayload = { ...safePayload, body: truncated + "…" };
json = JSON.stringify(safePayload);
}
await Promise.all(
subs.map(async (sub) => {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth },
},
json,
{ TTL: 60 },
);
await db
.update(pushSubscriptionsTable)
.set({ lastUsedAt: new Date() })
.where(eq(pushSubscriptionsTable.id, sub.id));
} catch (err) {
const status = (err as { statusCode?: number })?.statusCode;
if (status === 404 || status === 410) {
await db
.delete(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.id, sub.id));
logger.info(
{ userId, endpoint: sub.endpoint, status },
"Pruned stale push subscription",
);
} else {
logger.warn({ err, userId, status }, "Web Push send failed");
}
}
}),
);
}
export async function upsertSubscription(
userId: number,
input: {
endpoint: string;
p256dh: string;
auth: string;
userAgent?: string | null;
},
): Promise<void> {
const ua = input.userAgent ? input.userAgent.slice(0, 400) : null;
// If the same browser endpoint already exists under a different user
// (shared device, account switch), delete the stale row first so the
// previous user's notifications can't leak onto this device. The
// unique constraint on `endpoint` would otherwise quietly hand the
// subscription over via UPDATE.
const [existing] = await db
.select({ id: pushSubscriptionsTable.id, userId: pushSubscriptionsTable.userId })
.from(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.endpoint, input.endpoint))
.limit(1);
if (existing && existing.userId !== userId) {
await db
.delete(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.id, existing.id));
}
await db
.insert(pushSubscriptionsTable)
.values({
userId,
endpoint: input.endpoint,
p256dh: input.p256dh,
auth: input.auth,
userAgent: ua,
})
.onConflictDoUpdate({
target: pushSubscriptionsTable.endpoint,
set: {
userId,
p256dh: input.p256dh,
auth: input.auth,
userAgent: ua,
lastUsedAt: new Date(),
},
});
}
export async function removeSubscription(
userId: number,
endpoint: string,
): Promise<void> {
await db
.delete(pushSubscriptionsTable)
.where(
and(
eq(pushSubscriptionsTable.userId, userId),
eq(pushSubscriptionsTable.endpoint, endpoint),
),
);
}
@@ -44,6 +44,7 @@ import {
emitExecutiveMeetingsDaysChanged,
emitExecutiveMeetingAlertStateChanged,
} from "../lib/realtime";
import { logger } from "../lib/logger";
import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer";
import { getPdfLabels } from "../lib/pdf-labels";
import { ObjectStorageService } from "../lib/objectStorage";
@@ -116,12 +117,14 @@ const FONT_SIZE_MAX = 22;
const MUTATE_ROLES = [
"admin",
"executive_ceo",
"executive_office_manager",
"executive_coord_lead",
] as const;
const EM_ADMIN_ROLES = ["admin", "executive_office_manager"] as const;
const ADMIN_AUDIT_ROLES = [
"admin",
"executive_ceo",
"executive_office_manager",
"executive_coord_lead",
] as const;
@@ -386,6 +389,21 @@ async function logAudit(
// ---------- Common helpers ----------
// Serialize concurrent transactions that mutate `executive_meetings`
// rows for the same `meeting_date`. Without this, two parallel POSTs
// for the same date can either (a) deadlock in the negate-then-renumber
// dance inside `renumberDayByStartTime`, or (b) race `nextDailyNumber`
// and trip the `(meeting_date, daily_number)` unique index. Postgres
// advisory locks scoped to the current transaction are reentrant per
// session, so callers can call this multiple times within one tx (e.g.
// once at the top of POST create and again inside renumberDayByStartTime)
// without self-deadlocking.
async function lockMeetingDate(executor: DbExecutor, date: string): Promise<void> {
await executor.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(${date}, 0))`,
);
}
async function nextDailyNumber(
executor: DbExecutor,
date: string,
@@ -443,6 +461,11 @@ async function renumberDayByStartTime(
executor: DbExecutor,
date: string,
): Promise<DayOrderShift> {
// Serialize per-date so the negate-then-renumber dance below cannot
// deadlock with a concurrent transaction running the same dance for
// the same `meeting_date`. Reentrant — safe if the caller already
// grabbed this lock.
await lockMeetingDate(executor, date);
// Snapshot the visible order BEFORE the renumber so callers can audit
// a true "from -> to" diff (#312). Cheap single SELECT inside the same
// transaction the caller passes in.
@@ -623,6 +646,10 @@ router.post(
const userId = req.session.userId!;
try {
const inserted = await db.transaction(async (tx) => {
// Serialize per-date so concurrent POSTs for the same date can't
// race `nextDailyNumber` into a unique-index violation, and so
// the renumber dance below cannot deadlock with a parallel one.
await lockMeetingDate(tx, data.meetingDate);
const dailyNumber =
data.dailyNumber ?? (await nextDailyNumber(tx, data.meetingDate));
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
@@ -719,6 +746,7 @@ router.post(
});
return;
}
logger.error({ err }, "executive_meetings.create failed");
res.status(500).json({ error: "Could not create meeting", code: "create_failed" });
}
},
@@ -744,6 +772,19 @@ router.patch(
const userId = req.session.userId!;
try {
await db.transaction(async (tx) => {
// Serialize per-date for both the existing date and (if moving)
// the destination date so concurrent PATCH/POST/duplicate flows
// can't race the renumber + dailyNumber allocation. Acquire in
// sorted order to prevent deadlock between opposite-direction
// cross-day PATCHes (A->B vs B->A).
const datesToLock = Array.from(
new Set(
data.meetingDate !== undefined && data.meetingDate !== existing.meetingDate
? [existing.meetingDate, data.meetingDate]
: [existing.meetingDate],
),
).sort();
for (const d of datesToLock) await lockMeetingDate(tx, d);
const updateValues: Partial<typeof executiveMeetingsTable.$inferInsert> = {
updatedBy: userId,
};
@@ -1514,6 +1555,16 @@ router.post(
| StaleConflict
| CascadeBlock;
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
// Acquire the per-date advisory lock BEFORE the row-level FOR
// UPDATE so we can never deadlock with a concurrent POST create
// (which grabs the date lock first, then the row lock implicitly
// via renumber). Pre-fetch the date with a non-locking SELECT so
// we know which date to lock without acquiring a row lock first.
const [meta] = await tx
.select({ meetingDate: executiveMeetingsTable.meetingDate })
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id));
if (meta) await lockMeetingDate(tx, meta.meetingDate);
const [locked] = await tx
.select()
.from(executiveMeetingsTable)
@@ -1761,6 +1812,20 @@ router.post(
| { ok: false; status: number; error: string; code: string }
| CascadeBlock;
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
// Acquire per-date advisory lock(s) BEFORE the row-level FOR
// UPDATE. Pre-fetch the current date with a non-locking SELECT so
// we never grab the row lock first (which would deadlock against
// concurrent POST create on the same date).
const [meta] = await tx
.select({ meetingDate: executiveMeetingsTable.meetingDate })
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id));
if (meta) {
const datesToLock = Array.from(
new Set([meta.meetingDate, body.meetingDate].filter(Boolean) as string[]),
).sort();
for (const d of datesToLock) await lockMeetingDate(tx, d);
}
const [locked] = await tx
.select()
.from(executiveMeetingsTable)
@@ -1913,6 +1978,13 @@ router.post(
| { ok: true; meetingDate: string; changed: boolean }
| { ok: false; status: number; error: string; code: string };
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
// Per-date advisory lock BEFORE row FOR UPDATE — see comment on
// postpone-minutes for the deadlock pattern this prevents.
const [meta] = await tx
.select({ meetingDate: executiveMeetingsTable.meetingDate })
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, id));
if (meta) await lockMeetingDate(tx, meta.meetingDate);
const [locked] = await tx
.select()
.from(executiveMeetingsTable)
@@ -1970,6 +2042,9 @@ router.delete(
}
const userId = req.session.userId!;
await db.transaction(async (tx) => {
// Serialize per-date so DELETE + renumber can't deadlock with a
// concurrent POST/PATCH that grabbed the advisory lock first.
await lockMeetingDate(tx, existing.meetingDate);
await tx.delete(executiveMeetingsTable).where(eq(executiveMeetingsTable.id, id));
await logAudit(tx, {
action: "delete",
@@ -2066,6 +2141,9 @@ router.post(
const userId = req.session.userId!;
try {
const inserted = await db.transaction(async (tx) => {
// Serialize per-date so the renumber + insert flow can't race
// a concurrent POST/PATCH on the same target date.
await lockMeetingDate(tx, data.targetDate);
const nextNumber = await nextDailyNumber(tx, data.targetDate);
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
dailyNumber: nextNumber,
@@ -2199,6 +2277,19 @@ router.post(
// concurrent swap-times calls touch the same pair from opposite
// sides (A↔B vs B↔A).
const ids = [data.aId, data.bId].slice().sort((x, y) => x - y);
// Per-date advisory lock(s) BEFORE the row-level FOR UPDATE so a
// concurrent POST create on the same date cannot deadlock against
// us. Pre-fetch dates without locking, then lock distinct dates
// in sorted order.
const metas = await tx
.select({
id: executiveMeetingsTable.id,
meetingDate: executiveMeetingsTable.meetingDate,
})
.from(executiveMeetingsTable)
.where(inArray(executiveMeetingsTable.id, ids));
const datesToLock = Array.from(new Set(metas.map((m) => m.meetingDate))).sort();
for (const d of datesToLock) await lockMeetingDate(tx, d);
const lockedRows = await tx
.select()
.from(executiveMeetingsTable)
@@ -2409,6 +2500,9 @@ router.post(
// would silently leave stale time tuples on whatever rows the
// client omitted.
const ids = data.orderedIds.slice().sort((a, b) => a - b);
// Per-date advisory lock BEFORE row FOR UPDATE so we don't
// deadlock with a concurrent POST create on the same date.
await lockMeetingDate(tx, data.meetingDate);
const lockedRows = await tx
.select()
.from(executiveMeetingsTable)
@@ -2626,6 +2720,9 @@ router.post(
}
try {
await db.transaction(async (tx) => {
// Serialize per-date so concurrent reorder/POST/PATCH on the
// same day cannot deadlock with each other's renumber dance.
await lockMeetingDate(tx, data.meetingDate);
const allRows = await tx
.select()
.from(executiveMeetingsTable)
+4
View File
@@ -16,6 +16,8 @@ import groupsRouter from "./groups";
import rolesRouter from "./roles";
import auditRouter from "./audit";
import executiveMeetingsRouter from "./executive-meetings";
import pushRouter from "./push";
import systemRouter from "./system";
const router: IRouter = Router();
@@ -36,5 +38,7 @@ router.use(groupsRouter);
router.use(rolesRouter);
router.use(auditRouter);
router.use(executiveMeetingsRouter);
router.use(pushRouter);
router.use(systemRouter);
export default router;
+25
View File
@@ -13,6 +13,7 @@ import {
notificationsTable,
} from "@workspace/db";
import { requireAuth, getUserRoles } from "../middlewares/auth";
import { sendPushToUser } from "../lib/push";
const router: IRouter = Router();
@@ -1132,6 +1133,18 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
})
.returning();
await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" });
void sendPushToUser(r.recipientUserId, {
title: "ملاحظة جديدة",
body: `${senderNameAr} أرسل لك ملاحظة`,
titleAr: "ملاحظة جديدة",
titleEn: "New note",
bodyAr: `${senderNameAr} أرسل لك ملاحظة`,
bodyEn: `${senderName} sent you a note`,
type: "note",
relatedId: note.id,
tag: `note-${note.id}`,
url: "/notes",
});
await emitToUser(r.recipientUserId, "note_received", {
noteId: note.id,
recipientRowId: r.id,
@@ -1478,6 +1491,18 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
})
.returning();
await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" });
void sendPushToUser(otherPartyUserId, {
title: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك",
body: `${replierNameAr} رد على ملاحظة`,
titleAr: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك",
titleEn: isOwner ? "New reply on a note" : "Reply to your note",
bodyAr: `${replierNameAr} رد على ملاحظة`,
bodyEn: `${replierName} replied on a note`,
type: "note",
relatedId: id.id,
tag: `note-reply-${reply.id}`,
url: "/notes",
});
// Enrich the realtime payload so the recipient's client can render the
// floating reply card without an extra round-trip. We truncate the body
// to keep the socket frame small; the full reply is still in /notes.
+79
View File
@@ -0,0 +1,79 @@
import { Router, type IRouter } from "express";
import { z } from "zod";
import { requireAuth } from "../middlewares/auth";
import {
getVapid,
upsertSubscription,
removeSubscription,
} from "../lib/push";
const router: IRouter = Router();
const SubscribeBody = z.object({
endpoint: z.string().url().max(2048),
keys: z.object({
p256dh: z.string().min(1).max(512),
auth: z.string().min(1).max(512),
}),
});
const UnsubscribeBody = z.object({
endpoint: z.string().url().max(2048),
});
router.get("/push/vapid-public-key", async (_req, res): Promise<void> => {
const vapid = await getVapid();
if (!vapid) {
res.status(503).json({ error: "Web Push not configured" });
return;
}
res.json({ publicKey: vapid.publicKey });
});
router.post("/push/subscribe", requireAuth, async (req, res): Promise<void> => {
const parsed = SubscribeBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const userId = req.session.userId!;
const ua = (req.headers["user-agent"] as string | undefined) ?? null;
await upsertSubscription(userId, {
endpoint: parsed.data.endpoint,
p256dh: parsed.data.keys.p256dh,
auth: parsed.data.keys.auth,
userAgent: ua,
});
res.json({ success: true });
});
async function handleUnsubscribe(
req: Parameters<typeof requireAuth>[0] extends never ? never : Parameters<Parameters<typeof router.post>[2]>[0],
res: Parameters<Parameters<typeof router.post>[2]>[1],
): Promise<void> {
// Accept the endpoint from either the JSON body (POST) or the query
// string (DELETE). Browsers don't always send a body with DELETE, so
// the query-string form keeps the spec-friendly verb usable.
const candidate = {
endpoint:
(req.body && typeof req.body.endpoint === "string"
? req.body.endpoint
: undefined) ?? (req.query.endpoint as string | undefined),
};
const parsed = UnsubscribeBody.safeParse(candidate);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const userId = req.session.userId!;
await removeSubscription(userId, parsed.data.endpoint);
res.json({ success: true });
}
router.post("/push/unsubscribe", requireAuth, handleUnsubscribe);
// Spec-aligned alias: DELETE /api/push/subscribe?endpoint=... removes
// the caller's subscription. The POST form above stays for backward
// compat with clients that already shipped.
router.delete("/push/subscribe", requireAuth, handleUnsubscribe);
export default router;
@@ -12,6 +12,7 @@ import {
permissionsTable,
} from "@workspace/db";
import { requireAuth, requirePermission } from "../middlewares/auth";
import { sendPushToUser } from "../lib/push";
import {
CreateServiceOrderBody,
UpdateServiceOrderStatusParams as ServiceOrderIdParams,
@@ -133,6 +134,18 @@ async function notifyUser(
})
.returning();
await emitToUser(userId, "notification_created", notification);
void sendPushToUser(userId, {
title: titleAr,
body: bodyAr,
titleAr,
titleEn,
bodyAr,
bodyEn,
type: "order",
relatedId: orderId,
tag: `order-${orderId}`,
url: "/my-orders",
});
}
async function broadcastIncomingChanged(receiverIds: number[]) {
+113
View File
@@ -0,0 +1,113 @@
import { Router, type IRouter } from "express";
import semver from "semver";
import { requireAdmin } from "../middlewares/auth";
import versionFile from "../../../../version.json" with { type: "json" };
import { GetSystemVersionResponse } from "@workspace/api-zod";
const router: IRouter = Router();
const CURRENT_VERSION: string = versionFile.version;
const CURRENT_BUILD: string = versionFile.build;
const LATEST_URL = process.env.LATEST_VERSION_URL ?? "";
const FETCH_TIMEOUT_MS = 5000;
type LatestPayload = {
version?: unknown;
build?: unknown;
};
router.get("/system/version", requireAdmin, async (_req, res): Promise<void> => {
const checkedAt = new Date().toISOString();
if (!LATEST_URL) {
res.json(
GetSystemVersionResponse.parse({
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
latest: null,
status: "not-configured",
errorMessage: null,
checkedAt,
configured: false,
}),
);
return;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(LATEST_URL, {
signal: controller.signal,
headers: { Accept: "application/json" },
});
if (!response.ok) {
res.json(
GetSystemVersionResponse.parse({
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
latest: null,
status: "error",
errorMessage: `HTTP ${response.status}`,
checkedAt,
configured: true,
}),
);
return;
}
const body = (await response.json()) as LatestPayload;
const rawVersion = typeof body.version === "string" ? body.version : "";
const rawBuild = typeof body.build === "string" ? body.build : "";
const cleaned = semver.valid(semver.coerce(rawVersion));
const localCleaned = semver.valid(semver.coerce(CURRENT_VERSION));
if (!cleaned || !localCleaned) {
res.json(
GetSystemVersionResponse.parse({
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
latest: { version: rawVersion, build: rawBuild },
status: "error",
errorMessage: "Invalid version format",
checkedAt,
configured: true,
}),
);
return;
}
// semver.gt: strictly greater → update available. Equal or older →
// up to date (we never tell the user to "downgrade" — the desktop
// build is the source of truth for that scenario).
const status = semver.gt(cleaned, localCleaned)
? "update-available"
: "up-to-date";
res.json(
GetSystemVersionResponse.parse({
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
latest: { version: rawVersion, build: rawBuild },
status,
errorMessage: null,
checkedAt,
configured: true,
}),
);
} catch (err) {
const message =
err instanceof Error
? err.name === "AbortError"
? "Request timed out"
: err.message
: "Unknown error";
res.json(
GetSystemVersionResponse.parse({
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
latest: null,
status: "error",
errorMessage: message,
checkedAt,
configured: true,
}),
);
} finally {
clearTimeout(timer);
}
});
export default router;
@@ -0,0 +1,327 @@
// True unit test for the 404/410 cleanup path in sendPushToUser.
//
// Runs in-process via `tsx` so we can `mock.module('web-push', ...)` to
// stub the actual delivery call. We:
// 1. Insert a real row in `push_subscriptions` for a freshly-created
// throwaway user.
// 2. Force the mocked `webpush.sendNotification` to throw a 410 once
// (and 404 once) the way the FCM/APNs bridges do.
// 3. Call `sendPushToUser` directly and assert the row was pruned.
//
// This is the closest we can get to exercising the production prune
// path without standing up a real push gateway, and it exercises the
// real DB + real lib code (only `web-push` and the socket-presence
// check are stubbed).
//
// Run with:
// pnpm --filter @workspace/api-server exec \
// node --import tsx --test tests/push-410-unit.test.ts
import { test, before, after, mock } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
// Track sendNotification calls and let each test rig the next response.
const sendCalls: Array<{ endpoint: string; payload: string }> = [];
let nextThrow: { statusCode: number } | null = null;
mock.module("web-push", {
defaultExport: {
setVapidDetails: () => {},
generateVAPIDKeys: () => ({
publicKey: "B".padEnd(87, "A"),
privateKey: "C".padEnd(43, "A"),
}),
sendNotification: async (sub: { endpoint: string }, payload: string) => {
sendCalls.push({ endpoint: sub.endpoint, payload });
if (nextThrow) {
const err: Error & { statusCode?: number } = new Error("stubbed");
err.statusCode = nextThrow.statusCode;
nextThrow = null;
throw err;
}
return { statusCode: 201, body: "", headers: {} };
},
},
});
// Block the socket-presence dedup gate from short-circuiting the send.
// `sendPushToUser` lazy-imports `../index.js` to read `io`; throwing
// here makes `isUserConnected` swallow the error and return false.
mock.module("../src/index.js", {
namedExports: {
get io() {
throw new Error("io not available in unit test");
},
},
});
// Ensure VAPID is configured (the lib has its own fallback, but being
// explicit avoids surprise re-keying between runs).
process.env.VAPID_PUBLIC_KEY =
process.env.VAPID_PUBLIC_KEY ?? "B".padEnd(87, "A");
process.env.VAPID_PRIVATE_KEY =
process.env.VAPID_PRIVATE_KEY ?? "C".padEnd(43, "A");
process.env.VAPID_SUBJECT = process.env.VAPID_SUBJECT ?? "mailto:test@example.com";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds: number[] = [];
async function createUser(prefix: string): Promise<number> {
const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, '$2b$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $3, 'en', true)
RETURNING id`,
[username, `${username}@example.com`, prefix],
);
const id = rows[0].id as number;
createdUserIds.push(id);
return id;
}
async function insertSub(userId: number, endpoint: string): Promise<void> {
await pool.query(
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
VALUES ($1, $2, 'p256dh_test', 'auth_test')`,
[userId, endpoint],
);
}
async function rowExists(endpoint: string): Promise<boolean> {
const r = await pool.query(
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
[endpoint],
);
return (r.rowCount ?? 0) > 0;
}
// Import AFTER mocks are registered.
const { sendPushToUser } = await import("../src/lib/push.js");
before(() => {
// Touch sendCalls so the import is exercised.
sendCalls.length = 0;
});
after(async () => {
if (createdUserIds.length > 0) {
await pool.query(
`DELETE FROM push_subscriptions WHERE user_id = ANY($1::int[])`,
[createdUserIds],
);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
createdUserIds,
]);
}
await pool.end();
});
test("410 Gone response prunes the stale push subscription", async () => {
const userId = await createUser("p410");
const endpoint = `https://push.example.test/410-${Date.now()}`;
await insertSub(userId, endpoint);
assert.equal(await rowExists(endpoint), true);
nextThrow = { statusCode: 410 };
await sendPushToUser(userId, {
title: "t",
body: "b",
type: "note",
});
assert.equal(sendCalls.at(-1)?.endpoint, endpoint, "stub should have been called");
assert.equal(
await rowExists(endpoint),
false,
"row must be pruned after a 410 response",
);
});
test("404 Not Found response prunes the stale push subscription", async () => {
const userId = await createUser("p404");
const endpoint = `https://push.example.test/404-${Date.now()}`;
await insertSub(userId, endpoint);
assert.equal(await rowExists(endpoint), true);
nextThrow = { statusCode: 404 };
await sendPushToUser(userId, {
title: "t",
body: "b",
type: "note",
});
assert.equal(
await rowExists(endpoint),
false,
"row must be pruned after a 404 response",
);
});
test("non-410/404 errors leave the subscription intact", async () => {
const userId = await createUser("p500");
const endpoint = `https://push.example.test/500-${Date.now()}`;
await insertSub(userId, endpoint);
nextThrow = { statusCode: 500 };
await sendPushToUser(userId, {
title: "t",
body: "b",
type: "note",
});
assert.equal(
await rowExists(endpoint),
true,
"transient 5xx must NOT delete the subscription",
);
});
test("notificationsMuted=true silences every channel", async () => {
const userId = await createUser("pmute");
const endpoint = `https://push.example.test/mute-${Date.now()}`;
await insertSub(userId, endpoint);
await pool.query(
`UPDATE users SET notifications_muted = true WHERE id = $1`,
[userId],
);
const before = sendCalls.length;
await sendPushToUser(userId, {
title: "t",
body: "b",
type: "note",
});
assert.equal(
sendCalls.length,
before,
"muted user must not trigger sendNotification at all",
);
assert.equal(
await rowExists(endpoint),
true,
"muting must not delete the subscription",
);
});
test("per-channel flag off skips that channel only", async () => {
const userId = await createUser("pchan");
const endpoint = `https://push.example.test/chan-${Date.now()}`;
await insertSub(userId, endpoint);
await pool.query(
`UPDATE users SET notify_notes_enabled = false WHERE id = $1`,
[userId],
);
const before = sendCalls.length;
await sendPushToUser(userId, {
title: "t",
body: "b",
type: "note",
});
assert.equal(
sendCalls.length,
before,
"notes-disabled user must not receive a note push",
);
// Same user, different channel — order push must still fire.
await sendPushToUser(userId, {
title: "t",
body: "b",
type: "order",
});
assert.equal(
sendCalls.length,
before + 1,
"order channel must still fire when only notes is off",
);
});
test("oversized body is truncated so the wire payload stays under 4096 bytes", async () => {
const userId = await createUser("ptrunc");
const endpoint = `https://push.example.test/trunc-${Date.now()}`;
await insertSub(userId, endpoint);
// 8000 bytes of ASCII — comfortably above the 3500-byte budget and
// way past the 4096-byte web-push cap, so truncation MUST kick in.
const huge = "x".repeat(8000);
await sendPushToUser(userId, {
title: "t",
body: huge,
type: "note",
});
const sent = sendCalls.at(-1);
assert.ok(sent, "send stub should have been called");
const size = Buffer.byteLength(sent.payload, "utf8");
assert.ok(
size < 4096,
`truncated payload must fit under 4096 bytes, got ${size}`,
);
const parsed = JSON.parse(sent.payload) as { body: string };
assert.ok(
parsed.body.length < huge.length,
"body must have been shortened",
);
});
test("preferredLanguage=en picks the English title/body", async () => {
const userId = await createUser("plangen");
await pool.query(
`UPDATE users SET preferred_language = 'en' WHERE id = $1`,
[userId],
);
const endpoint = `https://push.example.test/langen-${Date.now()}`;
await insertSub(userId, endpoint);
await sendPushToUser(userId, {
title: "ar-fallback",
body: "ar-fallback",
titleAr: "ملاحظة جديدة",
titleEn: "New note",
bodyAr: "أرسل لك ملاحظة",
bodyEn: "sent you a note",
type: "note",
});
const sent = sendCalls.at(-1);
assert.ok(sent, "send stub should have been called");
const parsed = JSON.parse(sent.payload) as { title: string; body: string };
assert.equal(parsed.title, "New note");
assert.equal(parsed.body, "sent you a note");
});
test("preferredLanguage=ar picks the Arabic title/body", async () => {
const userId = await createUser("plangar");
// Default is already 'ar' but be explicit so the test documents intent.
await pool.query(
`UPDATE users SET preferred_language = 'ar' WHERE id = $1`,
[userId],
);
const endpoint = `https://push.example.test/langar-${Date.now()}`;
await insertSub(userId, endpoint);
await sendPushToUser(userId, {
title: "ignored",
body: "ignored",
titleAr: "ملاحظة جديدة",
titleEn: "New note",
bodyAr: "أرسل لك ملاحظة",
bodyEn: "sent you a note",
type: "note",
});
const sent = sendCalls.at(-1);
assert.ok(sent, "send stub should have been called");
const parsed = JSON.parse(sent.payload) as { title: string; body: string };
assert.equal(parsed.title, "ملاحظة جديدة");
assert.equal(parsed.body, "أرسل لك ملاحظة");
});
+280
View File
@@ -0,0 +1,280 @@
// API tests for the Web Push subsystem:
// - GET /api/push/vapid-public-key
// - POST /api/push/subscribe (auth + upsert)
// - POST /api/push/unsubscribe (auth + removal)
// - account-switch row reassignment for the same browser endpoint
//
// The full delivery path (web-push -> 410 cleanup) is exercised via a
// direct DB-level simulation: we insert a subscription with a known
// endpoint, then assert it can be looked up + removed through the
// public API. The 410 cleanup itself runs in the server when push
// providers reject — covered by reading the helper from
// `dist/index.mjs` would require a separate integration runner, so
// here we cover the DB/HTTP contract.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds = [];
const createdEndpoints = [];
function uniq() {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
async function createUser(prefix) {
const username = `${prefix}_${uniq()}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
);
const id = rows[0].id;
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'user'`,
[id],
);
return { id, username };
}
async function login(username) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password: TEST_PASSWORD }),
});
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
const setCookie = res.headers.get("set-cookie");
const sid = setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid="));
assert.ok(sid, "expected connect.sid cookie");
return sid;
}
function fakeSub(suffix) {
const endpoint = `https://fcm.example.test/push/${suffix}_${uniq()}`;
createdEndpoints.push(endpoint);
return {
endpoint,
keys: {
p256dh: "BPubKey-" + suffix,
auth: "auth-" + suffix,
},
};
}
before(async () => {
// Sanity: server up?
const r = await fetch(`${API_BASE}/api/healthz`).catch(() => null);
if (!r || !r.ok) {
throw new Error(`API at ${API_BASE} is not reachable`);
}
});
after(async () => {
if (createdEndpoints.length > 0) {
await pool.query(
`DELETE FROM push_subscriptions WHERE endpoint = ANY($1::text[])`,
[createdEndpoints],
);
}
if (createdUserIds.length > 0) {
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
createdUserIds,
]);
}
await pool.end();
});
test("GET /api/push/vapid-public-key returns a base64url key", async () => {
const res = await fetch(`${API_BASE}/api/push/vapid-public-key`);
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(
typeof body.publicKey === "string" && body.publicKey.length > 40,
"expected non-empty VAPID public key",
);
});
test("POST /api/push/subscribe requires auth", async () => {
const res = await fetch(`${API_BASE}/api/push/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fakeSub("noauth")),
});
assert.equal(res.status, 401);
});
test("POST /api/push/unsubscribe requires auth", async () => {
const res = await fetch(`${API_BASE}/api/push/unsubscribe`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: "https://example.test/x" }),
});
assert.equal(res.status, 401);
});
test("subscribe persists a row and unsubscribe removes it", async () => {
const user = await createUser("push_basic");
const cookie = await login(user.username);
const sub = fakeSub("basic");
const subRes = await fetch(`${API_BASE}/api/push/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify(sub),
});
assert.equal(subRes.status, 200);
const rowsAfter = await pool.query(
`SELECT user_id, p256dh, auth FROM push_subscriptions WHERE endpoint = $1`,
[sub.endpoint],
);
assert.equal(rowsAfter.rowCount, 1, "expected one row after subscribe");
assert.equal(rowsAfter.rows[0].user_id, user.id);
assert.equal(rowsAfter.rows[0].p256dh, sub.keys.p256dh);
assert.equal(rowsAfter.rows[0].auth, sub.keys.auth);
const unsubRes = await fetch(`${API_BASE}/api/push/unsubscribe`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
assert.equal(unsubRes.status, 200);
const rowsGone = await pool.query(
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
[sub.endpoint],
);
assert.equal(rowsGone.rowCount, 0, "expected row removed after unsubscribe");
});
test("subscribe is idempotent — re-subscribing updates keys without duplicating", async () => {
const user = await createUser("push_idem");
const cookie = await login(user.username);
const sub = fakeSub("idem");
for (const variant of [sub, { ...sub, keys: { p256dh: "rotated", auth: "rotated" } }]) {
const res = await fetch(`${API_BASE}/api/push/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify(variant),
});
assert.equal(res.status, 200);
}
const rows = await pool.query(
`SELECT p256dh, auth FROM push_subscriptions WHERE endpoint = $1`,
[sub.endpoint],
);
assert.equal(rows.rowCount, 1);
assert.equal(rows.rows[0].p256dh, "rotated");
assert.equal(rows.rows[0].auth, "rotated");
});
test("subscribe reassigns endpoint to the new user when a different user signs in on the same browser", async () => {
const userA = await createUser("push_a");
const userB = await createUser("push_b");
const cookieA = await login(userA.username);
const cookieB = await login(userB.username);
const sub = fakeSub("switch");
let res = await fetch(`${API_BASE}/api/push/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookieA },
body: JSON.stringify(sub),
});
assert.equal(res.status, 200);
res = await fetch(`${API_BASE}/api/push/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookieB },
body: JSON.stringify(sub),
});
assert.equal(res.status, 200);
const rows = await pool.query(
`SELECT user_id FROM push_subscriptions WHERE endpoint = $1`,
[sub.endpoint],
);
assert.equal(rows.rowCount, 1, "endpoint should never duplicate across users");
assert.equal(
rows.rows[0].user_id,
userB.id,
"endpoint should now belong to the most recent signer-in",
);
});
test("subscribe rejects malformed input", async () => {
const user = await createUser("push_bad");
const cookie = await login(user.username);
const res = await fetch(`${API_BASE}/api/push/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ endpoint: "not-a-url", keys: {} }),
});
assert.equal(res.status, 400);
});
test("DELETE /api/push/subscribe?endpoint=... removes the row (spec alias)", async () => {
const user = await createUser("push_del");
const cookie = await login(user.username);
const sub = fakeSub("del");
const subRes = await fetch(`${API_BASE}/api/push/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify(sub),
});
assert.equal(subRes.status, 200);
const before = await pool.query(
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
[sub.endpoint],
);
assert.equal(before.rowCount, 1);
const delRes = await fetch(
`${API_BASE}/api/push/subscribe?endpoint=${encodeURIComponent(sub.endpoint)}`,
{ method: "DELETE", headers: { Cookie: cookie } },
);
assert.equal(delRes.status, 200);
const after = await pool.query(
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
[sub.endpoint],
);
assert.equal(after.rowCount, 0, "DELETE alias must remove the subscription row");
});
test("DELETE /api/push/subscribe requires auth and a valid endpoint", async () => {
const noAuth = await fetch(
`${API_BASE}/api/push/subscribe?endpoint=https://x.example/abc`,
{ method: "DELETE" },
);
assert.equal(noAuth.status, 401);
const user = await createUser("push_del_bad");
const cookie = await login(user.username);
const bad = await fetch(
`${API_BASE}/api/push/subscribe?endpoint=not-a-url`,
{ method: "DELETE", headers: { Cookie: cookie } },
);
assert.equal(bad.status, 400);
});
+22 -1
View File
@@ -4,7 +4,28 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<title>Tx OS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico?v=itp1" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png?v=itp1" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png?v=itp1" />
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48.png?v=itp1" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png?v=itp1" />
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png?v=itp1" />
<!--
PWA / "Add to Home Screen" — when the user installs Tx OS to the
iPad/iPhone home screen (Share → Add to Home Screen) the app
launches full-screen with no Safari URL bar. The same manifest
also makes the app installable from Chrome (Add to Dock / Install).
Note: existing home-screen shortcuts created BEFORE these tags
shipped will keep opening in regular Safari with the URL bar —
remove and re-add the icon to pick up the manifest.
-->
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=itp1" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Tx OS" />
<meta name="theme-color" content="#0B1E3F" />
<!--
Fonts are now bundled locally via `src/custom-fonts.css`
(DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic,
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

-7
View File
@@ -1,7 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="14" fill="#0B1E3F"/>
<rect x="10" y="14" width="44" height="10" rx="2" fill="#FFFFFF"/>
<rect x="27" y="14" width="10" height="28" rx="2" fill="#FFFFFF"/>
<path d="M27 42 L15 54" stroke="#38BDF8" stroke-width="8" stroke-linecap="round"/>
<path d="M37 42 L49 54" stroke="#38BDF8" stroke-width="8" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

@@ -0,0 +1,30 @@
{
"name": "Tx OS",
"short_name": "Tx OS",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0B1E3F",
"theme_color": "#0B1E3F",
"icons": [
{
"src": "/icons/icon-192.png?v=itp1",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png?v=itp1",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png?v=itp1",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+87
View File
@@ -0,0 +1,87 @@
/* Tx OS service worker — Web Push only.
*
* Scope: registered with `scope: BASE_URL`, so the SW only controls
* paths under the SPA's base path (e.g. `/`, or `/tx-os/` in a future
* subpath deployment). Every URL the SW touches — icons, navigation
* targets — is resolved against `self.registration.scope` so the same
* code works at root and at a subpath without code changes.
*
* We deliberately do NOT cache app assets here — Tx OS is self-hosted
* on a single machine, so the network is fast and reliable. Adding
* cache layers would risk shipping stale React bundles after an
* upgrade.
*/
function scopedUrl(relative) {
// self.registration.scope is the full origin + base path with a
// trailing slash (e.g. "https://host/" or "https://host/tx-os/").
// Strip a leading slash from `relative` so URL() treats it as a
// path under scope rather than origin-relative.
const cleaned = String(relative || "").replace(/^\/+/, "");
return new URL(cleaned, self.registration.scope).pathname;
}
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("push", (event) => {
/** @type {{title?: string, body?: string, tag?: string, url?: string, type?: string}} */
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
payload = { title: event.data ? event.data.text() : "Tx OS" };
}
const title = payload.title || "Tx OS";
const iconUrl = scopedUrl("icons/icon-192.png");
const options = {
body: payload.body || "",
tag: payload.tag || payload.type || "tx-os",
renotify: true,
icon: iconUrl,
badge: iconUrl,
data: {
url: payload.url || "/",
type: payload.type || null,
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const rawTarget = (event.notification.data && event.notification.data.url) || "/";
const targetPath = scopedUrl(rawTarget);
event.waitUntil(
(async () => {
const allClients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
// Prefer an already-open Tx OS tab under our scope; navigate it
// to the target and focus.
for (const client of allClients) {
try {
await client.focus();
if ("navigate" in client) {
await client.navigate(targetPath);
}
return;
} catch {
/* try next */
}
}
if (self.clients.openWindow) {
await self.clients.openWindow(targetPath);
}
})(),
);
});
+2
View File
@@ -9,6 +9,7 @@ import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
import { useAutoplayHint } from "@/hooks/use-autoplay-hint";
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
import { PushEnablePrompt } from "@/components/push-enable-prompt";
import NotFound from "@/pages/not-found";
import LoginPage from "@/pages/login";
import RegisterPage from "@/pages/register";
@@ -55,6 +56,7 @@ function Router() {
<NotificationsSocketBridge />
<UpcomingMeetingAlert />
<IncomingNotePopup />
<PushEnablePrompt />
<Switch>
<Route path="/login" component={LoginPage} />
<Route path="/register" component={RegisterPage} />
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
type AnalogClockProps = {
size?: number;
className?: string;
};
export function AnalogClock({ size = 44, className = "" }: AnalogClockProps) {
const [now, setNow] = useState<Date>(() => new Date());
useEffect(() => {
const id = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(id);
}, []);
const hours = now.getHours() % 12;
const minutes = now.getMinutes();
const seconds = now.getSeconds();
const hourAngle = (hours + minutes / 60) * 30;
const minuteAngle = (minutes + seconds / 60) * 6;
const secondAngle = seconds * 6;
const cx = 50;
const cy = 50;
const navy = "#0B1E3F";
const gold = "#C9A04C";
return (
<svg
width={size}
height={size}
viewBox="0 0 100 100"
className={className}
role="img"
aria-label="analog clock"
data-testid="em-analog-clock"
>
<circle cx={cx} cy={cy} r="46" fill="#ffffff" stroke={navy} strokeWidth="3" />
{Array.from({ length: 12 }).map((_, i) => {
const angle = (i * 30 * Math.PI) / 180;
const x1 = cx + Math.sin(angle) * 40;
const y1 = cy - Math.cos(angle) * 40;
const x2 = cx + Math.sin(angle) * 44;
const y2 = cy - Math.cos(angle) * 44;
return (
<line
key={i}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={navy}
strokeWidth={i % 3 === 0 ? 2.5 : 1.5}
strokeLinecap="round"
/>
);
})}
<g transform={`rotate(${hourAngle} ${cx} ${cy})`}>
<line x1={cx} y1={cy + 6} x2={cx} y2={cy - 24} stroke={navy} strokeWidth="4" strokeLinecap="round" />
</g>
<g transform={`rotate(${minuteAngle} ${cx} ${cy})`}>
<line x1={cx} y1={cy + 8} x2={cx} y2={cy - 34} stroke={navy} strokeWidth="2.75" strokeLinecap="round" />
</g>
<g transform={`rotate(${secondAngle} ${cx} ${cy})`}>
<line x1={cx} y1={cy + 10} x2={cx} y2={cy - 38} stroke={gold} strokeWidth="1.5" strokeLinecap="round" />
</g>
<circle cx={cx} cy={cy} r="3" fill={navy} />
<circle cx={cx} cy={cy} r="1.25" fill={gold} />
</svg>
);
}
@@ -1436,7 +1436,12 @@ function PostponeDialog({
data-testid="postpone-dialog"
className="sm:max-w-2xl"
>
<DialogHeader className="space-y-1">
{/* #563: reserve pr-8 (~2rem) so the Radix close button
(positioned right-4 top-4 inside DialogContent) never
overlaps the title/description. Physical right padding is
correct for BOTH AR (RTL) and LTR because Radix uses CSS
physical positioning for the close. */}
<DialogHeader className="space-y-1 pr-8">
<DialogTitle className="text-base">
{t("executiveMeetings.alert.postponeTitle")}
</DialogTitle>
@@ -1741,11 +1746,21 @@ function PostponeDialog({
onBack={() => setCascadePrompt(null)}
/>
) : null}
{/* Responsive grid: 1 col on narrow / 2 cols on sm /
3 cols on md+. Prevents horizontal overflow inside the
dialog on phones and split panes. */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
{/* #563: responsive grid — single column up through iPad
portrait, then 3 columns in a single row on iPad
landscape / desktop. We use `lg:` (≥1024px) because
Tailwind's `md:` breakpoint is 768px, which iPad
portrait already hits (768820 CSS px) and would
re-introduce the same date/time collision the task
targets. The previous `sm:grid-cols-2 lg:grid-cols-3`
packed date + start onto one row on iPad portrait
where Safari's native date/time pickers carry wide
internal icons + Arabic labels and visibly collided.
`min-w-0` lets each column shrink inside its grid
track so inputs respect their cell, and `w-full` on
the Input ensures it fills the cell. */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<div className="space-y-1 min-w-0">
<Label className="text-xs">
{t("executiveMeetings.alert.rescheduleDate")}
</Label>
@@ -1755,9 +1770,10 @@ function PostponeDialog({
onChange={(e) => setResDate(e.target.value)}
disabled={busy !== null}
data-testid="reschedule-date"
className="w-full"
/>
</div>
<div className="space-y-1">
<div className="space-y-1 min-w-0">
<Label className="text-xs">
{t("executiveMeetings.alert.rescheduleStart")}
</Label>
@@ -1767,9 +1783,10 @@ function PostponeDialog({
onChange={(e) => setResStart(e.target.value)}
disabled={busy !== null}
data-testid="reschedule-start"
className="w-full"
/>
</div>
<div className="space-y-1">
<div className="space-y-1 min-w-0">
<Label className="text-xs">
{t("executiveMeetings.alert.rescheduleEnd")}
</Label>
@@ -1779,6 +1796,7 @@ function PostponeDialog({
onChange={(e) => setResEnd(e.target.value)}
disabled={busy !== null}
data-testid="reschedule-end"
className="w-full"
/>
</div>
</div>
@@ -1811,9 +1829,9 @@ function PostponeDialog({
className="space-y-2 focus:outline-none"
data-testid="postpone-cancel-panel"
>
<p className="text-xs text-muted-foreground">
{t("executiveMeetings.alert.cancelHint")}
</p>
{/* #563: cancelHint paragraph removed per user request —
the destructive button + confirm prompt convey intent
without the long disclaimer. */}
{!confirmCancel ? (
<Button
type="button"
@@ -8,6 +8,7 @@ import {
type NotificationSoundId,
} from "@workspace/api-client-react";
import { Bell, BellOff, Check, Volume2, VolumeX, Play } from "lucide-react";
import { usePushSubscription } from "@/hooks/use-push-subscription";
import { useToast } from "@/hooks/use-toast";
import {
Popover,
@@ -315,6 +316,10 @@ export function NotificationSettingsContent() {
<div className="h-px bg-foreground/10 mx-1 my-1" />
<PushToggleRow />
<div className="h-px bg-foreground/10 mx-1 my-1" />
<SoundList
current={user[cfg.soundKey]}
onPick={(id) =>
@@ -325,6 +330,69 @@ export function NotificationSettingsContent() {
);
}
function PushToggleRow() {
const { t } = useTranslation();
const { toast } = useToast();
const { status, busy, enable, disable } = usePushSubscription();
const subscribed = status === "subscribed";
const disabled = busy || status === "unsupported" || status === "denied";
const subtitle =
status === "unsupported"
? t("notifSettings.push.unsupported")
: status === "denied"
? t("notifSettings.push.denied")
: subscribed
? t("notifSettings.push.enabled")
: t("notifSettings.push.desc");
return (
<div className="px-2 py-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-foreground truncate">
{t("notifSettings.push.title")}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
{subtitle}
</div>
</div>
<button
type="button"
disabled={disabled}
data-testid="notif-push-toggle"
onClick={async () => {
if (subscribed) {
await disable();
} else {
const ok = await enable();
if (!ok) {
toast({ title: t("notifSettings.push.enableFailed") });
}
}
}}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
subscribed ? "bg-primary" : "bg-foreground/15"
} ${disabled ? "opacity-50" : ""}`}
aria-pressed={subscribed}
aria-label={
subscribed
? t("notifSettings.push.disable")
: t("notifSettings.push.enable")
}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
subscribed
? "translate-x-[18px] rtl:translate-x-0.5"
: "translate-x-0.5 rtl:translate-x-[18px]"
}`}
/>
</button>
</div>
</div>
);
}
export function NotificationSettingsPicker() {
const { t } = useTranslation();
const { user } = useAuth();
@@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Bell, X } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { usePushSubscription } from "@/hooks/use-push-subscription";
import { useToast } from "@/hooks/use-toast";
const DISMISS_KEY = "tx.pushPrompt.dismissedAt";
// Re-prompt after 14 days if the user dismissed but never opted in.
const RE_PROMPT_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
/**
* One-tap "Enable lock-screen alerts" card surfaced after the user logs
* in for the first time on a device. Renders only when:
* - the user is authenticated
* - Web Push is supported (Notifications API + SW + PushManager)
* - permission is still "default" (we haven't asked yet)
* - the user has no active subscription on this device
* - they haven't dismissed the prompt recently
*
* On unsupported devices (notably iOS Safari before "Add to Home
* Screen") we stay silent — the toggle inside Notification Settings
* exposes the relevant copy for power users. We don't want a
* permanent "not supported" banner cluttering the home screen.
*/
export function PushEnablePrompt() {
const { t } = useTranslation();
const { user } = useAuth();
const { toast } = useToast();
const { status, busy, enable } = usePushSubscription();
const [hidden, setHidden] = useState(true);
useEffect(() => {
if (!user) {
setHidden(true);
return;
}
if (status !== "default") {
setHidden(true);
return;
}
try {
const raw = localStorage.getItem(DISMISS_KEY);
if (raw) {
const ts = Number(raw);
if (
Number.isFinite(ts) &&
Date.now() - ts < RE_PROMPT_AFTER_MS
) {
setHidden(true);
return;
}
}
} catch {
/* localStorage unavailable — just show the prompt */
}
setHidden(false);
}, [user, status]);
if (hidden) return null;
const dismiss = () => {
try {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
} catch {
/* ignore */
}
setHidden(true);
};
return (
<div
role="dialog"
aria-live="polite"
data-testid="push-enable-prompt"
className="fixed inset-x-3 bottom-3 z-50 md:left-auto md:right-4 md:bottom-4 md:max-w-sm rounded-2xl border border-foreground/10 bg-background/95 backdrop-blur shadow-lg p-3"
>
<div className="flex items-start gap-3">
<div className="shrink-0 rounded-full bg-primary/15 p-2">
<Bell size={18} className="text-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">
{t("notifSettings.push.title")}
</div>
<div className="text-xs text-muted-foreground mt-0.5 leading-snug">
{t("notifSettings.push.desc")}
</div>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
disabled={busy}
data-testid="push-enable-prompt-enable"
onClick={async () => {
const ok = await enable();
if (ok) {
dismiss();
} else {
toast({ title: t("notifSettings.push.enableFailed") });
}
}}
className="text-xs font-semibold px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50"
>
{t("notifSettings.push.enable")}
</button>
<button
type="button"
data-testid="push-enable-prompt-dismiss"
onClick={dismiss}
className="text-xs font-medium px-3 py-1.5 rounded-md text-muted-foreground hover:bg-foreground/5"
>
{t("common.later")}
</button>
</div>
</div>
<button
type="button"
aria-label={t("common.close")}
onClick={dismiss}
className="shrink-0 text-muted-foreground hover:text-foreground p-1 rounded-md hover:bg-foreground/5"
>
<X size={14} />
</button>
</div>
</div>
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:ltr:translate-x-4 data-[state=checked]:rtl:-translate-x-4 data-[state=unchecked]:translate-x-0"
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:ltr:translate-x-4 data-[state=checked]:rtl:-translate-x-4 data-[state=unchecked]:ltr:translate-x-0 data-[state=unchecked]:rtl:translate-x-4"
)}
/>
</SwitchPrimitives.Root>
@@ -0,0 +1,162 @@
import { useCallback, useEffect, useState } from "react";
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
type PushStatus =
| "unsupported"
| "denied"
| "default"
| "subscribed";
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(b64);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function isSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
if (!("serviceWorker" in navigator)) return null;
// Wait for the SPA's registration in main.tsx to settle. `ready`
// resolves once an active SW controls the page.
return navigator.serviceWorker.ready;
}
/**
* Hook that exposes the user's current Web Push subscription state and
* `enable()` / `disable()` actions. Talks to the backend at
* `${BASE}/api/push/*`.
*
* Web Push on iOS Safari only works when the page is installed as a
* PWA (Add to Home Screen). The hook surfaces "unsupported" in that
* case so the UI can prompt the user to install first.
*/
export function usePushSubscription() {
const [status, setStatus] = useState<PushStatus>(() =>
!isSupported()
? "unsupported"
: Notification.permission === "denied"
? "denied"
: "default",
);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
if (!isSupported()) {
setStatus("unsupported");
return;
}
if (Notification.permission === "denied") {
setStatus("denied");
return;
}
const reg = await getRegistration();
const sub = await reg?.pushManager.getSubscription();
if (sub) setStatus("subscribed");
else setStatus(Notification.permission === "granted" ? "default" : "default");
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const enable = useCallback(async (): Promise<boolean> => {
if (!isSupported()) return false;
setBusy(true);
try {
const perm = await Notification.requestPermission();
if (perm !== "granted") {
setStatus(perm === "denied" ? "denied" : "default");
return false;
}
const reg = await getRegistration();
if (!reg) return false;
const keyRes = await fetch(`${BASE}/api/push/vapid-public-key`, {
credentials: "include",
});
if (!keyRes.ok) return false;
const { publicKey } = (await keyRes.json()) as { publicKey: string };
// If a stale subscription exists from a previous VAPID key,
// unsubscribe locally first so applicationServerKey isn't
// mismatched.
const existing = await reg.pushManager.getSubscription();
if (existing) {
try {
await existing.unsubscribe();
} catch {
/* ignore */
}
}
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
const json = sub.toJSON();
const subRes = await fetch(`${BASE}/api/push/subscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: json.endpoint,
keys: json.keys,
}),
});
if (!subRes.ok) {
try {
await sub.unsubscribe();
} catch {
/* ignore */
}
return false;
}
setStatus("subscribed");
return true;
} catch {
return false;
} finally {
setBusy(false);
}
}, []);
const disable = useCallback(async (): Promise<void> => {
if (!isSupported()) return;
setBusy(true);
try {
const reg = await getRegistration();
const sub = await reg?.pushManager.getSubscription();
if (sub) {
const endpoint = sub.endpoint;
try {
await sub.unsubscribe();
} catch {
/* ignore */
}
await fetch(`${BASE}/api/push/unsubscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
}).catch(() => {});
}
setStatus(Notification.permission === "denied" ? "denied" : "default");
} finally {
setBusy(false);
}
}, []);
return { status, busy, enable, disable, refresh };
}
+13
View File
@@ -4,6 +4,19 @@
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
/*
* Tailwind v4 doesn't ship `rtl:` / `ltr:` as built-in variants. The
* shared <Switch> (and a couple other components) rely on them to
* mirror the thumb translation in the Arabic UI — without these
* declarations, classes like `rtl:-translate-x-4` are silently
* dropped at build time and every toggle in the app shows the
* checked-state thumb on the LTR side regardless of document
* direction. The selectors match both the element that carries
* `dir="rtl"` itself and any descendant, which mirrors the
* tailwindcss-rtl plugin's behaviour.
*/
@custom-variant rtl (&:where([dir="rtl"], [dir="rtl"] *));
@custom-variant ltr (&:where([dir="ltr"], [dir="ltr"] *));
@theme inline {
--color-background: hsl(var(--background));
+33 -17
View File
@@ -127,7 +127,6 @@
"notesLabel": "ملاحظات:",
"cancel": "إلغاء الطلب",
"cancelConfirmTitle": "إلغاء هذا الطلب؟",
"cancelConfirmBody": "ستتاح لك ثوانٍ قليلة للتراجع.",
"cancelConfirm": "نعم، إلغاء",
"keep": "احتفظ به",
"cancelled": "تم إلغاء الطلب",
@@ -135,22 +134,9 @@
"restoreFailed": "تعذّر استعادة الطلب",
"delete": "حذف",
"reorder": "اطلب مجددًا",
"deleteConfirmTitle": "حذف هذا الطلب؟",
"deleteConfirmBody": "سيتم حذف الطلب نهائياً من سجلّك.",
"deleteConfirm": "نعم، احذف",
"deleted": "تم حذف الطلب",
"deleteFailed": "تعذّر حذف الطلب",
"undo": "تراجع",
"undone": "تمت الاستعادة",
"clearFinished_one": "مسح طلب منتهٍ",
"clearFinished_other": "مسح {{count}} طلبات منتهية",
"clearConfirmTitle": "مسح الطلبات المنتهية؟",
"clearConfirmBody_one": "سيتم حذف طلب واحد منتهٍ نهائياً من سجلّك.",
"clearConfirmBody_other": "سيتم حذف {{count}} طلبات منتهية نهائياً من سجلّك.",
"clearConfirm": "نعم، امسح",
"clearedCount_one": "تم حذف طلب واحد",
"clearedCount_other": "تم حذف {{count}} طلبات",
"clearedPartial": "تم حذف {{ok}} وفشل {{fail}}"
"undone": "تمت الاستعادة"
},
"orderStatus": {
"pending": "بانتظار المستلِم",
@@ -240,6 +226,16 @@
"autoplayHintTitle": "تحتاج الأصوات إلى نقرة",
"autoplayHint": "انقر في أي مكان لتفعيل أصوات الإشعارات.",
"autoplayHintIos": "اضغط في أي مكان لتفعيل الأصوات. إذا ما زلت لا تسمعها، تأكد إن مستوى صوت الوسائط مرفوع (نفس الصوت اللي يشغّل اليوتيوب).",
"push": {
"title": "إشعارات شاشة القفل",
"desc": "استقبل التنبيهات على الشاشة الرئيسية أو شاشة القفل حتى لو كان Tx مغلقاً.",
"enable": "تفعيل",
"disable": "إيقاف",
"enabled": "مفعّل",
"denied": "محظور — فعّله من إعدادات الجهاز",
"unsupported": "غير مدعوم على هذا الجهاز. على الآيباد، أضف Tx إلى الشاشة الرئيسية أولاً.",
"enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى."
},
"slot": {
"order": "الطلبات",
"meeting": "الاجتماعات",
@@ -532,8 +528,27 @@
"auditLog": "سجل التدقيق",
"userManagement": "إدارة المستخدمين",
"settings": "الإعدادات",
"systemUpdates": "تحديثات النظام",
"menu": "القائمة"
},
"systemUpdates": {
"title": "تحديثات النظام",
"subtitle": "تحقّق فقط من توفر إصدار أحدث — لا يقوم النظام بتثبيت أي تحديث.",
"currentVersion": "الإصدار الحالي",
"buildNumber": "رقم البناء",
"latestVersion": "أحدث إصدار",
"updateStatus": "حالة التحديث",
"statusUpToDate": "النظام محدّث",
"statusUpdateAvailable": "يتوفّر تحديث جديد",
"statusNotConfigured": "لم يتم تهيئة مصدر التحديثات",
"statusError": "تعذّر التحقق من التحديثات",
"notConfiguredHint": "اطلب من المسؤول ضبط متغيّر البيئة LATEST_VERSION_URL على رابط ملف latest.json الخارجي لتفعيل التحقق.",
"checkNow": "تحقّق الآن",
"loading": "جاري التحقق من الإصدار…",
"lastChecked": "آخر تحقق",
"toastErrorTitle": "تعذّر التحقق",
"toastErrorDesc": "لم نتمكن من الوصول إلى مصدر التحديثات. حاول مرة أخرى لاحقاً."
},
"apps": {
"counts": {
"groups": "{{count}} مجموعة",
@@ -920,6 +935,7 @@
"edit": "تعديل",
"add": "إضافة",
"close": "إغلاق",
"later": "لاحقاً",
"loading": "جاري التحميل...",
"error": "حدث خطأ",
"success": "تمت العملية بنجاح",
@@ -1238,7 +1254,6 @@
"rescheduleEnd": "النهاية",
"rescheduleNote": "ملاحظة (اختياري)",
"rescheduleApply": "إعادة الجدولة",
"cancelHint": "يحدد الاجتماع كمُلغى مع الاحتفاظ بسجل الحضور.",
"cancelApply": "إلغاء الاجتماع",
"cancelConfirmPrompt": "هل أنت متأكد؟ سيتم وضع الاجتماع كمُلغى.",
"cancelConfirmYes": "نعم، إلغاء",
@@ -1360,7 +1375,8 @@
"label": "إجراءات سريعة",
"moveUp": "نقل لأعلى",
"moveDown": "نقل لأسفل",
"postpone": "تأجيل"
"postpone": "تأجيل",
"edit": "تعديل"
},
"merge": {
"label": "دمج الخلايا",
+33 -17
View File
@@ -127,7 +127,6 @@
"notesLabel": "Notes:",
"cancel": "Cancel order",
"cancelConfirmTitle": "Cancel this order?",
"cancelConfirmBody": "You'll have a few seconds to undo this.",
"cancelConfirm": "Yes, cancel",
"keep": "Keep it",
"cancelled": "Order cancelled",
@@ -135,22 +134,9 @@
"restoreFailed": "Could not restore the order",
"delete": "Delete",
"reorder": "Reorder",
"deleteConfirmTitle": "Delete this order?",
"deleteConfirmBody": "This will permanently remove the order from your history.",
"deleteConfirm": "Yes, delete",
"deleted": "Order deleted",
"deleteFailed": "Could not delete the order",
"undo": "Undo",
"undone": "Restored",
"clearFinished_one": "Clear 1 finished",
"clearFinished_other": "Clear {{count}} finished",
"clearConfirmTitle": "Clear finished orders?",
"clearConfirmBody_one": "1 finished order will be permanently removed from your history.",
"clearConfirmBody_other": "{{count}} finished orders will be permanently removed from your history.",
"clearConfirm": "Yes, clear",
"clearedCount_one": "1 order deleted",
"clearedCount_other": "{{count}} orders deleted",
"clearedPartial": "{{ok}} deleted, {{fail}} failed"
"undone": "Restored"
},
"orderStatus": {
"pending": "Awaiting receiver",
@@ -246,6 +232,16 @@
"autoplayHintTitle": "Sounds need a click",
"autoplayHint": "Click anywhere on the page to enable notification sounds.",
"autoplayHintIos": "Tap anywhere to enable sounds. If you still don't hear them, raise the *media* volume (the same one that controls YouTube).",
"push": {
"title": "Lock-screen notifications",
"desc": "Get alerts on the home/lock screen when Tx OS is closed.",
"enable": "Enable",
"disable": "Disable",
"enabled": "Enabled",
"denied": "Blocked — enable from device settings",
"unsupported": "Not supported on this device. On iPad, add Tx OS to the Home Screen first.",
"enableFailed": "Could not enable notifications. Try again."
},
"slot": {
"order": "Orders",
"meeting": "Meetings",
@@ -508,8 +504,27 @@
"auditLog": "Audit log",
"userManagement": "User Management",
"settings": "Settings",
"systemUpdates": "System Updates",
"menu": "Menu"
},
"systemUpdates": {
"title": "System Updates",
"subtitle": "Check whether a newer version is available — this page never installs an update.",
"currentVersion": "Current Version",
"buildNumber": "Build Number",
"latestVersion": "Latest Version",
"updateStatus": "Update Status",
"statusUpToDate": "System is up to date",
"statusUpdateAvailable": "New update available",
"statusNotConfigured": "Update source not configured",
"statusError": "Unable to check for updates",
"notConfiguredHint": "Ask the administrator to set the LATEST_VERSION_URL environment variable to the external latest.json URL to enable checking.",
"checkNow": "Check Now",
"loading": "Checking version…",
"lastChecked": "Last checked",
"toastErrorTitle": "Check failed",
"toastErrorDesc": "Could not reach the update source. Please try again later."
},
"apps": {
"counts": {
"groups": "{{count}} groups",
@@ -832,6 +847,7 @@
"edit": "Edit",
"add": "Add",
"close": "Close",
"later": "Later",
"loading": "Loading...",
"error": "An error occurred",
"success": "Success",
@@ -1085,7 +1101,8 @@
"label": "Quick actions",
"moveUp": "Move up",
"moveDown": "Move down",
"postpone": "Postpone"
"postpone": "Postpone",
"edit": "Edit"
},
"merge": {
"label": "Merge cells",
@@ -1150,7 +1167,6 @@
"rescheduleEnd": "End",
"rescheduleNote": "Note (optional)",
"rescheduleApply": "Reschedule",
"cancelHint": "Marks the meeting as cancelled. Attendees keep history.",
"cancelApply": "Cancel meeting",
"cancelConfirmPrompt": "Are you sure? The meeting will be marked as cancelled.",
"cancelConfirmYes": "Yes, cancel",
+22
View File
@@ -17,4 +17,26 @@ if (typeof document !== "undefined" && document.fonts?.load) {
void document.fonts.load('700 1em "DIN Next LT Arabic"');
}
// Register the Web Push service worker scoped to the SPA's base path.
// The SW only handles `push` + `notificationclick` — no asset caching —
// so it's safe to register unconditionally as soon as the page boots.
// Without registration, iOS PWAs cannot receive lock-screen notifications.
if (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
window.isSecureContext
) {
const base = import.meta.env.BASE_URL.endsWith("/")
? import.meta.env.BASE_URL
: `${import.meta.env.BASE_URL}/`;
window.addEventListener("load", () => {
navigator.serviceWorker
.register(`${base}sw.js`, { scope: base })
.catch(() => {
/* SW registration is best-effort — push will just stay disabled */
});
});
}
createRoot(document.getElementById("root")!).render(<App />);
+195 -3
View File
@@ -27,6 +27,8 @@ import {
useUpdateAppSettings,
useGetAdminStats,
getGetAdminStatsQueryKey,
useGetSystemVersion,
getGetSystemVersionQueryKey,
useGetAdminAppOpensByApp,
getGetAdminAppOpensByAppQueryKey,
getAdminAppOpensByApp,
@@ -124,6 +126,7 @@ import type {
ServiceDependentOrdersPage,
UserDependentNotesPage,
UserDependentOrdersPage,
SystemVersionResponse,
} from "@workspace/api-client-react";
import { ListUsersStatus } from "@workspace/api-client-react";
import { useQueryClient, useQuery } from "@tanstack/react-query";
@@ -131,7 +134,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { isBuiltinAppSlug } from "@workspace/db/built-in-apps";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download, X, ImageIcon, Power, HelpCircle, Palette, Link2, Hash, Lock, ExternalLink, AlertTriangle, type LucideIcon } from "lucide-react";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download, X, ImageIcon, Power, HelpCircle, Palette, Link2, Hash, Lock, ExternalLink, AlertTriangle, Package, RefreshCw, CheckCircle2, AlertCircle, type LucideIcon } from "lucide-react";
import * as LucideIcons from "lucide-react";
function resolveAppIcon(name: string): LucideIcon | null {
@@ -184,7 +187,7 @@ async function fetchAdminApps(): Promise<App[]> {
return res.json();
}
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "audit-log" | "settings";
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "audit-log" | "settings" | "system-updates";
function LeaderboardAvatar({
src,
@@ -1081,6 +1084,11 @@ export default function AdminPage() {
],
},
{ key: "settings", label: t("admin.nav.settings"), Icon: Settings },
// #575: Read-only "System Updates" page — checks the running build
// against a remote latest.json (configured via LATEST_VERSION_URL on
// the server) and shows whether a newer version is available. It
// never performs an update — pure status surface.
{ key: "system-updates", label: t("admin.nav.systemUpdates"), Icon: Package },
];
// Sync section to URL hash so deep links work
@@ -1090,7 +1098,7 @@ export default function AdminPage() {
const sectionMatch = hash.match(/section=([a-z-]+)/);
if (sectionMatch) {
const s = sectionMatch[1] as AdminSection;
if (["dashboard", "apps", "services", "users", "groups", "roles", "audit-log", "settings"].includes(s)) {
if (["dashboard", "apps", "services", "users", "groups", "roles", "audit-log", "settings", "system-updates"].includes(s)) {
setSection(s);
}
}
@@ -2211,6 +2219,8 @@ export default function AdminPage() {
<SiteSettingsPanel />
</div>
)}
{section === "system-updates" && <SystemUpdatesPanel />}
</div>
</div>
@@ -2331,6 +2341,188 @@ export default function AdminPage() {
);
}
// #575: System Updates panel — read-only version checker. Reads the
// local build (server returns version.json embedded at build time)
// and, if the server has LATEST_VERSION_URL configured, compares it
// against the remote latest.json via semver on the server side. We
// never perform an update from this screen — pure status surface.
// The bell icon and other admin features stay untouched.
function SystemUpdatesPanel() {
const { t, i18n } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const queryKey = getGetSystemVersionQueryKey();
const { data, isLoading, isFetching, refetch } = useGetSystemVersion({
query: { queryKey, refetchOnWindowFocus: false },
});
const handleCheckNow = async () => {
// Invalidate first so React Query treats the next fetch as fresh
// (no stale cache reuse) — this is the user explicitly asking for
// a re-check, so we want a real network hit.
await queryClient.invalidateQueries({ queryKey });
const result = await refetch();
if (result.error) {
toast({
title: t("admin.systemUpdates.toastErrorTitle"),
description: t("admin.systemUpdates.toastErrorDesc"),
variant: "destructive",
});
}
};
const formatChecked = (iso: string | null | undefined) => {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return new Intl.DateTimeFormat(i18n.language === "ar" ? "ar" : "en", {
dateStyle: "medium",
timeStyle: "short",
}).format(d);
};
const renderStatusBadge = (resp: SystemVersionResponse | undefined) => {
if (!resp) return null;
const map: Record<
SystemVersionResponse["status"],
{ label: string; cls: string; Icon: typeof CheckCircle2 }
> = {
"up-to-date": {
label: t("admin.systemUpdates.statusUpToDate"),
cls: "bg-emerald-50 text-emerald-700 border-emerald-200",
Icon: CheckCircle2,
},
"update-available": {
label: t("admin.systemUpdates.statusUpdateAvailable"),
cls: "bg-amber-50 text-amber-800 border-amber-200",
Icon: AlertCircle,
},
"not-configured": {
label: t("admin.systemUpdates.statusNotConfigured"),
cls: "bg-slate-50 text-slate-700 border-slate-200",
Icon: HelpCircle,
},
error: {
label: t("admin.systemUpdates.statusError"),
cls: "bg-rose-50 text-rose-700 border-rose-200",
Icon: AlertTriangle,
},
};
const cfg = map[resp.status];
const Icon = cfg.Icon;
return (
<span
className={cn(
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border",
cfg.cls,
)}
data-testid="system-updates-status-badge"
>
<Icon size={14} />
{cfg.label}
</span>
);
};
return (
<div className="space-y-4" data-testid="system-updates-panel" dir={i18n.language === "ar" ? "rtl" : "ltr"}>
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-5">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-semibold text-foreground">
{t("admin.systemUpdates.title")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("admin.systemUpdates.subtitle")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleCheckNow}
disabled={isFetching}
data-testid="system-updates-check-now"
>
{isFetching ? (
<Loader2 size={14} className="animate-spin me-2" />
) : (
<RefreshCw size={14} className="me-2" />
)}
{t("admin.systemUpdates.checkNow")}
</Button>
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-6">
<Loader2 size={14} className="animate-spin" />
{t("admin.systemUpdates.loading")}
</div>
) : data ? (
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-slate-200 p-4 bg-slate-50/60">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
{t("admin.systemUpdates.currentVersion")}
</div>
<div
className="text-xl font-semibold mt-1 tabular-nums"
data-testid="system-updates-current-version"
>
{data.current.version}
</div>
<div className="text-xs text-muted-foreground mt-1">
{t("admin.systemUpdates.buildNumber")}:{" "}
<span className="tabular-nums" data-testid="system-updates-current-build">
{data.current.build}
</span>
</div>
</div>
<div className="rounded-xl border border-slate-200 p-4 bg-slate-50/60">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
{t("admin.systemUpdates.latestVersion")}
</div>
<div
className="text-xl font-semibold mt-1 tabular-nums"
data-testid="system-updates-latest-version"
>
{data.latest?.version ?? "—"}
</div>
<div className="text-xs text-muted-foreground mt-1">
{t("admin.systemUpdates.buildNumber")}:{" "}
<span className="tabular-nums" data-testid="system-updates-latest-build">
{data.latest?.build ?? "—"}
</span>
</div>
</div>
<div className="sm:col-span-2 rounded-xl border border-slate-200 p-4">
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-2">
{t("admin.systemUpdates.updateStatus")}
</div>
{renderStatusBadge(data)}
{data.status === "error" && data.errorMessage && (
<div className="text-xs text-rose-700 mt-2" data-testid="system-updates-error-message">
{data.errorMessage}
</div>
)}
{data.status === "not-configured" && (
<div className="text-xs text-muted-foreground mt-2">
{t("admin.systemUpdates.notConfiguredHint")}
</div>
)}
{data.checkedAt && (
<div className="text-xs text-muted-foreground mt-3">
{t("admin.systemUpdates.lastChecked")}: {formatChecked(data.checkedAt)}
</div>
)}
</div>
</div>
) : null}
</div>
</div>
);
}
function SiteSettingsPanel() {
const { t } = useTranslation();
const { toast } = useToast();
+259 -41
View File
@@ -114,6 +114,8 @@ import { EditableCell } from "@/components/editable-cell";
// #486: reuse the same dialog the upcoming-alert pops, so postponing
// from the schedule row quick-actions menu shares one implementation.
import { PostponeDialog } from "@/components/executive-meetings/upcoming-meeting-alert";
import { AnalogClock } from "@/components/executive-meetings/analog-clock";
import { formatDate, formatTime as formatTimeI18n, formatWeekday } from "@/lib/i18n-format";
import { safeHtml, htmlToPlainText, wrapAsParagraph } from "@/lib/safe-html";
import {
ALERT_COLOR_PRESETS,
@@ -779,6 +781,34 @@ export default function ExecutiveMeetingsPage() {
);
}
function PresidentClockBar({ date, lang }: { date: string; lang: "ar" | "en" }) {
const [now, setNow] = useState<Date>(() => new Date());
useEffect(() => {
const id = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(id);
}, []);
return (
<div
className="flex items-center gap-2 sm:gap-3 ps-2 sm:ps-3 ms-1 border-s border-gray-200"
data-testid="em-president-clock-bar"
>
<AnalogClock size={40} />
<div className="leading-tight">
<div className="flex items-center gap-2 text-[#0B1E3F] font-semibold text-sm sm:text-base">
<span data-testid="em-president-weekday">{formatWeekday(date, lang, "long")}</span>
<span className="text-muted-foreground/60">·</span>
<span className="tabular-nums" data-testid="em-president-digital-time">
{formatTimeI18n(now, lang, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: true })}
</span>
</div>
<div className="text-xs sm:text-sm text-muted-foreground tabular-nums" data-testid="em-president-date">
{formatDate(date, lang)}
</div>
</div>
</div>
);
}
function HeaderClock({ lang }: { lang: "ar" | "en" }) {
const [now, setNow] = useState<Date>(() => new Date());
useEffect(() => {
@@ -850,6 +880,27 @@ function ExecutiveMeetingsPageInner() {
queryFn: () => apiJson<MeRoles>("/api/executive-meetings/me"),
});
// #572: "President view" — a focused, presentation-style layout for users
// whose primary role is the CEO/president (`executive_ceo`) and who are
// NOT also wearing an admin hat (admin / office_manager). They get the
// schedule table only — no sub-nav tabs, no PDF shortcut, no audit log —
// plus an analog clock + weekday/date label in the toolbar. Their
// permissions on the backend are untouched; this is purely a UI gate.
const isPresidentView = useMemo(() => {
const roles = me?.roles ?? [];
if (!roles.includes("executive_ceo")) return false;
return !roles.some((r) => r === "admin" || r === "executive_office_manager");
}, [me]);
// Force the schedule section when the president lands on the page or
// their role flips on mid-session, so they never get stuck on a now-
// hidden tab.
useEffect(() => {
if (isPresidentView && section !== "schedule") {
setSection("schedule");
}
}, [isPresidentView, section]);
const { data: fontResp } = useQuery<FontSettingsResponse>({
queryKey: ["/api/executive-meetings/font-settings"],
queryFn: () => apiJson<FontSettingsResponse>("/api/executive-meetings/font-settings"),
@@ -932,30 +983,34 @@ function ExecutiveMeetingsPageInner() {
<div className="flex-1" />
<div className="hidden sm:flex pointer-events-none absolute inset-x-0 top-0 bottom-0 items-center justify-center print:hidden">
<div className="pointer-events-auto">
<HeaderClock lang={lang} />
{!isPresidentView && (
<div className="hidden sm:flex pointer-events-none absolute inset-x-0 top-0 bottom-0 items-center justify-center print:hidden">
<div className="pointer-events-auto">
<HeaderClock lang={lang} />
</div>
</div>
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => setSection("pdf")}
data-testid="em-export-shortcut"
>
<FileDown className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
</Button>
{!isPresidentView && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => setSection("pdf")}
data-testid="em-export-shortcut"
>
<FileDown className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
</Button>
)}
</div>
</div>
<nav className="border-t border-gray-100 bg-white overflow-x-auto">
<div className="max-w-screen-2xl mx-auto px-2 sm:px-4 flex items-center gap-1 sm:gap-2">
<div className="flex gap-1 sm:gap-2 min-w-max">
{SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
{!isPresidentView && SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
({ key, icon: Icon }) => {
const active = section === key;
return (
@@ -999,6 +1054,8 @@ function ExecutiveMeetingsPageInner() {
columns={columns}
onColumnsChange={setColumns}
highlightPrefs={highlightPrefs}
isPresidentView={isPresidentView}
lang={lang}
/>
)}
{section === "manage" && me && (
@@ -1057,6 +1114,8 @@ function ScheduleSection({
columns,
onColumnsChange,
highlightPrefs,
isPresidentView = false,
lang = "en",
}: {
meetings: Meeting[];
date: string;
@@ -1070,6 +1129,8 @@ function ScheduleSection({
columns: ColumnSetting[];
onColumnsChange: Dispatch<SetStateAction<ColumnSetting[]>>;
highlightPrefs: HighlightPrefs;
isPresidentView?: boolean;
lang?: "ar" | "en";
}) {
const qc = useQueryClient();
const { toast } = useToast();
@@ -2246,6 +2307,15 @@ function ScheduleSection({
const [postponeMeetingId, setPostponeMeetingId] = useState<number | null>(
null,
);
// #570: editing state for the in-place Meeting edit dialog opened
// from the row's quick-actions Edit button. We host the dialog in
// ScheduleSection (rather than switching the user over to the
// Manage tab) so the edit flow feels local to the row the user
// just tapped. Mirrors ManageSection's `editing` shape so we can
// reuse the same MeetingFormDialog + save body unchanged.
const [editingMeeting, setEditingMeeting] =
useState<MeetingFormState | null>(null);
const [editingSaving, setEditingSaving] = useState(false);
const postponeMeeting = useMemo(() => {
if (postponeMeetingId == null) return null;
return meetings.find((m) => m.id === postponeMeetingId) ?? null;
@@ -2645,6 +2715,9 @@ function ScheduleSection({
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
/>
</label>
{isPresidentView && (
<PresidentClockBar date={date} lang={lang} />
)}
</>
);
@@ -2947,6 +3020,33 @@ function ScheduleSection({
onBulkSelectChange={(v) => toggleMeetingSelected(m.id, v)}
quickActionsCanMutate={canMutate && !editMode}
onQuickPostpone={() => setPostponeMeetingId(m.id)}
onQuickEdit={
canMutate && !editMode
? () =>
setEditingMeeting({
id: m.id,
titleAr: htmlToPlainText(m.titleAr),
titleEn: htmlToPlainText(m.titleEn ?? ""),
meetingDate: m.meetingDate,
dailyNumber: String(m.dailyNumber),
startTime: m.startTime
? m.startTime.slice(0, 5)
: "",
endTime: m.endTime ? m.endTime.slice(0, 5) : "",
location: m.location ?? "",
meetingUrl: m.meetingUrl ?? "",
platform: m.platform,
status: m.status,
isHighlighted: m.isHighlighted === 1,
notes: m.notes ?? "",
attendees: m.attendees.map((a) => ({
...a,
name: htmlToPlainText(a.name),
_sid: genAttendeeSid(),
})),
})
: undefined
}
/>
))}
</SortableContext>
@@ -2987,6 +3087,77 @@ function ScheduleSection({
same component the upcoming-meeting alert renders so the
postpone-minutes / reschedule / cancel flows + the 409
stale_meeting handling stay in one place. */}
{/* #570: In-place Meeting edit dialog hosted by ScheduleSection,
opened from the row's quick-actions Edit button. Save body
mirrors ManageSection's `save()` exactly (PATCH with the
same field projection) so an edit started here writes the
same shape the Manage tab would. Invalidates the same
react-query key the rest of the page uses so the row
reflects new values instantly. */}
{editingMeeting && (
<MeetingFormDialog
state={editingMeeting}
onChange={setEditingMeeting}
onSave={async (committedStart, committedEnd) => {
if (!editingMeeting) return;
if (!editingMeeting.titleAr.trim()) {
toast({
title: t("executiveMeetings.manage.errors.titleRequired"),
variant: "destructive",
});
return;
}
const titleEnPlain =
editingMeeting.titleEn.trim() || editingMeeting.titleAr.trim();
setEditingSaving(true);
const body: Record<string, unknown> = {
titleAr: wrapAsParagraph(editingMeeting.titleAr),
titleEn: wrapAsParagraph(titleEnPlain),
meetingDate: editingMeeting.meetingDate,
startTime: committedStart || null,
endTime: committedEnd || null,
location: editingMeeting.location.trim() || null,
meetingUrl: editingMeeting.meetingUrl.trim() || null,
platform: editingMeeting.platform,
status: editingMeeting.status,
isHighlighted: editingMeeting.isHighlighted ? 1 : 0,
notes: editingMeeting.notes.trim() || null,
attendees: editingMeeting.attendees.map((a, idx) => ({
name: wrapAsParagraph(a.name),
title: a.title,
attendanceType: a.attendanceType,
sortOrder: idx,
kind: a.kind ?? "person",
})),
};
if (editingMeeting.dailyNumber.trim()) {
body.dailyNumber = Number(editingMeeting.dailyNumber);
}
try {
await apiJson(
`/api/executive-meetings/${editingMeeting.id}`,
{ method: "PATCH", body },
);
toast({ title: t("executiveMeetings.common.saved") });
setEditingMeeting(null);
refreshDay();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("executiveMeetings.manage.errors.saveFailed"),
description: translateSaveError(msg, t),
variant: "destructive",
});
} finally {
setEditingSaving(false);
}
}}
onClose={() => setEditingMeeting(null)}
saving={editingSaving}
t={t}
/>
)}
{postponeMeeting && canMutate ? (
<PostponeDialog
open={postponeMeetingId !== null}
@@ -3747,6 +3918,7 @@ function MeetingRow({
dragEnabled,
quickActionsCanMutate,
onQuickPostpone,
onQuickEdit,
}: {
meeting: Meeting;
displayNumber?: number;
@@ -3772,6 +3944,12 @@ function MeetingRow({
// MUTATE_ROLES server-side gating as the postpone-minutes endpoint.
quickActionsCanMutate?: boolean;
onQuickPostpone?: () => void;
// #570: opens the full Meeting edit dialog (same one used in
// ManageSection) and closes the quick-actions surface. Wired by
// ScheduleSection to its own `setEditingMeeting` lifted state so
// the Schedule tab can host the edit dialog without switching the
// user to the Manage tab.
onQuickEdit?: () => void;
onSaveTitle: (html: string) => Promise<void>;
onSaveAttendeeName: (idx: number, html: string) => Promise<void>;
onSaveTimes: (
@@ -4263,7 +4441,16 @@ function MeetingRow({
if (!quickActionsCanMutate && quickOpen) setQuickOpen(false);
}, [quickActionsCanMutate, quickOpen]);
// #570: bulk-selected rows previously only got a 2px inset ring in
// navy, which is easy to miss on iPad against the table grid. We now
// *also* paint a visible navy tint behind the row so the selected
// state reads at a glance. The tint composes after the per-row
// `baseBg` so user-picked row colours still win when nothing is
// selected, but loses to `quickOpenBg` (the open quick-actions
// surface is a transient, action-scoped state) — see the
// `backgroundColor` composition in `trStyle` below.
const selectionShadow = bulkSelected ? "inset 0 0 0 2px #0B1E3F" : null;
const selectionBg = bulkSelected ? "rgba(11, 30, 63, 0.18)" : null;
const currentShadow = isCurrent
? `inset 0 0 0 2px ${highlightColor}`
: null;
@@ -4275,8 +4462,17 @@ function MeetingRow({
// #496: belt-and-suspenders — also gate on capability so a stale
// `quickOpen=true` from a render before the effect runs cannot
// paint the accented frame in edit mode.
// #563: thicker inset ring + outer glow + tinted background so the
// originating row is unmistakable on iPad where the default 3px
// inset is easy to miss against the table grid. The outer
// `0 0 0 2px` ring sits OUTSIDE the row box (no inset), so the row
// appears framed on all four sides even when adjacent rows would
// otherwise visually clip the inset ring.
const quickOpenShadow = quickActionsCanMutate && quickOpen
? `inset 0 0 0 3px ${highlightColor}`
? `inset 0 0 0 4px ${highlightColor}, 0 0 0 2px ${highlightColor}`
: null;
const quickOpenBg = quickActionsCanMutate && quickOpen
? hexToRgba(highlightColor, 0.08)
: null;
// #499: iPad long-press feedback. dnd-kit's TouchSensor has a 200ms
// activation delay before a long-press becomes a drag — during that
@@ -4329,7 +4525,7 @@ function MeetingRow({
.filter(Boolean)
.join(", ");
const trStyle: CSSProperties = {
backgroundColor: baseBg,
backgroundColor: quickOpenBg ?? selectionBg ?? baseBg,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
@@ -4340,38 +4536,37 @@ function MeetingRow({
if (!quickActionsCanMutate) return;
const target = e.target as HTMLElement | null;
if (!target) return;
// Skip clicks that landed on an interactive child — leave them to
// their own handlers. Includes ARIA roles because TimeRangeCell
// and a few other affordances render as `div role="button"`
// (keyboard-activatable) rather than native `<button>`s, so a
// pure tag-name filter would let those clicks fall through to
// the row handler and pop the quick-actions menu underneath the
// inline editor the user just opened.
// #489: scope to descendants — the row itself now carries
// role="button" + tabIndex from dnd-kit's `attributes` (because
// dragEnabled is no longer gated by edit mode), so an unscoped
// closest() would match the row itself and swallow every click.
// #563: outside edit mode (which is the only state where
// handleRowClick runs — `quickActionsCanMutate` is force-false
// in edit mode, see line ~2948), the user expects ANY click
// anywhere in the row to open the quick-actions surface, not
// just the attendees cell. We only skip clicks that landed on
// a *truly* interactive native control (buttons, inputs,
// links, menu/checkbox/switch/combobox/dialog roles) — the
// `[role='button']` rule is intentionally DROPPED because
// TimeRangeCell and EditableInlineText render with role='button'
// to mark themselves as keyboard-activatable, but their actual
// edit affordance is gated on `editMode === true` — outside
// edit mode they are display-only and clicks should bubble.
// #489 still holds: scope to descendants so the row's own
// dnd-kit `role="button"` doesn't swallow every click.
const interactive = target.closest(
"button, a, input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='menuitem'], [role='button'], [role='checkbox'], [role='switch'], [role='combobox'], [role='dialog']",
"button, a, input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='menuitem'], [role='checkbox'], [role='switch'], [role='combobox'], [role='dialog']",
);
if (interactive && interactive !== e.currentTarget) {
return;
}
// Skip clicks on row affordances by data-testid prefix so we
// don't fight with drag, row-actions, merge-edit, inline-edit,
// bulk-select, or the time cell's edit-on-click surface
// (em-time-*). The role='button' rule above catches the time
// cell's display state too, but the explicit testid guard keeps
// the intent obvious and survives any future refactor that
// drops the role attribute.
// Keep only the affordances that are visible OUTSIDE edit mode
// and must not trigger quick-actions: the 3-dot row menu and
// the bulk-select checkbox. The previous `em-edit-*`,
// `em-merge-edit-*`, and `em-time-*` guards were dropped because
// those affordances only do anything in edit mode (where this
// handler never fires).
if (
target.closest(
[
`[data-testid="em-row-actions-${meeting.id}"]`,
`[data-testid="em-row-select-${meeting.id}"]`,
`[data-testid^="em-edit-"]`,
`[data-testid^="em-merge-edit-"]`,
`[data-testid^="em-time-"]`,
].join(", "),
)
) {
@@ -4455,8 +4650,31 @@ function MeetingRow({
</DialogHeader>
{/* #490: polished Postpone button primary-styled, larger
tap target for iPad, icon flips with locale via flex
direction so it stays adjacent to the label in RTL. */}
<div className="flex justify-center pt-2">
direction so it stays adjacent to the label in RTL.
#570: added an Edit (pencil) button alongside Postpone
that opens the same Meeting edit dialog ManageSection
uses. Wrapped in a flex row + `flex-wrap` so the two
buttons sit side by side on iPad/desktop and stack on
very narrow viewports. Edit button only renders when a
handler is wired (defensive quickActionsCanMutate
already gates the surface itself). */}
<div className="flex flex-wrap justify-center gap-2 pt-2">
{onQuickEdit ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setQuickOpen(false);
onQuickEdit();
}}
className={`min-w-[11rem] gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
data-testid={`em-row-quick-edit-${meeting.id}`}
>
<Pencil className="w-4 h-4" aria-hidden="true" />
<span>{t("executiveMeetings.quickActions.edit")}</span>
</Button>
) : null}
<Button
type="button"
variant="default"
+16 -1
View File
@@ -135,8 +135,18 @@ function AppIconContent({
)}
</div>
<span
lang={lang === "ar" ? "ar" : "en"}
dir={lang === "ar" ? "rtl" : "ltr"}
className="text-[11px] text-foreground/85 font-medium text-center max-w-[78px] block"
style={{
// Pin an Arabic-capable face so iOS Safari doesn't fall back
// to a Latin font (which drops the dots under final ي in
// labels like "خدماتي"). Falls back to the Latin face for
// English labels via the second family in the stack.
fontFamily:
lang === "ar"
? '"DIN Next LT Arabic", "Helvetica Neue LT Arabic", "Tajawal", sans-serif'
: '"Helvetica Neue", system-ui, sans-serif',
lineHeight: 1.6,
paddingTop: 2,
overflow: "hidden",
@@ -511,7 +521,12 @@ export default function HomePage() {
});
};
const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "notifications", "admin"].includes(a.slug)) ?? [];
// #571: drop the Notifications tile from the bottom dock — the bell
// icon in the top bar (with the same unread badge) is the canonical
// entry point, so a second affordance for the same destination on
// the home screen was redundant. The bell still routes to
// `/notifications` (see the top-bar button below).
const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "admin"].includes(a.slug)) ?? [];
const initials = (displayName || "?").trim().slice(0, 1).toUpperCase();
+25 -1
View File
@@ -66,9 +66,33 @@ export default function LoginPage() {
{lang === "ar" ? "EN" : "عربي"}
</button>
{/* Animated art panel — hidden on small screens, no logo, no text */}
{/* Animated art panel — IT-P brand image layered over animated art.
Hidden on small screens. The image sits above AnimatedArt with a
soft gradient overlay so the lines/orbs still bleed through at the
edges and the composition reads as one piece. */}
<div className="hidden lg:block lg:w-1/2 relative overflow-hidden shrink-0">
<AnimatedArt />
<div
className="absolute inset-0 bg-center bg-cover bg-no-repeat opacity-90 mix-blend-screen"
style={{ backgroundImage: "url('/brand-itp.jpg?v=itp1')" }}
aria-hidden
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(180deg, rgba(11,30,63,0.35) 0%, rgba(11,30,63,0) 30%, rgba(11,30,63,0) 70%, rgba(11,30,63,0.6) 100%)",
}}
aria-hidden
/>
<div
className="absolute inset-y-0 right-0 w-24"
style={{
background:
"linear-gradient(90deg, rgba(11,30,63,0) 0%, rgba(11,30,63,0.5) 100%)",
}}
aria-hidden
/>
</div>
{/* Form panel */}
+8 -132
View File
@@ -30,7 +30,6 @@ import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
@@ -169,7 +168,6 @@ function OrderCard({
const { toast } = useToast();
const lang = i18n.language;
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const updateStatus = useUpdateServiceOrderStatus();
const name = lang === "ar" ? order.service.nameAr : order.service.nameEn;
@@ -233,7 +231,6 @@ function OrderCard({
};
const handleDelete = () => {
setConfirmDeleteOpen(false);
onDelete(order.id);
};
@@ -293,7 +290,7 @@ function OrderCard({
variant="ghost"
size="sm"
className="text-slate-500 hover:text-rose-700 hover:bg-rose-50"
onClick={() => setConfirmDeleteOpen(true)}
onClick={handleDelete}
aria-label={t("myOrders.delete")}
title={t("myOrders.delete")}
>
@@ -309,9 +306,6 @@ function OrderCard({
<AlertDialogTitle>
{t("myOrders.cancelConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("myOrders.cancelConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={updateStatus.isPending}>
@@ -331,32 +325,6 @@ function OrderCard({
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>
{t("myOrders.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("myOrders.deleteConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("myOrders.keep")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
className="bg-rose-600 hover:bg-rose-700 text-white"
>
{t("myOrders.deleteConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -369,7 +337,6 @@ export default function MyOrdersPage() {
const lang = i18n.language;
const isRtl = lang === "ar";
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
const [confirmClearOpen, setConfirmClearOpen] = useState(false);
const deleteOrder = useDeleteServiceOrder();
const [pendingDeleteIds, setPendingDeleteIds] = useState<Set<number>>(
() => new Set(),
@@ -438,65 +405,22 @@ export default function MyOrdersPage() {
const timer = setTimeout(() => {
ids.forEach((id) => timersRef.current.delete(id));
void performDelete(ids).then(({ okCount, failCount }) => {
void performDelete(ids).then(({ failCount }) => {
setPendingDeleteIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.delete(id));
return next;
});
if (failCount > 0) {
// Restore failed deletions on error so the user sees them again.
setPendingDeleteIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.delete(id));
return next;
});
// #224: surface ONE summary toast instead of N error toasts. Total
// failure keeps the existing single-line copy; partial failure
// uses the existing `clearedPartial` key with both counts.
toast({
title:
okCount === 0
? t("myOrders.deleteFailed")
: t("myOrders.clearedPartial", { ok: okCount, fail: failCount }),
title: t("myOrders.deleteFailed"),
variant: "destructive",
});
} else {
setPendingDeleteIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.delete(id));
return next;
});
}
});
}, UNDO_WINDOW_MS);
ids.forEach((id) => timersRef.current.set(id, timer));
const undo = () => {
ids.forEach((id) => {
const existing = timersRef.current.get(id);
if (existing) clearTimeout(existing);
timersRef.current.delete(id);
});
setPendingDeleteIds((prev) => {
const next = new Set(prev);
ids.forEach((id) => next.delete(id));
return next;
});
handle.dismiss();
toast({ title: t("myOrders.undone") });
};
// #223: lean on i18next pluralization (clearedCount_one / _other are
// already in both locales) so N=1 reads as "1 order deleted" instead
// of the generic "Order deleted" copy.
const title = t("myOrders.clearedCount", { count: ids.length });
const handle = toast({
title,
duration: UNDO_WINDOW_MS,
action: (
<ToastAction altText={t("myOrders.undo")} onClick={undo}>
{t("myOrders.undo")}
</ToastAction>
),
});
};
const sortedOrders = orders
@@ -508,14 +432,6 @@ export default function MyOrdersPage() {
)
: [];
const finishedOrders = sortedOrders.filter((o) => isFinished(o.status));
const finishedCount = finishedOrders.length;
const handleClearFinished = () => {
setConfirmClearOpen(false);
scheduleDelete(finishedOrders.map((o) => o.id));
};
return (
<div className="min-h-screen os-bg flex flex-col">
<div className="glass-panel border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-10">
@@ -567,19 +483,6 @@ export default function MyOrdersPage() {
</div>
) : (
<div className="flex flex-col gap-3">
{finishedCount > 0 && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-slate-600 hover:text-rose-700 hover:bg-rose-50 gap-1.5"
onClick={() => setConfirmClearOpen(true)}
>
<Trash2 size={14} />
{t("myOrders.clearFinished", { count: finishedCount })}
</Button>
</div>
)}
{sortedOrders.map((o) => (
<OrderCard
key={o.id}
@@ -602,33 +505,6 @@ export default function MyOrdersPage() {
)}
</div>
<AlertDialog open={confirmClearOpen} onOpenChange={setConfirmClearOpen}>
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>
{t("myOrders.clearConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("myOrders.clearConfirmBody", { count: finishedCount })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("myOrders.keep")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleClearFinished();
}}
className="bg-rose-600 hover:bg-rose-700 text-white"
>
{t("myOrders.clearConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<OrderServiceModal
open={reorderTarget !== null}
onOpenChange={(o) => {
+25 -5
View File
@@ -32,6 +32,7 @@ services:
# the first-run Setup Wizard (/api/setup/complete).
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-}
SEED_DEMO_MEETINGS: ${SEED_DEMO_MEETINGS:-false}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}}
user: root
entrypoint: ["/usr/bin/tini", "--"]
@@ -62,6 +63,7 @@ services:
DEFAULT_OBJECT_STORAGE_BUCKET_ID: ${DEFAULT_OBJECT_STORAGE_BUCKET_ID:-tx-local}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${APP_PORT:-3000}}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}}
TRUST_PROXY_HTTPS: ${TRUST_PROXY_HTTPS:-false}
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTPS_MODE: ${HTTPS_MODE:-local}
LOCAL_DOMAIN: ${LOCAL_DOMAIN:-}
@@ -72,6 +74,9 @@ services:
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-}
SMTP_SECURE: ${SMTP_SECURE:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
volumes:
- app_storage:/app/storage
@@ -97,15 +102,29 @@ services:
LOCAL_DOMAIN: ${LOCAL_DOMAIN:-tx.local}
LOCAL_IP: ${LOCAL_IP:-127.0.0.1}
HTTPS_MODE: ${HTTPS_MODE:-local}
# Pick the Caddyfile at boot. We auto-fall-back to the cert-free
# Caddyfile.skip when HTTPS_MODE=skip OR when /certs is missing
# the keypair, so a fresh `./start.sh` works on hosts that don't
# have mkcert without any extra wiring.
PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-}
ACME_EMAIL: ${ACME_EMAIL:-}
# Pick the Caddyfile at boot:
# * HTTPS_MODE=auto → Caddyfile.auto (Let's Encrypt for PUBLIC_DOMAIN)
# * HTTPS_MODE=skip → Caddyfile.skip (plain HTTP, dev only)
# * /certs missing → Caddyfile.skip (graceful fall-back)
# * otherwise → Caddyfile (BYO cert under /certs)
entrypoint:
- /bin/sh
- -c
- |
if [ "$${HTTPS_MODE}" = "skip" ] || [ ! -f /certs/local-cert.pem ] || [ ! -f /certs/local-key.pem ]; then
if [ "$${HTTPS_MODE}" = "auto" ]; then
if [ -z "$${PUBLIC_DOMAIN}" ]; then
echo "HTTPS_MODE=auto requires PUBLIC_DOMAIN to be set (e.g. tx.example.com)" >&2
exit 1
fi
if [ -n "$${ACME_EMAIL}" ]; then
export ACME_EMAIL_DIRECTIVE="email $${ACME_EMAIL}"
else
export ACME_EMAIL_DIRECTIVE=""
fi
exec caddy run --config /etc/caddy/Caddyfile.auto --adapter caddyfile
elif [ "$${HTTPS_MODE}" = "skip" ] || [ ! -f /certs/local-cert.pem ] || [ ! -f /certs/local-key.pem ]; then
exec caddy run --config /etc/caddy/Caddyfile.skip --adapter caddyfile
else
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
@@ -124,6 +143,7 @@ services:
volumes:
- ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
- ./docker/Caddyfile.skip:/etc/caddy/Caddyfile.skip:ro
- ./docker/Caddyfile.auto:/etc/caddy/Caddyfile.auto:ro
- ./certs:/certs:ro
- caddy_data:/data
- caddy_config:/config
+54
View File
@@ -0,0 +1,54 @@
# Tx OS — Caddy reverse proxy in HTTPS_MODE=auto.
#
# Issues + renews a real Let's Encrypt certificate for $PUBLIC_DOMAIN.
# Used when the operator points a stable custom domain (e.g.
# `tx.example.com`) at the box and wants automatic TLS — this keeps
# iPad Web Push subscriptions valid forever, because subscriptions
# are bound to the origin (host) they were created under and a stable
# hostname means they never get orphaned when Tailscale or the LAN IP
# changes.
#
# Requirements:
# - PUBLIC_DOMAIN env var must be set (e.g. tx.example.com).
# - DNS A/AAAA record for PUBLIC_DOMAIN points at this host.
# - Ports 80 + 443 are reachable from the public internet (ACME
# HTTP-01 / TLS-ALPN challenge). Behind a NAT/firewall you must
# forward both. If only 443 is reachable, switch ACME to DNS-01
# by extending this file with a DNS provider plugin.
# - ACME_EMAIL env var SHOULD be set for renewal notifications.
#
# The site MUST stay single-origin so the API session cookie
# (sameSite=lax) keeps working across SPA + /api requests.
{
{$ACME_EMAIL_DIRECTIVE}
admin off
}
(api_proxy) {
@websocket {
header Connection *Upgrade*
header Upgrade websocket
}
reverse_proxy /api/socket.io/* api:8080
reverse_proxy /api/* api:8080
}
(spa_proxy) {
reverse_proxy web:80
}
# ---------- HTTPS site for the public custom domain ----------
# Caddy auto-issues + renews the cert via ACME when this host is
# reached over HTTPS for the first time.
{$PUBLIC_DOMAIN} {
encode zstd gzip
import api_proxy
import spa_proxy
}
# Plain HTTP → permanent redirect to HTTPS. Caddy still needs :80
# bound so the ACME HTTP-01 challenge can be answered.
:80 {
redir https://{host}{uri} permanent
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"version": "0.1.0-dev",
"build": "20260517.b5efd9eb"
}
@@ -9,6 +9,32 @@ export interface HealthStatus {
status: string;
}
export interface SystemVersionInfo {
version: string;
build: string;
}
export type SystemVersionResponseStatus =
(typeof SystemVersionResponseStatus)[keyof typeof SystemVersionResponseStatus];
export const SystemVersionResponseStatus = {
"up-to-date": "up-to-date",
"update-available": "update-available",
"not-configured": "not-configured",
error: "error",
} as const;
export interface SystemVersionResponse {
current: SystemVersionInfo;
latest: SystemVersionInfo | null;
status: SystemVersionResponseStatus;
/** @nullable */
errorMessage: string | null;
/** @nullable */
checkedAt: string | null;
configured: boolean;
}
export interface ErrorResponse {
error: string;
}
@@ -1303,6 +1329,31 @@ export type DeleteServiceParams = {
force?: boolean;
};
export type GetPushVapidPublicKey200 = {
publicKey: string;
};
export type SubscribePushBodyKeys = {
p256dh: string;
auth: string;
};
export type SubscribePushBody = {
endpoint: string;
keys: SubscribePushBodyKeys;
};
export type DeletePushSubscriptionParams = {
/**
* The push endpoint URL to unsubscribe
*/
endpoint: string;
};
export type UnsubscribePushBody = {
endpoint: string;
};
export type ListUsersParams = {
/**
* Free-text search across username, email, and display names
+430
View File
@@ -46,6 +46,7 @@ import type {
CreateUserBody,
DeleteAppParams,
DeleteGroupParams,
DeletePushSubscriptionParams,
DeleteServiceParams,
DeleteUserParams,
ErrorResponse,
@@ -64,6 +65,7 @@ import type {
GetAdminUserDependentOrdersParams,
GetAppPermissionAuditParams,
GetGroupPermissionAuditParams,
GetPushVapidPublicKey200,
GetRolePermissionAuditParams,
GetUserPermissionAuditParams,
Group,
@@ -106,7 +108,10 @@ import type {
ServiceDeletionConflict,
ServiceDependentOrdersPage,
ServiceOrder,
SubscribePushBody,
SuccessResponse,
SystemVersionResponse,
UnsubscribePushBody,
UpdateAppBody,
UpdateAppSettingsBody,
UpdateClockHour12Body,
@@ -213,6 +218,86 @@ export function useHealthCheck<
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Returns the local build version (read from version.json embedded at
build time) and, if `LATEST_VERSION_URL` is configured on the
server, the latest published version fetched from that URL. Admin
only. Never performs an update — pure read.
* @summary Get current and latest system version
*/
export const getGetSystemVersionUrl = () => {
return `/api/system/version`;
};
export const getSystemVersion = async (
options?: RequestInit,
): Promise<SystemVersionResponse> => {
return customFetch<SystemVersionResponse>(getGetSystemVersionUrl(), {
...options,
method: "GET",
});
};
export const getGetSystemVersionQueryKey = () => {
return [`/api/system/version`] as const;
};
export const getGetSystemVersionQueryOptions = <
TData = Awaited<ReturnType<typeof getSystemVersion>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemVersion>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetSystemVersionQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getSystemVersion>>
> = ({ signal }) => getSystemVersion({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getSystemVersion>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSystemVersionQueryResult = NonNullable<
Awaited<ReturnType<typeof getSystemVersion>>
>;
export type GetSystemVersionQueryError = ErrorType<unknown>;
/**
* @summary Get current and latest system version
*/
export function useGetSystemVersion<
TData = Awaited<ReturnType<typeof getSystemVersion>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemVersion>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSystemVersionQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Register a new user
*/
@@ -3573,6 +3658,351 @@ export const useMarkAllNotificationsRead = <
return useMutation(getMarkAllNotificationsReadMutationOptions(options));
};
/**
* @summary Get the server's VAPID public key for Web Push subscriptions
*/
export const getGetPushVapidPublicKeyUrl = () => {
return `/api/push/vapid-public-key`;
};
export const getPushVapidPublicKey = async (
options?: RequestInit,
): Promise<GetPushVapidPublicKey200> => {
return customFetch<GetPushVapidPublicKey200>(getGetPushVapidPublicKeyUrl(), {
...options,
method: "GET",
});
};
export const getGetPushVapidPublicKeyQueryKey = () => {
return [`/api/push/vapid-public-key`] as const;
};
export const getGetPushVapidPublicKeyQueryOptions = <
TData = Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError = ErrorType<ErrorResponse>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetPushVapidPublicKeyQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPushVapidPublicKey>>
> = ({ signal }) => getPushVapidPublicKey({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetPushVapidPublicKeyQueryResult = NonNullable<
Awaited<ReturnType<typeof getPushVapidPublicKey>>
>;
export type GetPushVapidPublicKeyQueryError = ErrorType<ErrorResponse>;
/**
* @summary Get the server's VAPID public key for Web Push subscriptions
*/
export function useGetPushVapidPublicKey<
TData = Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError = ErrorType<ErrorResponse>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPushVapidPublicKeyQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Register a Web Push subscription for the current user
*/
export const getSubscribePushUrl = () => {
return `/api/push/subscribe`;
};
export const subscribePush = async (
subscribePushBody: SubscribePushBody,
options?: RequestInit,
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getSubscribePushUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(subscribePushBody),
});
};
export const getSubscribePushMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
> => {
const mutationKey = ["subscribePush"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof subscribePush>>,
{ data: BodyType<SubscribePushBody> }
> = (props) => {
const { data } = props ?? {};
return subscribePush(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type SubscribePushMutationResult = NonNullable<
Awaited<ReturnType<typeof subscribePush>>
>;
export type SubscribePushMutationBody = BodyType<SubscribePushBody>;
export type SubscribePushMutationError = ErrorType<unknown>;
/**
* @summary Register a Web Push subscription for the current user
*/
export const useSubscribePush = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof subscribePush>>,
TError,
{ data: BodyType<SubscribePushBody> },
TContext
> => {
return useMutation(getSubscribePushMutationOptions(options));
};
/**
* @summary Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe)
*/
export const getDeletePushSubscriptionUrl = (
params: DeletePushSubscriptionParams,
) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/push/subscribe?${stringifiedParams}`
: `/api/push/subscribe`;
};
export const deletePushSubscription = async (
params: DeletePushSubscriptionParams,
options?: RequestInit,
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getDeletePushSubscriptionUrl(params), {
...options,
method: "DELETE",
});
};
export const getDeletePushSubscriptionMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deletePushSubscription>>,
TError,
{ params: DeletePushSubscriptionParams },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deletePushSubscription>>,
TError,
{ params: DeletePushSubscriptionParams },
TContext
> => {
const mutationKey = ["deletePushSubscription"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deletePushSubscription>>,
{ params: DeletePushSubscriptionParams }
> = (props) => {
const { params } = props ?? {};
return deletePushSubscription(params, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DeletePushSubscriptionMutationResult = NonNullable<
Awaited<ReturnType<typeof deletePushSubscription>>
>;
export type DeletePushSubscriptionMutationError = ErrorType<unknown>;
/**
* @summary Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe)
*/
export const useDeletePushSubscription = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deletePushSubscription>>,
TError,
{ params: DeletePushSubscriptionParams },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof deletePushSubscription>>,
TError,
{ params: DeletePushSubscriptionParams },
TContext
> => {
return useMutation(getDeletePushSubscriptionMutationOptions(options));
};
/**
* @summary Remove a Web Push subscription for the current user
*/
export const getUnsubscribePushUrl = () => {
return `/api/push/unsubscribe`;
};
export const unsubscribePush = async (
unsubscribePushBody: UnsubscribePushBody,
options?: RequestInit,
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getUnsubscribePushUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(unsubscribePushBody),
});
};
export const getUnsubscribePushMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
> => {
const mutationKey = ["unsubscribePush"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof unsubscribePush>>,
{ data: BodyType<UnsubscribePushBody> }
> = (props) => {
const { data } = props ?? {};
return unsubscribePush(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UnsubscribePushMutationResult = NonNullable<
Awaited<ReturnType<typeof unsubscribePush>>
>;
export type UnsubscribePushMutationBody = BodyType<UnsubscribePushBody>;
export type UnsubscribePushMutationError = ErrorType<unknown>;
/**
* @summary Remove a Web Push subscription for the current user
*/
export const useUnsubscribePush = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof unsubscribePush>>,
TError,
{ data: BodyType<UnsubscribePushBody> },
TContext
> => {
return useMutation(getUnsubscribePushMutationOptions(options));
};
/**
* @summary List all users (admin)
*/
+153
View File
@@ -30,6 +30,8 @@ tags:
description: Object storage upload and serving endpoints
- name: audit
description: Admin audit log
- name: system
description: System metadata (version, update availability)
paths:
/healthz:
@@ -45,6 +47,24 @@ paths:
schema:
$ref: "#/components/schemas/HealthStatus"
/system/version:
get:
operationId: getSystemVersion
tags: [system]
summary: Get current and latest system version
description: |
Returns the local build version (read from version.json embedded at
build time) and, if `LATEST_VERSION_URL` is configured on the
server, the latest published version fetched from that URL. Admin
only. Never performs an update — pure read.
responses:
"200":
description: System version info
content:
application/json:
schema:
$ref: "#/components/schemas/SystemVersionResponse"
# Auth
/auth/register:
post:
@@ -972,6 +992,101 @@ paths:
schema:
$ref: "#/components/schemas/SuccessResponse"
/push/vapid-public-key:
get:
operationId: getPushVapidPublicKey
tags: [notifications]
summary: Get the server's VAPID public key for Web Push subscriptions
responses:
"200":
description: VAPID public key
content:
application/json:
schema:
type: object
properties:
publicKey:
type: string
required: [publicKey]
"503":
description: Web Push not configured on this server
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/push/subscribe:
post:
operationId: subscribePush
tags: [notifications]
summary: Register a Web Push subscription for the current user
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
endpoint:
type: string
keys:
type: object
properties:
p256dh:
type: string
auth:
type: string
required: [p256dh, auth]
required: [endpoint, keys]
responses:
"200":
description: Subscribed
content:
application/json:
schema:
$ref: "#/components/schemas/SuccessResponse"
delete:
operationId: deletePushSubscription
tags: [notifications]
summary: Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe)
parameters:
- in: query
name: endpoint
required: true
schema:
type: string
description: The push endpoint URL to unsubscribe
responses:
"200":
description: Unsubscribed
content:
application/json:
schema:
$ref: "#/components/schemas/SuccessResponse"
/push/unsubscribe:
post:
operationId: unsubscribePush
tags: [notifications]
summary: Remove a Web Push subscription for the current user
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
endpoint:
type: string
required: [endpoint]
responses:
"200":
description: Unsubscribed
content:
application/json:
schema:
$ref: "#/components/schemas/SuccessResponse"
# Users (admin)
/users:
get:
@@ -2832,6 +2947,44 @@ components:
required:
- status
SystemVersionInfo:
type: object
properties:
version:
type: string
build:
type: string
required:
- version
- build
SystemVersionResponse:
type: object
properties:
current:
$ref: "#/components/schemas/SystemVersionInfo"
latest:
oneOf:
- $ref: "#/components/schemas/SystemVersionInfo"
- type: "null"
status:
type: string
enum: [up-to-date, update-available, not-configured, error]
errorMessage:
type: ["string", "null"]
checkedAt:
type: ["string", "null"]
format: date-time
configured:
type: boolean
required:
- current
- latest
- status
- errorMessage
- checkedAt
- configured
ErrorResponse:
type: object
properties:
+77
View File
@@ -14,6 +14,37 @@ export const HealthCheckResponse = zod.object({
status: zod.string(),
});
/**
* Returns the local build version (read from version.json embedded at
build time) and, if `LATEST_VERSION_URL` is configured on the
server, the latest published version fetched from that URL. Admin
only. Never performs an update — pure read.
* @summary Get current and latest system version
*/
export const GetSystemVersionResponse = zod.object({
current: zod.object({
version: zod.string(),
build: zod.string(),
}),
latest: zod.union([
zod.object({
version: zod.string(),
build: zod.string(),
}),
zod.null(),
]),
status: zod.enum([
"up-to-date",
"update-available",
"not-configured",
"error",
]),
errorMessage: zod.string().nullable(),
checkedAt: zod.coerce.date().nullable(),
configured: zod.boolean(),
});
/**
* @summary Register a new user
*/
@@ -1458,6 +1489,52 @@ export const MarkAllNotificationsReadResponse = zod.object({
success: zod.boolean(),
});
/**
* @summary Get the server's VAPID public key for Web Push subscriptions
*/
export const GetPushVapidPublicKeyResponse = zod.object({
publicKey: zod.string(),
});
/**
* @summary Register a Web Push subscription for the current user
*/
export const SubscribePushBody = zod.object({
endpoint: zod.string(),
keys: zod.object({
p256dh: zod.string(),
auth: zod.string(),
}),
});
export const SubscribePushResponse = zod.object({
success: zod.boolean(),
});
/**
* @summary Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe)
*/
export const DeletePushSubscriptionQueryParams = zod.object({
endpoint: zod.coerce
.string()
.describe("The push endpoint URL to unsubscribe"),
});
export const DeletePushSubscriptionResponse = zod.object({
success: zod.boolean(),
});
/**
* @summary Remove a Web Push subscription for the current user
*/
export const UnsubscribePushBody = zod.object({
endpoint: zod.string(),
});
export const UnsubscribePushResponse = zod.object({
success: zod.boolean(),
});
/**
* @summary List all users (admin)
*/
+5
View File
@@ -252,6 +252,11 @@ export const executiveMeetingAlertStateTable = pgTable(
.references(() => usersTable.id, { onDelete: "cascade" }),
dismissed: boolean("dismissed").notNull().default(false),
acknowledged: boolean("acknowledged").notNull().default(false),
// #558: Set once by the server-side scheduler the moment a Web
// Push reminder has been fired for this (meeting, user) pair, so a
// single tick (or any later tick that re-runs the same query) does
// not double-ring the iPad. NULL = no push has been sent yet.
pushedAt: timestamp("pushed_at", { withTimezone: true }),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
+1
View File
@@ -14,3 +14,4 @@ export * from "./audit-logs";
export * from "./role-audit";
export * from "./permission-audit";
export * from "./executive-meetings";
export * from "./push-subscriptions";
+35
View File
@@ -0,0 +1,35 @@
import {
pgTable,
serial,
integer,
text,
varchar,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { usersTable } from "./users";
export const pushSubscriptionsTable = pgTable(
"push_subscriptions",
{
id: serial("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
endpoint: text("endpoint").notNull().unique(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
userAgent: varchar("user_agent", { length: 400 }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
userIdx: index("push_subscriptions_user_idx").on(t.userId),
}),
);
export type PushSubscriptionRow = typeof pushSubscriptionsTable.$inferSelect;
+3 -1
View File
@@ -4,10 +4,12 @@
"license": "MIT",
"scripts": {
"preinstall": "sh -c 'rm -f package-lock.json yarn.lock; case \"$npm_config_user_agent\" in pnpm/*) ;; *) echo \"Use pnpm instead\" >&2; exit 1 ;; esac'",
"update-version": "node scripts/update-version.mjs",
"prebuild": "pnpm run update-version",
"build": "pnpm run typecheck && pnpm -r --if-present run build",
"typecheck:libs": "tsc --build",
"typecheck": "pnpm run typecheck:libs && pnpm -r --filter \"./artifacts/**\" --filter \"./scripts\" --if-present run typecheck",
"test": "pnpm --filter @workspace/api-server test && pnpm --filter @workspace/scripts test && pnpm --filter @workspace/tx-os test:e2e"
"test": "node artifacts/api-server/scripts/with-server.mjs -- sh -c 'pnpm --filter @workspace/api-server run test:run && pnpm --filter @workspace/scripts test && pnpm --filter @workspace/tx-os test:e2e'"
},
"private": true,
"devDependencies": {
+122 -1
View File
@@ -46,7 +46,7 @@ catalogs:
specifier: ^4.1.14
version: 4.2.1
tsx:
specifier: ^4.21.0
specifier: 4.21.0
version: 4.21.0
vite:
specifier: ^7.3.2
@@ -135,9 +135,15 @@ importers:
sanitize-html:
specifier: ^2.17.3
version: 2.17.3
semver:
specifier: ^7.6.3
version: 7.8.0
socket.io:
specifier: ^4.8.3
version: 4.8.3
web-push:
specifier: ^3.6.7
version: 3.6.7
zod:
specifier: 'catalog:'
version: 3.25.76
@@ -172,6 +178,12 @@ importers:
'@types/sanitize-html':
specifier: ^2.16.1
version: 2.16.1
'@types/semver':
specifier: ^7.5.8
version: 7.7.1
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
esbuild:
specifier: ^0.27.3
version: 0.27.3
@@ -190,6 +202,9 @@ importers:
thread-stream:
specifier: 3.1.0
version: 3.1.0
tsx:
specifier: 'catalog:'
version: 4.21.0
artifacts/mockup-sandbox:
devDependencies:
@@ -2987,6 +3002,9 @@ packages:
'@types/sanitize-html@2.16.1':
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
'@types/semver@7.7.1':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
@@ -3002,6 +3020,9 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/web-push@3.6.4':
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -3093,6 +3114,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ajv-draft-04@1.0.0:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
@@ -3125,6 +3150,9 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@@ -3155,6 +3183,9 @@ packages:
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
bn.js@4.12.3:
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@@ -3180,6 +3211,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -3508,6 +3542,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -3812,6 +3849,14 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
http_ece@1.2.0:
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
engines: {node: '>=16'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
human-signals@8.0.1:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
@@ -3933,6 +3978,12 @@ packages:
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
engines: {node: '>=0.10.0'}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
leven@4.1.0:
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -4091,6 +4142,9 @@ packages:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -4623,6 +4677,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.0:
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
engines: {node: '>=10'}
hasBin: true
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
@@ -4942,6 +5001,11 @@ packages:
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
web-push@3.6.7:
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
engines: {node: '>= 16'}
hasBin: true
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -7490,6 +7554,8 @@ snapshots:
dependencies:
htmlparser2: 10.1.0
'@types/semver@7.7.1': {}
'@types/send@1.2.1':
dependencies:
'@types/node': 25.3.5
@@ -7506,6 +7572,10 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
'@types/web-push@3.6.4':
dependencies:
'@types/node': 25.3.5
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.3.5
@@ -7615,6 +7685,8 @@ snapshots:
acorn@8.16.0: {}
agent-base@7.1.4: {}
ajv-draft-04@1.0.0(ajv@8.18.0):
optionalDependencies:
ajv: 8.18.0
@@ -7642,6 +7714,13 @@ snapshots:
dependencies:
tslib: 2.8.1
asn1.js@5.4.1:
dependencies:
bn.js: 4.12.3
inherits: 2.0.4
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
atomic-sleep@1.0.0: {}
balanced-match@1.0.2: {}
@@ -7660,6 +7739,8 @@ snapshots:
dependencies:
require-from-string: 2.0.2
bn.js@4.12.3: {}
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@@ -7700,6 +7781,8 @@ snapshots:
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
bytes@3.1.2: {}
@@ -7912,6 +7995,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
electron-to-chromium@1.5.307: {}
@@ -8344,6 +8431,15 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http_ece@1.2.0: {}
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
human-signals@8.0.1: {}
i18next@26.0.6(typescript@5.9.3):
@@ -8421,6 +8517,17 @@ snapshots:
jsonpointer@5.0.1: {}
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
leven@4.1.0: {}
lightningcss-android-arm64@1.31.1:
@@ -8547,6 +8654,8 @@ snapshots:
dependencies:
mime-db: 1.54.0
minimalistic-assert@1.0.1: {}
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.2
@@ -9128,6 +9237,8 @@ snapshots:
semver@6.3.1: {}
semver@7.8.0: {}
send@1.2.1:
dependencies:
debug: 4.4.3
@@ -9440,6 +9551,16 @@ snapshots:
w3c-keyname@2.2.8: {}
web-push@3.6.7:
dependencies:
asn1.js: 5.4.1
http_ece: 1.2.0
https-proxy-agent: 7.0.6
jws: 4.0.1
minimist: 1.2.8
transitivePeerDependencies:
- supports-color
which@2.0.2:
dependencies:
isexe: 2.0.0
+1 -1
View File
@@ -22,7 +22,7 @@ catalog:
react-dom: 19.1.0
tailwind-merge: ^3.3.1
tailwindcss: ^4.1.14
tsx: ^4.21.0
tsx: 4.21.0
vite: ^7.3.2
zod: 3.25.76
+1
View File
@@ -7,6 +7,7 @@
"hello": "tsx ./src/hello.ts",
"seed": "tsx ./src/seed.ts",
"remove-chat": "tsx ./src/remove-chat.ts",
"enable-default-apps": "tsx ./src/enable-default-apps.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test 'tests/**/*.test.mjs'"
},
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Tx OS — redeploy after `git pull` on the Mac (or any Docker host).
#
# Why this exists:
# The SPA is baked into the `web` (nginx) image at build time, and the
# API binary is baked into the `api` image. `docker compose up -d` on
# its own re-uses the existing images, so a plain `git pull` will NOT
# pick up new front-end assets (e.g. service tile images), code
# changes, or DB-seed updates.
#
# What it does:
# 1. git pull — fast-forward the working tree (skipped with --no-pull)
# 2. docker compose build api web
# — rebuild both images so source + static assets are fresh
# 3. docker compose run --rm migrate
# — apply Drizzle schema (migrate.sh also runs the seed,
# but tolerates seed failures — see step 4)
# 4. docker compose run --rm --entrypoint "" --no-deps migrate \
# pnpm --filter @workspace/scripts run seed
# — explicit seed pass that fails hard if the seed
# errors. Catches new-app / renamed-app regressions
# that migrate.sh would otherwise swallow.
# 5. docker compose up -d
# — restart api + web with the new images
#
# Safe to re-run. Each step is idempotent.
set -euo pipefail
PULL=1
for arg in "$@"; do
case "$arg" in
--no-pull) PULL=0 ;;
-h|--help)
sed -n '2,27p' "$0"
exit 0
;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: $0 [--no-pull]" >&2
exit 2
;;
esac
done
# Resolve the repo root so the script works no matter where it's invoked from.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
if [ ! -f docker-compose.yml ]; then
echo "[redeploy] error: docker-compose.yml not found in $REPO_ROOT" >&2
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
echo "[redeploy] error: docker CLI not found in PATH" >&2
exit 1
fi
# Pick `docker compose` (v2) and fall back to `docker-compose` (v1) so this
# works on older hosts.
if docker compose version >/dev/null 2>&1; then
DC=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
DC=(docker-compose)
else
echo "[redeploy] error: neither 'docker compose' nor 'docker-compose' is available" >&2
exit 1
fi
step() { echo; echo "==> $*"; }
if [ "$PULL" -eq 1 ]; then
step "1/4 git pull"
git pull --ff-only
else
step "1/4 git pull (skipped via --no-pull)"
fi
step "2/5 docker compose build api web (rebuilds SPA + API images)"
"${DC[@]}" build api web
step "3/5 docker compose run --rm migrate (schema push + tolerant seed)"
"${DC[@]}" run --rm migrate
step "4/5 explicit seed pass (fails hard on seed regressions)"
"${DC[@]}" run --rm --no-deps --entrypoint "" migrate \
pnpm --filter @workspace/scripts run seed
step "5/5 docker compose up -d (restart with the new images)"
"${DC[@]}" up -d
echo
echo "[redeploy] done. Verify in a browser; hard-refresh once to bust the SPA cache."
+29
View File
@@ -0,0 +1,29 @@
import { db, appsTable } from "@workspace/db";
import { inArray } from "drizzle-orm";
const SLUGS = ["calendar", "notifications"];
async function main() {
console.log(`Enabling apps: ${SLUGS.join(", ")}...`);
const updated = await db
.update(appsTable)
.set({ isActive: true })
.where(inArray(appsTable.slug, SLUGS))
.returning({ slug: appsTable.slug, isActive: appsTable.isActive });
if (updated.length === 0) {
console.warn("No matching apps found — did seed run yet?");
} else {
for (const row of updated) {
console.log(`${row.slug} (isActive=${row.isActive})`);
}
}
console.log("Done.");
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => process.exit(0));
+135 -21
View File
@@ -231,7 +231,14 @@ async function main() {
iconName: "Bell",
route: "/notifications",
color: "#8b5cf6",
isActive: true,
// #571: ship the Notifications app DISABLED by default so fresh
// installs don't surface a redundant tile alongside the bell icon
// already in the top bar. The bell stays the canonical entry
// point; admins can flip this on from app settings if they want
// the launcher tile too. Insert here uses onConflictDoNothing,
// so existing environments that already have the app enabled
// are NOT affected — only new inserts pick up the default.
isActive: false,
isSystem: true,
sortOrder: 3,
},
@@ -248,6 +255,19 @@ async function main() {
isSystem: true,
sortOrder: 4,
},
{
slug: "notes",
nameAr: "الملاحظات",
nameEn: "Notes",
descriptionAr: "ملاحظاتك الشخصية ورسائلك بين الزملاء",
descriptionEn: "Personal notes and messages with colleagues",
iconName: "StickyNote",
route: "/notes",
color: "#eab308",
isActive: true,
isSystem: false,
sortOrder: 8,
},
{
slug: "calendar",
nameAr: "التقويم",
@@ -257,7 +277,11 @@ async function main() {
iconName: "Calendar",
route: "/calendar",
color: "#3b82f6",
isActive: true,
// #571: ship the Calendar app DISABLED by default. Admins can
// enable it from app settings when they actually want a
// calendar surface. Insert uses onConflictDoNothing so existing
// environments where calendar is already enabled stay enabled.
isActive: false,
isSystem: false,
sortOrder: 5,
},
@@ -276,10 +300,10 @@ async function main() {
},
{
slug: "executive-meetings",
nameAr: "إدارة الاجتماعات التنفيذية",
nameEn: "Executive Meetings",
descriptionAr: "جدولة اجتماعات المكتب التنفيذي ومتابعتها",
descriptionEn: "Schedule and track executive office meetings",
nameAr: "الاجتماعات",
nameEn: "Meetings",
descriptionAr: "جدولة الاجتماعات ومتابعتها",
descriptionEn: "Schedule and track meetings",
iconName: "CalendarClock",
route: "/meetings",
color: "#0B1E3F",
@@ -321,17 +345,26 @@ async function main() {
const insertedApps = await db.insert(appsTable).values(apps).onConflictDoNothing().returning();
console.log("Apps created");
// Force the executive-meetings row onto the canonical /meetings route.
// The slug is in BUILTIN_APP_SLUGS, so the SPA owns its route — any
// drift in the DB (including legacy /executive-meetings or other
// experimental values) would launch the home tile at a path that
// has no React route and silently break the app. `onConflictDoNothing`
// above skips updating existing rows, so this UPDATE is what carries
// the route migration into already-deployed environments. The WHERE
// by slug keeps it idempotent across re-seeds.
// Force the executive-meetings row onto the canonical /meetings route
// AND onto the current display name. The slug is in BUILTIN_APP_SLUGS,
// so the SPA owns its route — any drift in the DB (including legacy
// /executive-meetings or other experimental values) would launch the
// home tile at a path that has no React route and silently break the
// app. `onConflictDoNothing` above skips updating existing rows, so
// this UPDATE is what carries the route + name migration into
// already-deployed environments. The WHERE by slug keeps it
// idempotent across re-seeds. Names are kept in sync with the
// hardcoded values in the `apps` array above so admins on existing
// deployments don't have to rename the tile by hand.
await db
.update(appsTable)
.set({ route: "/meetings" })
.set({
route: "/meetings",
nameAr: "الاجتماعات",
nameEn: "Meetings",
descriptionAr: "جدولة الاجتماعات ومتابعتها",
descriptionEn: "Schedule and track meetings",
})
.where(eq(appsTable.slug, "executive-meetings"));
// Restrict admin app to users with apps:manage permission
@@ -404,6 +437,7 @@ async function main() {
categoryId: beveragesCat?.id ?? null,
nameAr: "شاي",
nameEn: "Tea",
imageUrl: "service-images/tea.png",
price: "0.00",
isAvailable: true,
sortOrder: 1,
@@ -421,6 +455,7 @@ async function main() {
categoryId: beveragesCat?.id ?? null,
nameAr: "بلاك كوفي",
nameEn: "Black Coffee",
imageUrl: "service-images/black-coffee.png",
price: "0.00",
isAvailable: true,
sortOrder: 3,
@@ -429,6 +464,7 @@ async function main() {
categoryId: beveragesCat?.id ?? null,
nameAr: "عصير طازج",
nameEn: "Fresh Juice",
imageUrl: "service-images/juice.png",
price: "5.00",
isAvailable: true,
sortOrder: 5,
@@ -438,6 +474,23 @@ async function main() {
await db.insert(servicesTable).values(services).onConflictDoNothing({ target: servicesTable.nameEn });
console.log("Services created");
// Backfill default imageUrl on existing rows that have a NULL imageUrl
// (the insert above is onConflictDoNothing, so existing rows aren't
// touched). Only fills NULL — admin-customized URLs are never
// overwritten. Re-reads the table so newly-inserted rows are included.
const allServicesAfterInsert = await db.select().from(servicesTable);
for (const seed of services) {
if (!seed.imageUrl) continue;
const existing = allServicesAfterInsert.find((s) => s.nameEn === seed.nameEn);
if (existing && !existing.imageUrl) {
await db
.update(servicesTable)
.set({ imageUrl: seed.imageUrl })
.where(eq(servicesTable.id, existing.id));
}
}
console.log("Service images backfilled for NULL rows");
// ----- Groups: idempotent migration -----
await db
.insert(groupsTable)
@@ -646,13 +699,74 @@ async function main() {
}
}
// Executive meetings: sample data for today (idempotent per-date)
// Notes: gate the home-screen icon behind a dedicated permission so a
// freshly seeded deployment does NOT auto-show Notes to every regular
// user. By default only the `admin` role gets the permission; admins
// then grant `notes.access` to whichever roles/users they want from
// the admin panel. Same three-step pattern as executive_meetings.access
// above — all inserts are idempotent.
await db
.insert(permissionsTable)
.values({
name: "notes.access",
descriptionAr: "الوصول إلى تطبيق الملاحظات",
descriptionEn: "Access the Notes app",
})
.onConflictDoNothing();
const [notesAccessPerm] = await db
.select()
.from(permissionsTable)
.where(eq(permissionsTable.name, "notes.access"));
if (notesAccessPerm) {
const adminRoleForNotes = (await db.select().from(rolesTable))
.find((r) => r.name === "admin");
if (adminRoleForNotes) {
await db
.insert(rolePermissionsTable)
.values({
roleId: adminRoleForNotes.id,
permissionId: notesAccessPerm.id,
})
.onConflictDoNothing();
}
const [notesApp] = await db
.select()
.from(appsTable)
.where(eq(appsTable.slug, "notes"));
if (notesApp) {
await db
.insert(appPermissionsTable)
.values({ appId: notesApp.id, permissionId: notesAccessPerm.id })
.onConflictDoNothing();
console.log(
"App permissions set: notes app restricted to notes.access permission",
);
}
}
// Executive meetings: sample data for today (idempotent per-date).
//
// Opt-in only — a vanilla self-hosted install should start with an
// empty Executive Meetings module. Set `SEED_DEMO_MEETINGS=true` in
// your environment when bootstrapping a demo / staging deploy that
// benefits from realistic-looking sample data.
const seedDemoMeetings =
(process.env.SEED_DEMO_MEETINGS ?? "").toLowerCase() === "true";
const today = new Date().toISOString().slice(0, 10);
const existingForToday = await db
.select({ id: executiveMeetingsTable.id })
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, today));
if (existingForToday.length === 0) {
const existingForToday = seedDemoMeetings
? await db
.select({ id: executiveMeetingsTable.id })
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, today))
: [];
if (!seedDemoMeetings) {
console.log(
"Executive Meetings demo data skipped (set SEED_DEMO_MEETINGS=true to enable)",
);
} else if (existingForToday.length === 0) {
{
const sampleMeetings: Array<{
meeting: {
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const versionPath = resolve(__dirname, "..", "version.json");
function git(args) {
return execSync(`git ${args}`, { encoding: "utf8" }).trim();
}
let commit = "unknown";
let date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
try {
commit = git("log -1 --format=%h");
date = git("log -1 --format=%cd --date=format:%Y%m%d");
} catch (err) {
console.warn(`[update-version] git unavailable, using fallback: ${err.message}`);
}
const build = `${date}.${commit}`;
const current = JSON.parse(readFileSync(versionPath, "utf8"));
const next = { ...current, build };
if (current.build === build) {
console.log(`[update-version] build unchanged: ${build}`);
} else {
writeFileSync(versionPath, JSON.stringify(next, null, 2) + "\n");
console.log(`[update-version] ${current.build} -> ${build}`);
}
+4
View File
@@ -0,0 +1,4 @@
{
"version": "0.1.0-dev",
"build": "20260517.b5efd9eb"
}