63 Commits

Author SHA1 Message Date
Riyadh 1000ed3a4f Task #673: rename booking nav labels (Arabic)
Renamed two Arabic sidebar tab labels in the protocol (room-booking)
module in artifacts/tx-os/src/locales/ar.json:
- protocol.tabs.bookings: "حجوزات القاعات" → "حجز القاعات"
- protocol.tabs.external: "اللقاءات الخارجية" → "اجتماعات خارجية"

English translations left unchanged. The chart label
"اللقاءات الخارجية حسب الحالة" was left as-is per the task's out-of-scope
note. Text-only change, no logic touched.
2026-07-07 12:50:29 +00:00
Riyadh aafd0fee4b Transitioned from Plan to Build mode 2026-07-07 12:49:56 +00:00
Riyadh 96f9f8150e Task #670: revamp public room-booking form (protocol)
Public booking form now captures department, meeting type (internal/
external), meeting purpose, and a repeatable "key attendees" table
(name + position + side). External meetings require the entity name
(reuses requesterOrg). "عدد الحضور" relabeled to "العدد المتوقع للحضور".
Added a static red disclaimer note about possible cancellation.

Every booking (public + internal) now gets a unique, NOT NULL, searchable
reference (RB-{year}-{pad4(id)}) shown on the success screen. Staff can
search the admin bookings list by reference OR phone.

Changes:
- lib/db schema: department, meetingType, purpose, keyAttendees (jsonb),
  bookingReference (NOT NULL + unique) + requesterPhone index; new
  enums/types. Applied via drizzle push; existing rows backfilled.
- api-server: extended public booking schema + refine (org required when
  external); reserveBookingId (nextval on the serial sequence) so the
  reference is written in a single INSERT and can never be null; GET
  /protocol/bookings accepts a `q` param filtering by reference OR phone
  (server-side, indexed, case-insensitive).
- tx-os public form rewritten; admin card shows new fields; admin search
  box now debounced and wired to the API `q` param (server-side filtering)
  with an empty state.
- ar/en i18n keys under protocol.bookings.

Deploy safety / backfill:
- lib/db pre-push-cleanup (runs before every drizzle push, dev + deploy)
  now backfills booking_reference for any pre-existing rows
  (RB-{year(created_at)}-{pad4(id)}) BEFORE the NOT NULL + unique
  constraint is enforced, so pushing the constraint on a populated table
  can never fail. Idempotent: only updates NULL rows.

Verification:
- Public booking POST returns a reference; external-without-org rejected
  (400); DB has zero null references after push. Re-running push logs
  "No protocol_room_bookings booking_reference to backfill" (idempotent).
- Pre-existing push.ts / executive-meetings.ts typecheck errors are
  unrelated to this task (files untouched).
2026-07-07 12:38:03 +00:00
Riyadh dd2816a1a9 Task #670: revamp public room-booking form (protocol)
Public booking form now captures department, meeting type (internal/
external), meeting purpose, and a repeatable "key attendees" table
(name + position + side). External meetings require the entity name
(reuses requesterOrg). "عدد الحضور" relabeled to "العدد المتوقع للحضور".
Added a static red disclaimer note about possible cancellation.

Every booking (public + internal) now gets a unique, NOT NULL, searchable
reference (RB-{year}-{pad4(id)}) shown on the success screen. Staff can
search the admin bookings list by reference OR phone.

Changes:
- lib/db schema: department, meetingType, purpose, keyAttendees (jsonb),
  bookingReference (NOT NULL + unique) + requesterPhone index; new
  enums/types. Applied via drizzle push; existing rows backfilled.
- api-server: extended public booking schema + refine (org required when
  external); reserveBookingId (nextval on the serial sequence) so the
  reference is written in a single INSERT and can never be null; GET
  /protocol/bookings accepts a `q` param filtering by reference OR phone
  (server-side, indexed, case-insensitive).
- tx-os public form rewritten; admin card shows new fields; admin search
  box now debounced and wired to the API `q` param (server-side filtering)
  with an empty state.
- ar/en i18n keys under protocol.bookings.

Verification:
- Public booking POST returns a reference; external-without-org rejected
  (400); DB has zero null references after push.
- Pre-existing push.ts / executive-meetings.ts typecheck errors are
  unrelated to this task (files untouched).
2026-07-07 12:34:52 +00:00
Riyadh 3ff1338c7e Task #670: revamp public room-booking form (protocol)
Public booking form now captures department, meeting type (internal/
external), meeting purpose, and a repeatable "key attendees" table
(name + position + side). External meetings require the entity name
(reuses requesterOrg). "عدد الحضور" relabeled to "العدد المتوقع للحضور".
Added a static red disclaimer note about possible cancellation.

Each booking now gets an auto-generated searchable reference
(RB-{year}-{pad4}) shown on the success screen; staff can search the
admin bookings list by reference OR phone.

Changes:
- lib/db schema: department, meetingType, purpose, keyAttendees (jsonb),
  bookingReference (unique) + requesterPhone index; new enums/types.
  Applied via drizzle push; backfilled existing 4 rows.
- api-server: extended public booking schema + refine (org required when
  external), makeBookingReference, public/internal POST generate+return
  bookingReference.
- tx-os public form rewritten; admin card shows new fields + client-side
  search with empty state.
- ar/en i18n keys under protocol.bookings.

Notes:
- Pre-existing push.ts typecheck errors are unrelated to this task.
- Architect review: PASS, no blocking issues.
2026-07-07 12:26:19 +00:00
Riyadh e167b3ef45 Transitioned from Plan to Build mode 2026-07-07 12:15:51 +00:00
Riyadh 97f0b9217d Task #668: Rename Protocol side-menu items (bilingual AR/EN)
Relabeled three Protocol (العلاقات العامة والمراسم) menu items to match real usage:
- photos: "تصوير" -> "الصور" / "Photos"
- gifts (item catalog where أصناف are added): "الإهداءات" -> "المستودع" / "Warehouse"
- issues (giving gifts out): "طلبات الصرف" -> "الإهداءات" / "Dedications"

Swapped menu icons to match new meanings: gifts->Warehouse, issues->Gift,
photos->ImageIcon. Removed now-unused imports (PackageCheck, Camera).

Grouped المستودع + الإهداءات under one expandable submenu (parent label
"الهدايا"/"Gifts", new i18n key protocol.tabs.giftsGroup, HandHeart icon,
ChevronDown toggle). Group auto-expands when a child tab is active; state
giftsGroupOpen. RTL-aware (ms-auto chevron, md:ps-6 child indent).

Aligned in-screen wording with new names in both locales:
dashboard.pendingIssues, issues.new, issues.empty reworded from
issuance/صرف to dedication/إهداء.

Deviation: submenu grouping and parent-group naming ("الهدايا") were an
interpretation of the user's "sub-menu" request (user_query was unavailable
during planning). Kept scope tight: remaining secondary "issuance/صرف"
strings in reports/actions/dashboard stats (e.g. giftsIssuedThisMonth,
actions.issued, reports.byGift) were left unchanged and proposed as a
follow-up.

Unchanged (deliberately): internal tab keys (gifts/issues/photos) and URL
slugs (gifts, gift-issues, photos) so deep links keep working. No feature,
API, or data changes.

Verified: pnpm --filter @workspace/tx-os run typecheck passes; architect
code review passed after issues icon corrected to Gift.
2026-07-07 11:58:20 +00:00
Riyadh bd50a93db5 Transitioned from Plan to Build mode 2026-07-07 11:52:01 +00:00
Riyadh 049d01def5 Task #666: Today & Book buttons in room-booking calendar view
Added two quick actions inside the Protocol bookings "Calendar" view's day
panel header (artifacts/tx-os/src/pages/protocol.tsx):
- "Today" (اليوم): outline button that resets both selectedDay and a new
  controlled calendarMonth state to new Date(), so the month picker, day
  time-grid, and red now-line all snap to today. The Calendar is now
  controlled via month/onMonthChange (defaultMonth is initial-only and did
  not re-navigate after mount).
- "Book" (حجز): primary button gated by the same c?.canRequest capability as
  the existing toolbar new-booking button. Opens the new-booking dialog
  pre-filled with the currently selected day.

Implementation:
- Extended bookingDialog state with an optional `day?: Date`.
- BookingDialog now accepts an `initialDay?: Date` prop; for new (non-edit)
  bookings it pre-fills startsAt=09:00 and endsAt=10:00 on that day via a
  local dayAt(hour) helper (reuses toLocalInput). Edit flow unchanged.
- Added i18n keys protocol.bookings.today / protocol.bookings.book to both
  ar.json and en.json. Buttons use logical spacing (me-1) so they mirror in
  Arabic RTL.

No backend/API changes. List/Calendar toggle unaffected. tx-os typecheck
passes; HMR clean, no console errors. Screenshot tool unavailable (no
artifact registered in this repo), verified via typecheck + logs.
2026-07-07 10:36:01 +00:00
Riyadh a8f4693fce Task #666: Today & Book buttons in room-booking calendar view
Added two quick actions inside the Protocol bookings "Calendar" view's day
panel header (artifacts/tx-os/src/pages/protocol.tsx):
- "Today" (اليوم): outline button that resets selectedDay to new Date(), so
  the month picker, day time-grid, and red now-line all snap to today.
- "Book" (حجز): primary button gated by the same c?.canRequest capability as
  the existing toolbar new-booking button. Opens the new-booking dialog
  pre-filled with the currently selected day.

Implementation:
- Extended bookingDialog state with an optional `day?: Date`.
- BookingDialog now accepts an `initialDay?: Date` prop; for new (non-edit)
  bookings it pre-fills startsAt=09:00 and endsAt=10:00 on that day via a
  local dayAt(hour) helper (reuses toLocalInput). Edit flow unchanged.
- Added i18n keys protocol.bookings.today / protocol.bookings.book to both
  ar.json and en.json. Buttons use logical spacing (me-1) so they mirror in
  Arabic RTL.

No backend/API changes. List/Calendar toggle unaffected. tx-os typecheck
passes; HMR clean, no console errors. Screenshot tool unavailable (no
artifact registered in this repo), verified via typecheck + logs.
2026-07-07 10:34:16 +00:00
Riyadh 2bea68c92d Transitioned from Plan to Build mode 2026-07-07 10:31:59 +00:00
Riyadh 83f07c7a22 Task #662: Google-Calendar-style room booking calendar view
Replaced the Protocol bookings "Calendar" view (previously an agenda list
grouped by day) with a two-panel Google-Calendar layout in
artifacts/tx-os/src/pages/protocol.tsx:
- LEFT: a mini month day-picker (reuses components/ui/calendar.tsx,
  react-day-picker single mode) driving a selectedDay state. Days that
  have bookings get a small sky dot via a `booked` modifier.
- RIGHT: a single-day vertical time-grid (BookingDayGrid) over working
  hours 07:00–22:00 (56px/hour). Bookings render as absolutely-positioned
  blocks: top = start offset, height = duration (min 22px), colored by
  status (BOOKING_BLOCK_STYLE) with an inline-start accent border and a
  title/room/time label.
- Overlapping bookings are laid out side-by-side via interval-partition
  column assignment (layoutDayBookings), chained overlaps share a column
  count.
- A live RED current-time line + red dot (useNow, 60s) shows only when the
  selected day is today and within grid hours.

Details:
- View-only (no drag/create); single-day grid only. No backend changes.
- RTL/i18n: logical props (insetInlineStart, border-s) so it mirrors in
  Arabic; added protocol.bookings.emptyDay to ar.json + en.json.
- Blocks with no visible intersection with the grid range are skipped
  (fixes a phantom-block bug for bookings fully before 7am / after 10pm,
  found in code review).
- Removed the now-unused groupBookingsByDay helper.
- List toggle preserved. tx-os typecheck passes.
2026-07-07 10:27:15 +00:00
Riyadh 42d8ae13ad Transitioned from Plan to Build mode 2026-07-07 10:20:17 +00:00
Riyadh 5bfe56f18c Add download for protocol visit photos (Task #660)
Users could upload photos into Protocol "تصوير" albums but had no way to
download them — clicking a photo only opened it inline. This adds per-photo
downloads and a whole-album ZIP download.

Backend:
- objectStorage.ts: new exported extensionForContentType() mapping content
  types to file extensions (stored paths are extension-less UUIDs).
- storage.ts: opt-in ?download=1 mode on GET /storage/objects/*path sets
  Content-Disposition: attachment (with optional ?name base + derived ext);
  default inline behavior and per-entity authz unchanged.
- protocol.ts: new GET /protocol/photo-albums/:id/download streams all album
  photos as a ZIP (guarded by requireProtocolAccess), named from the album;
  unreadable objects are skipped rather than failing the archive. Empty album
  returns 404.
- Added archiver@8 dependency (+@types/archiver dev).

Frontend (protocol.tsx):
- Per-photo download button (attachment, name = album-index) in album detail.
- "تحميل الكل" / "Download all" ZIP button in album header; rendered disabled
  (not hidden) when the album has no photos.
- Added photoDownloadSrc/albumDownloadSrc helpers, Download icon.
- AR/EN locale strings protocol.photos.download / downloadAll.

Notes / deviations:
- archiver v8 is ESM-only with no callable default export; used
  `new ZipArchive(...)` instead of `archiver("zip")`. Recorded in memory.
- Could not run authenticated e2e (seed admin password is a secret). Verified:
  api-server typecheck + build pass, tx-os typecheck passes, server healthy,
  new routes wired (401 auth-required not 404). Pre-existing typecheck errors
  in executive-meetings.ts / push.ts are unrelated and untouched.
2026-07-07 09:37:15 +00:00
Riyadh a36adb5e9e Add download for protocol visit photos (Task #660)
Users could upload photos into Protocol "تصوير" albums but had no way to
download them — clicking a photo only opened it inline. This adds per-photo
downloads and a whole-album ZIP download.

Backend:
- objectStorage.ts: new exported extensionForContentType() mapping content
  types to file extensions (stored paths are extension-less UUIDs).
- storage.ts: opt-in ?download=1 mode on GET /storage/objects/*path sets
  Content-Disposition: attachment (with optional ?name base + derived ext);
  default inline behavior and per-entity authz unchanged.
- protocol.ts: new GET /protocol/photo-albums/:id/download streams all album
  photos as a ZIP (guarded by requireProtocolAccess), named from the album;
  unreadable objects are skipped rather than failing the archive. Empty album
  returns 404.
- Added archiver@8 dependency (+@types/archiver dev).

Frontend (protocol.tsx):
- Per-photo download button (attachment, name = album-index) in album detail.
- "تحميل الكل" / "Download all" ZIP button in album header (hidden when empty).
- Added photoDownloadSrc/albumDownloadSrc helpers, Download icon.
- AR/EN locale strings protocol.photos.download / downloadAll.

Notes / deviations:
- archiver v8 is ESM-only with no callable default export; used
  `new ZipArchive(...)` instead of `archiver("zip")`. Recorded in memory.
- Could not run authenticated e2e (seed admin password is a secret). Verified:
  api-server typecheck + build pass, tx-os typecheck passes, server healthy,
  new routes wired (401 auth-required not 404). Pre-existing typecheck errors
  in executive-meetings.ts / push.ts are unrelated and untouched.
2026-07-07 09:34:06 +00:00
Riyadh 22af2e8848 Transitioned from Plan to Build mode 2026-07-07 09:25:28 +00:00
Riyadh 224e69f111 Add functionality to manage visit photos and albums for protocols
Introduce new database tables for photo albums and photos, add API endpoints for CRUD operations on albums and photos, and implement frontend components for managing photo albums.
2026-07-07 09:14:07 +00:00
Riyadh 05586fc997 Transitioned from Plan to Build mode 2026-07-07 08:58:54 +00:00
Riyadh 36c1dee00a Make external meeting titles display in red
Update the styling of the MeetingRow component to conditionally apply red text color to the meeting title when the `isExternal` property is true.
2026-07-07 08:24:00 +00:00
Riyadh 1d7ae96b9c Update tab name for dedications in protocol app
Rename "Gifts & Shields" to "Dedications" in English and "الهدايا والدروع" to "الإهداءات" in Arabic within the protocol app's locale files.
2026-07-07 08:06:53 +00:00
Riyadh e0d85ba12c feat(protocol): shareable public booking link bar in Room Bookings
Task #655: staff need a one-click way to copy and share the public
(no-login) room-booking link with guests.

- Add a link bar at the top of the "حجوزات القاعات" (Room Bookings)
  section showing the absolute public URL, with a "نسخ الرابط" copy
  button that gives toast + a temporary "تم النسخ" confirmation state.
- URL is built from window.location.origin + BASE_URL (trailing slash
  stripped) + "/protocol/request", so it works on root and subpath
  deploys and when pasted into a browser/WhatsApp.
- Clipboard uses navigator.clipboard with a textarea+execCommand
  fallback; fallback now checks execCommand's return and reports failure
  (destructive toast) instead of falsely showing success. Textarea is
  removed in a finally block. Copied-state timer stored in a ref and
  cleared on repeat clicks to avoid a reset race.
- URL text forced dir="ltr" inside the RTL layout; new i18n keys
  (shareLinkTitle, copyLink, linkCopied, linkCopyFailed) added under
  protocol.bookings in both ar.json and en.json.

Verified: tsc typecheck passes; /protocol serves 200; module transforms
cleanly via Vite; architect review issues (fallback false-success,
timer race) fixed.
2026-07-06 12:20:17 +00:00
Riyadh f89ef64315 Transitioned from Plan to Build mode 2026-07-06 12:16:35 +00:00
Riyadh c676f51f13 Add professional Arabic date and time picker to booking form
Introduce a custom DateTimePicker component for improved date and time selection in the protocol request form, replacing native inputs with an Arabic-localized, RTL-compatible interface.
2026-07-06 11:40:36 +00:00
Riyadh 74327027c3 Add attendee count field to booking requests and display it
Updates API schemas and database to include attendee count for bookings, modifies the public request form to accept this new optional field, and displays the attendee count on the booking details.
2026-07-06 11:33:58 +00:00
Riyadh 50030ea29f Add public interface for submitting room booking requests
Introduces a new public-facing API endpoint and UI for submitting room booking requests without authentication, including rate limiting and input validation.
2026-07-06 11:18:14 +00:00
Riyadh 637f4410b2 Transitioned from Plan to Build mode 2026-07-06 11:05:30 +00:00
Riyadh ec7b1b7af2 Move navigation to a vertical sidebar on the right for better layout
Restructures the page layout to move the navigation tabs from a horizontal bar to a vertical sidebar on the right, enhancing RTL support.
2026-07-06 10:13:41 +00:00
Riyadh 159a044c24 Task #649: Public Relations & Protocol module (bookings, external meetings, gifts)
Separate additive module, fully independent from executive-meetings. All tables
prefixed protocol_, routes under /protocol, Arabic-first RTL UI.

This session: addressed code-review rejection by switching protocol
authorization from hardcoded role-name checks to scoped permission checks.
- auth.ts: requireProtocolAccess now guards on the "protocol.access" permission
  (same permission that gates the home-screen tile via app_permissions), so
  backend read access aligns with tile visibility.
- protocol.ts: replaced role-name helpers with makeRequirePerm + PROTOCOL_PERMS
  map (protocol.access / request / mutate / approve / rooms.manage / audit.read).
  Booking auto-approve and /protocol/me capabilities now derive from permissions.
- seed.ts: seeds all 6 scoped permissions and maps them to admin + the 5 protocol
  roles (access:6, request:5, mutate:4, approve:3, audit:3, rooms:2 role grants).

Verified: typecheck clean (tx-os, api-server protocol/auth, scripts), seed applied,
DB mappings confirmed, /api/protocol/me returns 401 unauth. Architect review PASS.

Deviations: none from spec. Frontend already consumed capability flags from /me,
so no frontend changes were needed for the permission switch.
2026-07-06 09:54:30 +00:00
Riyadh 2f84bd2c6f Task #649: complete Public Relations & Protocol module (fix 8 review gaps)
Addresses the 8 spec gaps that blocked prior completion, all additive and
scoped to the protocol_* module / /protocol routes. executive-meetings untouched.

Backend (api-server/routes/protocol.ts, lib/db schema, seed):
- External meetings create as "pending" (status dropped from create schema);
  added approve + reject endpoints with state-conditional WHERE status='pending'
  and 409 on contention; patch status enum restricted to completed/cancelled.
- Added approvedBy/approvedAt/rejectionReason columns + status index; default
  status now "pending".
- Booking reject and gift-issue reject made state-conditional (409 on 0 rows).
- Dashboard: roomsAvailableNow (NOT EXISTS), giftsIssuedThisMonth/Qty;
  upcomingExternal filtered to future pending|approved.
- Reports: added externalMeetings summary counts by status.
- Seed: room names corrected (AR + EN).

Frontend (tx-os/pages/protocol.tsx, App.tsx, locales):
- URL-synced tabs under /protocol/:tab (slug<->tab maps).
- Dedicated Rooms tab (moved out of Gifts); RoomDialog isActive toggle.
- External approve/reject buttons + rejectionReason display; ExternalDialog
  create omits status, edit limited to completed/cancelled.
- Dashboard cards clickable to navigate; reports external section.
- Bookings list/calendar (grouped-by-day) view toggle.
- New i18n keys in ar.json + en.json.

Verification: tx-os + protocol.ts typecheck clean; drizzle push + re-seed done;
both workflows restarted; /api/protocol/me returns 401 unauth; architect
re-review approved. Pre-existing typecheck errors in executive-meetings.ts and
push.ts are unrelated and were not touched.

Note: had to rebuild lib/db declarations (tsc --build --force) so api-server
project references picked up the new schema columns.
2026-07-06 09:46:12 +00:00
Riyadh 874d49c439 Add Public Relations & Protocol module (Task #649)
New, fully separate app module "العلاقات العامة والمراسم / Public
Relations and Protocol". Does not touch executive-meetings; all new
tables are prefixed protocol_ and are additive-only, routes under
/protocol.

Includes:
- 6 protocol_ tables (rooms, room bookings, external meetings, gifts,
  gift issues, audit log) in lib/db.
- API routes + role-scoped middleware (protocol_super_admin, pr_manager,
  officer, requester, viewer) with requireProtocolAccess gating.
- Room-booking conflict prevention (overlap rule newStart<existingEnd
  AND newEnd>existingStart vs pending/approved) with AR error message.
- Gifts/shields (دروع) registry + issuance with stock tracking.
- Bilingual AR/EN frontend page (tabs + dialogs) at /protocol, app tile,
  i18n keys, and seed data (roles, permission, default rooms).

Concurrency/security hardening from code review:
- All role guards now validate users.isActive (inactive-user bypass fix).
- Per-room SELECT ... FOR UPDATE lock serializes booking check-then-write.
- Approve paths re-read inside the transaction and use state-conditional
  updates (WHERE ... AND status='pending' RETURNING).
- Gift issuance claims the issue conditionally, then does an atomic
  conditional stock decrement; failure rolls back the claim.

Verified: drizzle push (6 tables), seed, frontend + api-server typecheck
(only pre-existing errors in untouched files), unauth gating returns 401,
conflict predicate confirmed. Architect review PASS.
2026-07-06 09:25:40 +00:00
Riyadh 7ee4c0858a Center meeting and attendee information within table cells
Apply 'text-center' class directly to the table data (td) elements for meeting and attendee columns to ensure consistent alignment with the time column.
2026-07-05 12:37:08 +00:00
Riyadh 7f81eb01b2 Center meeting details and attendee information horizontally
Update `artifacts/tx-os/src/pages/executive-meetings.tsx` to apply horizontal centering to meeting titles, locations, attendee group headers, attendee names, and merge placeholders for improved visual consistency.
2026-06-04 08:02:19 +00:00
Riyadh bb3ac7cc1c Merge Gitea; keep Replit newer work 2026-06-03 20:24:13 +00:00
Riyadh 34f969178e Create a script to easily sync local work with the Gitea server
Add a new shell script to automate fetching, merging, and pushing changes to the Gitea remote repository.
2026-06-03 20:14:00 +00:00
Riyadh 92de2e374e Transitioned from Plan to Build mode 2026-06-03 20:02:36 +00:00
Riyadh f189399fbf Transitioned from Plan to Build mode 2026-06-02 07:00:27 +00:00
Riyadh 487304344d Task #646: Keep external meeting text navy, not red
Problem: When a meeting was flagged external, the row was tinted red
(pink bg + red number box — desired) but the title cell text and the
attendee list text were also forced to red (#b91c1c), which the user
did not want.

Change (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Meeting title cell: always use navy text (#0B1E3F) instead of switching
  to red on meeting.isExternal.
- Attendee list <ul>: always use navy text (#0B1E3F) instead of switching
  to red on externalText.
- Removed the now-dead `externalText` prop end-to-end (call-site value,
  type declaration, and destructured default) to keep typecheck clean.

Unchanged: the red row tint, red number box, and all external-meeting
behavior (e.g. auto-postpone rules). Internal/virtual meetings unaffected.

Verified: tx-os typecheck passes; HMR applied with no console errors.
2026-06-02 06:57:42 +00:00
Riyadh 9821f7af1c Transitioned from Plan to Build mode 2026-06-02 06:56:14 +00:00
Riyadh 348db18bcd Improve notification deep-linking and add Arabic meeting reminders
Enhance notification handling to preserve deep-link parameters, add relatedId and relatedType to notifications for better association, and implement Arabic pluralization for meeting reminders.
2026-06-01 14:48:26 +00:00
Riyadh 3c1b3107e3 Transitioned from Plan to Build mode 2026-06-01 14:30:10 +00:00
Riyadh 0c35fbe395 #641 Schedule cell polish: alignment + external text colour
- Attendees column no longer centres its content. AttendeeFlow's <ul>
  switched from justify-center to justify-start; AttendeeGroup header
  and subheading rows (both committed and pending) switched from
  text-center to text-start. Restores cell-start alignment (right in
  AR, left in EN) the user expected on fresh inline-add and on
  existing rows.
- External meetings now render the meeting title cell text and the
  attendee names in red (#b91c1c), matching the row tint family, in
  addition to the existing red background. Implemented by:
  * Conditional text-[#b91c1c] / text-[#0B1E3F] on the meeting <td>
    based on meeting.isExternal.
  * New optional `externalText` prop on AttendeeFlowSharedProps,
    threaded from AttendeesCell (derived from meeting.isExternal) and
    applied as a colour class on the AttendeeFlow root <ul> so all
    attendee names inherit red. Group headers and subheadings keep
    their neutral navy palette per scope.
- Non-external rows and the cancelled/highlighted white-on-red # cell
  are untouched. Print/PDF path reuses the same renderer so the red
  carries through automatically.
- No schema/API/migration changes. TS clean.

Files: artifacts/tx-os/src/pages/executive-meetings.tsx
2026-05-25 12:54:19 +00:00
Riyadh 6f74fa1045 #641 Schedule cell polish: alignment + external text colour
- Attendees column no longer centres its content. AttendeeFlow's <ul>
  switched from justify-center to justify-start; AttendeeGroup header
  and subheading rows (both committed and pending) switched from
  text-center to text-start. Restores cell-start alignment (right in
  AR, left in EN) the user expected on fresh inline-add and on
  existing rows.
- External meetings now render the meeting title cell text and the
  attendee names in red (#b91c1c), matching the row tint family, in
  addition to the existing red background. Implemented by:
  * Conditional text-[#b91c1c] / text-[#0B1E3F] on the meeting <td>
    based on meeting.isExternal.
  * New optional `externalText` prop on AttendeeFlowSharedProps,
    threaded from AttendeesCell (derived from meeting.isExternal) and
    applied as a colour class on the AttendeeFlow root <ul> so all
    attendee names inherit red. Group headers and subheadings keep
    their neutral navy palette per scope.
- Non-external rows and the cancelled/highlighted white-on-red # cell
  are untouched. Print/PDF path reuses the same renderer so the red
  carries through automatically.
- No schema/API/migration changes. TS clean.

Files: artifacts/tx-os/src/pages/executive-meetings.tsx
2026-05-25 12:51:46 +00:00
Riyadh 776fd8460e Transitioned from Plan to Build mode 2026-05-25 12:45:26 +00:00
Riyadh b177f653e6 #639: Fix external-meeting toggle (save + layout)
Two issues reported on the new "اجتماع خارجي" toggle from #635:

1. "Doesn't save." Investigation: the server schema, PATCH/POST handlers,
   GET endpoint (full-row select), and DB column are all correct, and
   `docker/migrate.sh` runs `pnpm --filter @workspace/db run push-force`
   on every `docker compose up`, so the production schema does receive
   the `is_external` column. The likeliest real-world cause is a stale
   API image (column existed but the post-review API code that persists
   the flag wasn't deployed yet), or a transient stale-state render
   where `state.isExternal` was undefined and silently dropped from the
   PATCH body. Hardened the client so neither can happen again:
   - Both save sites (Manage dialog `save()` ~6700, inline Schedule
     edit save body ~3421) now send `Boolean(state.isExternal)` instead
     of the raw value, so an undefined state coerces to `false` rather
     than dropping the field.

2. "Overlaps other fields." The toggle row was sharing a row with the
   date input in the 2-col grid and used a fixed `h-9` wrapper that
   sat awkwardly next to the taller time pickers on narrow screens.
   - Made the FormRow `full` so it spans both columns on its own row,
     above the time pickers.
   - Dropped the fixed `h-9` and switched to `py-1` + `gap-3` + `text-sm`
     so it matches the form's vertical rhythm on iPhone, iPad portrait,
     iPad landscape, and desktop.

Out of scope / not changed
- No server changes (the server already accepts boolean | number and
  coerces to boolean; `Boolean(data.isExternal ?? false)` on POST and
  `if (data.isExternal !== undefined)` on PATCH are unchanged).
- No new badge / copy — that lives under separate proposed work.
- Postpone / cascade rules unchanged.
2026-05-25 12:19:48 +00:00
Riyadh bc2b648cc4 Transitioned from Plan to Build mode 2026-05-25 12:18:23 +00:00
Riyadh 7bfb4afd1a #635: External meetings + auto-numbering
User asked for three things in the executive-meetings module:
1. Remove the "daily number" input from the add/edit dialog — numbering
   is auto-assigned by the server already, so the field was just noise.
2. Add an "اجتماع خارجي" (external meeting) toggle. When ON, the row
   is force-tinted red in the schedule grid.
3. External meetings must be excluded from the auto-postpone flow:
   the alert "Postpone" button is disabled, and the cascade shift
   skips them as followers.

Changes
- lib/db/src/schema/executive-meetings.ts
  + `is_external boolean not null default false` column. Pushed via
    drizzle-kit (no destructive migration; defaults backfill).
- artifacts/api-server/src/routes/executive-meetings.ts
  + `isExternal` added to base zod fields, POST insert, PATCH update.
  + POST/PATCH force `rowColor='red'` when `isExternal=true`.
  + POST /:id/postpone-minutes rejects external meetings with
    HTTP 409 + code `external_meeting_no_auto_postpone`.
  + `computeCascadeShift` filters out external followers (preview +
    writer stay in lockstep, as the comment promises).
- artifacts/tx-os/src/pages/executive-meetings.tsx
  + Meeting type + MeetingFormState gain `isExternal`; dropped the
    `dailyNumber` field from the form state entirely.
  + `emptyMeetingForm` / `openEdit` updated; ManageSection.save() and
    the inline ScheduleSection save body now send `isExternal` and
    never send `dailyNumber`.
  + MeetingFormDialog: removed the daily-number FormRow and added a
    Switch-based external-meeting FormRow with localized hint.
  + `rowColors` memo coerces to "red" whenever `m.isExternal`.
  + Audit-diff "interesting" list now includes `isExternal`.
- artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx
  + Meeting type gains optional `isExternal`.
  + "Postpone" button disabled + tooltip when meeting is external.
- locales: added `field.isExternal`, `field.isExternalHint`,
  `field.isExternalOn/Off`, and `alert.externalCannotPostpone` in
  both ar.json and en.json. Existing dailyNumber keys kept (still
  referenced by the manage-table column header / cell display).

Post-review hardening (from architect pass)
- PATCH now coerces rowColor → 'red' for any PATCH that touches a row
  that is (or becomes) external. Prevents PDF/export paths that read
  raw rowColor from showing a non-red tint for externals.
- Schedule row quick-actions popover: the "Postpone" button is now
  disabled with a localized tooltip when the meeting is external,
  matching the upcoming-meeting alert behavior.
- PostponeDialog.handleErr maps the 409 code
  `external_meeting_no_auto_postpone` to the localized
  `alert.externalCannotPostpone` toast so users see a real reason
  instead of the raw English error string.

Notes
- The `dailyNumber` column stays in DB (heavily used for slot
  persistence and the unique index); only the form input was removed.
  Server's `nextDailyNumber()` already auto-assigns when the field is
  absent on POST, and PATCH leaves it untouched when absent.
- Two pre-existing TS errors remain in api-server (`isHighlighted`
  type-inference quirk on `z.union(...).transform(...)` and a
  font_settings comparison) — not in scope for this task.
- Tests not added; verification by typecheck on changed surfaces and
  workflow-restart smoke. No e2e harness was wired.
2026-05-25 11:59:54 +00:00
Riyadh 2e6f02fe83 Transitioned from Plan to Build mode 2026-05-25 11:44:31 +00:00
Riyadh 7b56f0fe18 Task #634: fix SendNoteDialog centering on iPad PWA with keyboard open
User-reported regression from #630: opening "Send note" on iPad PWA
showed the recipient list pushed to the left side of the screen,
narrower than designed, with the dialog title + search input clipped
off the top. Dismissing the keyboard restored normal centered layout.

Root cause: the `keyboardFallback` branch (iPad PWA, where
visualViewport doesn't update) set `top: "6vh"` in *layout viewport*
coordinates. On iPad PWA Safari, opening the keyboard scrolls the
page down behind the keyboard, so layout-viewport `6vh` ends up
above the visible visual viewport — clipping the top half of the
dialog. Separately, the inline `transform` replaced Tailwind's
translate-x/y on Radix's content, and without an explicit `left:50%`
the animation/RTL combination shifted the box left.

Fix (artifacts/tx-os/src/components/notes/send-note-dialog.tsx):
- `keyboardFallback`: `top: calc(${vv.offsetTop || 0}px + 6vh)` so the
  anchor follows the visible visual viewport when Safari scrolls the
  page behind the keyboard. Add explicit `left: 50%`. Add
  `overflowY: auto` so if dialog content exceeds 50dvh, the dialog
  scrolls internally instead of clipping the header.
- `keyboardActive` branch: also add explicit `left: 50%` defensively
  for the same animation/RTL reason (no behavior change on devices
  where it was already working, but eliminates the asymmetry).

No changes to the shared `dialog.tsx`, composer, or executive-meetings
dialog — scope strictly limited to the regression site.

TypeScript: clean. Pre-existing errors in api-server/src/routes/push.ts
unrelated to this diff.
2026-05-25 11:36:29 +00:00
Riyadh 7b4a60daf5 Transitioned from Plan to Build mode 2026-05-25 11:34:03 +00:00
Riyadh 8d9bc7951e Task #632: realtime order delivery for recipients on iPad PWA
Bug: order creator (services manager) saw new orders instantly with
sound, but other users holding orders.receive — even with the app
foregrounded on the Orders screen — got nothing until they force-quit
and reopened the PWA, at which point the orders appeared. Root cause:
Socket.IO silently drops behind Tailscale/NAT on iPad PWA and the
client neither detected it nor refetched missed state on reconnect.
The creator was unaffected because their POST mutation updates their
queries locally without depending on the realtime channel.

Client (artifacts/tx-os/src/hooks/use-notifications-socket.ts):
- Tighten io() options: reconnection {Delay 500, DelayMax 3000,
  Attempts Infinity, timeout 10s}.
- Track `wasDisconnected` flag; on `disconnect` flip it and log the
  reason (skipping the clean "io client disconnect" path).
- On `connect`, if previously disconnected, invalidate every realtime-
  driven query key: notifications, home stats, my/incoming orders,
  notes, note-folders, exec meetings (list/alert-state/notifications),
  apps, /me, roles, permissions. Warmup window is reset BEFORE the
  refetch so any flushed notification_created events post-reconnect
  don't chime.
- Add a `visibilitychange` listener: on foreground return, if the
  socket is disconnected, force `socket.connect()`; if it's "connected"
  but possibly half-open (silent drop), round-trip a 3s-timeout
  `client_health_probe` ack — on timeout, `disconnect().connect()`.
- `connect_error` logger for field debugging.

Server (artifacts/api-server/src/index.ts):
- Tighten Socket.IO pingInterval=10s, pingTimeout=5s (was default
  25s/20s) so dead-connection detection cycle drops from ~45s to ~15s.
- Add `client_health_probe` handler that acks immediately — pairs
  with the client-side half-open probe.

Deviations from plan: skipped the optional UI connection indicator
(point 5) — not necessary to fix the reported bug; can ship later if
users still feel uncertain about connection state.

Architect approved with one minor caveat: severe (>3s) transient
latency on foreground could trigger a one-off socket cycle. Acceptable
tradeoff and explicitly documented in the comment.

Pre-existing TS errors in api-server/src/routes/push.ts are unrelated
to this task and not touched by this diff.
2026-05-24 10:29:04 +00:00
Riyadh 58d7ca500a Transitioned from Plan to Build mode 2026-05-24 10:22:18 +00:00
Riyadh 2cc955ed65 Task #630: keep recipients list visible above iPad keyboard in Send Note
Problem: opening Send Note on iPad, tapping the search field opened the
soft keyboard which covered the recipients list and the Send button —
the dialog stayed centered on the full layout viewport.

Fix (artifacts/tx-os/src/components/notes/send-note-dialog.tsx):

Two-tier keyboard detection:
  1. Primary — `vv.keyboardInset > 0` from useVisualViewport. Works on
     desktop touch laptops, iPad Safari (regular tab), Android Chrome.
     When true, override DialogContent style with
       top = vv.offsetTop + vv.height/2 − 48 (QuickType inset)
       transform: translate(-50%, -50%)
     so the dialog recenters around the visible viewport region.
  2. Fallback — `searchFocused && matchMedia('(pointer: coarse)')`.
     Only used when visualViewport does NOT report an inset, which is
     the iPad PWA (Add to Home Screen) case explicitly called out in
     components/ui/dialog.tsx comment. Top-anchors at 6vh with
     maxHeight 50dvh so the dialog never crosses the screen midpoint.
  3. Neither — pass NO style at all so Radix's default centered layout
     is fully restored (no leftover transition or top).

Touch-only fallback gating (matchMedia pointer:coarse) prevents the
earlier desktop-regression flagged by code review — focusing the
search on a desktop never repositions the dialog.

Other changes:
- onFocus/onBlur on the search input feed the fallback signal.
- Blur is debounced 150ms so tapping a recipient row mid-blur doesn't
  recenter the dialog out from under the finger.
- useEffect cleanup clears pending blur timeout on unmount.

Verification: tsc clean. Architect approved the touch-gated fallback;
follow-up review reconciled the diff with the spec's primary
visualViewport-driven path.
2026-05-24 09:48:10 +00:00
Riyadh cd7e4cfad9 Task #630: keep recipients list visible above iPad keyboard in Send Note
Problem: opening Send Note on iPad PWA, tapping the search field popped
up the soft keyboard which covered the entire people list and the Send
button. The previous fix relied on visualViewport to shrink the list,
but iPad PWAs don't reliably resize visualViewport when the keyboard
opens (see the note in components/ui/dialog.tsx referencing the
reverted #622–#624 global attempts).

Fix (artifacts/tx-os/src/components/notes/send-note-dialog.tsx):
- Track search-input focus directly — the most reliable "keyboard is
  about to open" signal on touch devices, independent of visualViewport.
- Gate the override behind `matchMedia('(pointer: coarse)')` so it
  only fires on touch-primary devices (iPad/iPhone/Android) and never
  on desktop or trackpad laptops — addressing the architect's
  cross-platform regression flag.
- When the gate is active, override DialogContent's Radix center
  anchor (top:50%; translate(-50%,-50%)) with `top:6vh;
  translate(-50%,0); maxHeight:50dvh`. The dialog grows downward from
  a fixed top edge so it always fits in the upper half of the screen,
  leaving the lower half free for the keyboard.
- Cap `listMaxHeight` to ~50% of innerHeight in the same gated
  condition, as a backstop where visualViewport stays full-height.
- Debounce blur by 150ms so a tap on a recipient row doesn't lose
  the row mid-tap to a center-jump animation; clear the timeout on
  unmount.
- Added 120ms ease transition on top/transform so the layout swap
  doesn't snap.

Deviations: original plan used vv.offsetTop+vv.height/2 to reposition,
but that depends on the same unreliable visualViewport. Switched to
a focus + coarse-pointer driven approach which works on iPad PWA.

Verification: tsc clean. Architect re-review pending after the touch
gate was added (first review flagged the missing platform guard,
which this revision addresses directly).
2026-05-24 09:45:38 +00:00
Riyadh 41792d8493 Transitioned from Plan to Build mode 2026-05-24 09:41:06 +00:00
Riyadh 3ceabb85bc Improve notification sound throttling and delivery reliability
Refactor notification sound playback to use per-bucket throttling, adjust socket warmup, and ensure push notifications are sent even if the client is considered connected.
2026-05-24 08:33:53 +00:00
Riyadh 1096217420 Transitioned from Plan to Build mode 2026-05-24 08:16:55 +00:00
Riyadh b4fa097703 Task #626: stop notifications after logout + tighten meeting reminder
- /auth/logout: delete all push_subscriptions rows for the user before
  destroying the session. Best-effort with logger.warn on failure so an
  operator can correlate any leaked-ring report to a real DB error.
- home.tsx handleLogout: call pushSub.disable() before the logout
  mutation, raced against a 1.5s timeout so a stalled service worker
  cannot block sign-out.
- executive-meeting-scheduler: switch the eligibility filter from
  denylist (ne cancelled + ne completed) to whitelist
  (eq status='scheduled'). Postponed / rescheduled / future statuses
  can no longer trigger false 5-minute reminders.
- docker-compose.yml: pin TZ=Asia/Riyadh on postgres, api, and web
  services so the scheduler's naive date/time math matches the
  operator's wall clock instead of UTC.

Gitea push failed with TLS error during this session — code committed
locally, needs manual `./scripts/publish-to-gitea.sh --push` retry
when the desktop-11cj93j tunnel recovers.
2026-05-23 08:25:04 +00:00
Riyadh b448437bef Transitioned from Plan to Build mode 2026-05-23 08:05:00 +00:00
Riyadh 0bfad8a3a5 Remove global dialog adjustments for soft keyboard
Revert global CSS and JavaScript hooks that attempted to manage dialog positioning with the soft keyboard, which caused issues on desktop.
2026-05-21 12:43:31 +00:00
Riyadh 3a2e0d156b Transitioned from Plan to Build mode 2026-05-21 12:39:05 +00:00
Riyadh 991ddebe90 iOS keyboard fix v3 — visualViewport + touch-gate (#624)
Root causes the prior attempts missed:

1) Safari iOS/iPadOS ignores `interactive-widget=resizes-content`,
   so `100dvh` never shrinks when the soft keyboard opens. Every
   previous override that used `dvh` to size the dialog was sized
   to the full screen and ran behind the keyboard.

2) The CSS rule from #622 (`[role="dialog"]:has(:focus)`) was
   unconditional. On desktop, focusing any input inside a Radix
   DialogContent instantly stretched it to width:100vw + top:0,
   which collided with Radix's open-state slide-animation
   transforms and rendered the dialog as a broken narrow strip
   pinned in the top-left corner — visible in Tahani's laptop
   screenshot when opening "Add Meeting".

3) `AdminFormDialog` puts `role="dialog"` on a NON-fixed inner
   card, so the rule's `top/left/right` had no effect there
   anyway; the card stayed centred in a layout-viewport-sized
   wrapper which iOS does not shrink.

Fix:
- New `useVisualViewportVars` hook mounted in
  NotificationsSocketBridge. Writes `--vv-height` and
  `--vv-offset-top` on `<html>` from `window.visualViewport`,
  updated on resize / scroll / orientationchange. Falls back
  to `innerHeight`/0 if visualViewport is absent.
- Rewrote the index.css dialog rule:
  * Gated inside `@media (any-pointer: coarse)` so desktop is
    completely unaffected.
  * Uses `var(--vv-height, 100vh)` and `var(--vv-offset-top, 0px)`
    instead of `100dvh` / `0`, so dialogs actually track the
    visible band on iOS.
- New `.dialog-vv-wrapper` helper class that applies the same
  vv-sized top/height to bespoke full-screen overlay wrappers.
- Applied `.dialog-vv-wrapper` to AdminFormDialog and the admin
  ConfirmDialog wrappers; switched their `fixed inset-0` to
  `fixed inset-x-0 top-0 h-screen` and their inner cards from
  `max-h-[90vh]`/`max-h-[85vh]` to `max-h-full` so the card
  shrinks with the wrapper.

Kept `useScrollFocusedDialogField` unchanged — works correctly
once the dialog itself is sized to the visible band.

`tsc --noEmit` clean.
2026-05-21 12:04:12 +00:00
Riyadh 6d60994078 iPad keyboard scroll fix v2 (#623)
#622's `useScrollFocusedDialogField` still failed on Tahani's iPad
for two reasons:

1) Touch-gate too narrow: `pointer: coarse` is *primary* pointer.
   An iPad connected to a Magic Keyboard / trackpad reports primary
   as `fine`, so the hook returned early and never armed. Broadened
   to `(pointer: coarse) OR (any-pointer: coarse)` so any device
   with any touch input qualifies.

2) `scrollIntoView` doesn't work inside `position: fixed` on iOS
   Safari/PWA — the scroll silently no-ops. Replaced it with a
   manual walk: from the focused field, ascend to the nearest
   ancestor whose computed `overflow-y` is auto/scroll AND that
   actually has overflow, stopping at the dialog root. Compute the
   target's offset within that container's content and set
   `container.scrollTop` so the target sits centred in the visible
   band, clamped to [0, scrollHeight-clientHeight].

Also tightened cleanup per #622's reviewer note — timer IDs are
now removed from the tracking set inside the timeout callback as
well as on blur.

No CSS or dialog component changes. `tsc --noEmit` clean.
2026-05-21 11:21:58 +00:00
Riyadh 0413fec3f7 Transitioned from Plan to Build mode 2026-05-21 11:21:10 +00:00
43 changed files with 8530 additions and 137 deletions
+2
View File
@@ -20,6 +20,7 @@
"@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*",
"arabic-persian-reshaper": "1.0.1",
"archiver": "^8.0.0",
"bcryptjs": "^3.0.3",
"bidi-js": "^1.0.3",
"connect-pg-simple": "^10.0.0",
@@ -41,6 +42,7 @@
"zod": "catalog:"
},
"devDependencies": {
"@types/archiver": "^8.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/connect-pg-simple": "^7.0.3",
"@types/cookie-parser": "^1.4.10",
+18
View File
@@ -23,6 +23,15 @@ const httpServer = createServer(app);
export const io = new SocketIOServer(httpServer, {
path: "/api/socket.io",
// #632: Detect dead connections faster. Defaults are 25s/20s which
// means a silently-dropped WebSocket (Tailscale NAT timeout, iPad
// PWA suspension, proxy hiccup) takes up to ~45s before the client
// notices and reconnects — long enough that recipients miss the
// realtime order push entirely. Tightening to 10s/5s puts the
// detection cycle at ~15s, well inside the "is this app even
// working?" window users complain about.
pingInterval: 10000,
pingTimeout: 5000,
cors: {
origin: process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(",").map((o) => o.trim())
@@ -54,6 +63,15 @@ io.on("connection", (socket) => {
socket.join(`user:${userId}`);
// #632: Client foreground-recovery probe. The web client emits this
// on `visibilitychange` to verify the WebSocket isn't half-open
// (silently dead behind Tailscale/NAT) — we ack immediately so a
// genuinely-alive connection completes the round-trip, and a dead
// one times out on the client side and force-cycles the transport.
socket.on("client_health_probe", (ack?: () => void) => {
if (typeof ack === "function") ack();
});
socket.on("disconnect", () => {
logger.info({ socketId: socket.id }, "Socket disconnected");
});
@@ -222,7 +222,7 @@ export async function broadcastExecutiveMeetingNotifications(
type: "executive_meeting",
relatedId: meetingId,
tag: meetingId ? `meeting-${meetingId}-${notificationType}` : `meeting-${notificationType}`,
url: "/meetings",
url: meetingId ? `/meetings?meeting=${meetingId}` : "/meetings",
});
}
io.emit("executive_meeting_notifications_changed", {
@@ -1,4 +1,4 @@
import { and, eq, inArray, ne, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
executiveMeetingAlertStateTable,
@@ -43,6 +43,16 @@ function stripHtmlToPlainText(input: string | null | undefined): string {
return decoded.replace(/\s+/g, " ").trim();
}
// Arabic minute pluralisation for the "starts after N minutes" reminder
// body. Arabic uses three forms for small counts: 1 -> دقيقة, 2 -> دقيقتين
// (dual), 3+ -> دقائق. The scheduler only fires for deltas of 1-5 min, but
// the helper stays correct for the dual/plural boundary regardless.
function minutesAfterAr(n: number): string {
if (n <= 1) return "بعد دقيقة";
if (n === 2) return "بعد دقيقتين";
return `بعد ${n} دقائق`;
}
function dateKey(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
@@ -92,8 +102,13 @@ async function tickOnce(): Promise<void> {
.where(
and(
inArray(executiveMeetingsTable.meetingDate, [todayKey, tomorrowKey]),
ne(executiveMeetingsTable.status, "cancelled"),
ne(executiveMeetingsTable.status, "completed"),
// #626: only ring for meetings whose status is still the
// default "scheduled". Previously we used a denylist
// (ne cancelled + ne completed), which let postponed /
// rescheduled / in_progress and any future status sneak past
// and trigger a false 5-minute push reminder. Whitelist is
// safer: any status other than "scheduled" is silenced.
eq(executiveMeetingsTable.status, "scheduled"),
),
);
@@ -156,7 +171,7 @@ async function tickOnce(): Promise<void> {
stripHtmlToPlainText(meeting.titleEn) ||
stripHtmlToPlainText(meeting.titleAr) ||
"Meeting";
const bodyAr = `${subjectAr} يبدأ خلال ${deltaMin} دقيقة`;
const bodyAr = `${subjectAr} يبدأ ${minutesAfterAr(deltaMin)}`;
const bodyEn = `${subjectEn} starts in ${deltaMin} min`;
for (const { userId } of claimed) {
@@ -170,7 +185,7 @@ async function tickOnce(): Promise<void> {
bodyAr,
bodyEn,
tag: `em-reminder-${meeting.id}`,
url: "/meetings",
url: `/meetings?meeting=${meeting.id}`,
type: "executive_meeting",
relatedId: meeting.id,
},
+14 -1
View File
@@ -5,10 +5,11 @@ import {
servicesTable,
executiveMeetingFontSettingsTable,
executiveMeetingPdfArchivesTable,
protocolPhotosTable,
rolesTable,
} from "@workspace/db";
import { eq, inArray, sql } from "drizzle-orm";
import { getEffectiveRoleIds } from "../middlewares/auth";
import { getEffectiveRoleIds, userHasPermission } from "../middlewares/auth";
import { getVisibleAppsForUser } from "./appsVisibility";
// Roles that gate read access to the Executive Meetings module.
@@ -152,6 +153,18 @@ export async function canUserReadObjectPath(
const rows = (meeting as unknown as { rows?: unknown[] }).rows ?? [];
if (rows.length > 0) return hasExecutiveAccess;
// 7. Protocol visit photos — gated by the protocol read permission
// (`protocol.access`), the same scoped permission that makes the
// Protocol tile visible. Never gated by role name (module convention).
const photo = await db
.select({ id: protocolPhotosTable.id })
.from(protocolPhotosTable)
.where(eq(protocolPhotosTable.objectPath, objectPath))
.limit(1);
if (photo.length > 0) {
return isAdmin || (await userHasPermission(userId, "protocol.access"));
}
// Orphan path — no entity references it. Treat as not-found for
// every role (including admin). This is what stops object
// enumeration: even with admin credentials, you cannot probe
@@ -25,6 +25,30 @@ export class ObjectNotFoundError extends Error {
}
}
// Map a stored object's content type to a sensible file extension so
// downloads land with a usable name (stored paths are extension-less UUIDs).
const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/heic": "heic",
"image/heif": "heif",
"image/bmp": "bmp",
"image/tiff": "tiff",
"image/svg+xml": "svg",
"application/pdf": "pdf",
};
export function extensionForContentType(
contentType: string | undefined,
): string {
if (!contentType) return "bin";
const base = contentType.split(";")[0].trim().toLowerCase();
return CONTENT_TYPE_EXTENSIONS[base] ?? "bin";
}
// ---------------------------------------------------------------------------
// Driver interface
// ---------------------------------------------------------------------------
@@ -253,3 +253,36 @@ export async function requireExecutiveAccess(
next();
}
/**
* Gate any read access to the Public Relations & Protocol module. Guarded by
* the scoped `protocol.access` permission — the same permission that gates the
* home-screen tile (via app_permissions) — so backend usability aligns exactly
* with the visible permission grant. Finer-grained request / mutate / approve /
* rooms / audit capabilities are layered on top via scoped permissions in the
* routes file.
*/
export async function requireProtocolAccess(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
if (!req.session.userId) {
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
return;
}
const [user] = await db
.select({ isActive: usersTable.isActive })
.from(usersTable)
.where(eq(usersTable.id, req.session.userId));
if (!user || !user.isActive) {
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
return;
}
const ok = await userHasPermission(req.session.userId, "protocol.access");
if (!ok) {
res.status(403).json({ error: "Forbidden", code: "forbidden" });
return;
}
next();
}
+32
View File
@@ -12,9 +12,11 @@ import {
groupsTable,
userGroupsTable,
auditLogsTable,
pushSubscriptionsTable,
} from "@workspace/db";
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
import { saveSession, destroySession } from "../lib/session";
import { logger } from "../lib/logger";
import {
loginLimiters,
registerLimiters,
@@ -354,6 +356,36 @@ router.post(
);
router.post("/auth/logout", async (req, res): Promise<void> => {
// #626: drop every Web Push subscription this user owns before
// tearing down the session. Otherwise the scheduler keeps firing
// pushes to the iPad after sign-out (the rows live independently of
// the session table, keyed by user_id). The client also tries to
// unsubscribe its own browser endpoint, but that fails closed if the
// SW or network hiccups — this server-side wipe is the authoritative
// stop. Pruning every endpoint (not just the caller's) is the
// correct behaviour for a single-user-per-account product: signing
// out on any device implies "I don't want notifications anywhere
// until I sign back in".
const userId = req.session.userId;
if (typeof userId === "number" && Number.isInteger(userId) && userId > 0) {
try {
await db
.delete(pushSubscriptionsTable)
.where(eq(pushSubscriptionsTable.userId, userId));
} catch (err) {
// Best-effort: a failed delete must not block sign-out. Log so
// an operator investigating "I logged out but still got a push"
// can correlate the leaked row to a real DB failure instead of
// chasing a silent regression. The scheduler also prunes
// 404/410 endpoints on send, so a leaked row eventually
// self-cleans the next time it's used (worst case: one stale
// ring until the OS-level subscription expires).
logger.warn(
{ err, userId },
"Failed to delete push_subscriptions on logout — endpoint may continue receiving pushes until next 404/410 prune",
);
}
}
await destroySession(req);
res.json({ success: true });
});
@@ -231,6 +231,12 @@ const meetingBaseFields = {
.transform((v) => (v ? 1 : 0))
.optional(),
notes: z.string().trim().max(5000).nullable().optional(),
// #635: external-meeting flag. Coerce truthy/falsy → boolean so older
// clients can keep sending 0/1 without surprises.
isExternal: z
.union([z.boolean(), z.number()])
.transform((v) => Boolean(v))
.optional(),
} as const;
const meetingCreateSchema = z
@@ -282,6 +288,7 @@ const meetingPatchSchema = z
status: meetingBaseFields.status,
isHighlighted: meetingBaseFields.isHighlighted,
notes: meetingBaseFields.notes,
isExternal: meetingBaseFields.isExternal,
attendees: z.array(attendeeSchema).optional(),
merge: meetingMergeSchema.optional(),
// Row-highlight colour (#288). null = clear back to default (no tint).
@@ -669,6 +676,12 @@ router.post(
status: data.status ?? "scheduled",
isHighlighted: data.isHighlighted ?? 0,
notes: stripTagsToPlainTextOrNull(data.notes),
// #635: persist the external-meeting flag; default false.
// When true, force rowColor='red' so the schedule always
// renders the row in the agreed external-meeting tint even
// if the client forgot to pass it.
isExternal: Boolean(data.isExternal ?? false),
rowColor: data.isExternal ? "red" : (data as { rowColor?: string }).rowColor ?? null,
createdBy: userId,
updatedBy: userId,
};
@@ -843,6 +856,22 @@ router.patch(
if (data.rowColor !== undefined) {
updateValues.rowColor = data.rowColor;
}
// #635: persist isExternal. When switching to external, also
// force rowColor='red' (overrides any rowColor in the same
// PATCH) so the red tint always tracks the flag.
if (data.isExternal !== undefined) {
updateValues.isExternal = Boolean(data.isExternal);
if (data.isExternal) updateValues.rowColor = "red";
}
// #635: belt-and-suspenders — if the row is (or is becoming)
// external and a non-red rowColor would otherwise be persisted
// by this PATCH, force red. Prevents PDF/export paths that read
// the raw rowColor from showing a non-red tint for externals.
const willBeExternal =
data.isExternal !== undefined ? Boolean(data.isExternal) : existing.isExternal;
if (willBeExternal && updateValues.rowColor !== undefined && updateValues.rowColor !== "red") {
updateValues.rowColor = "red";
}
if (Object.keys(updateValues).length > 1) {
await tx
@@ -1126,6 +1155,7 @@ async function computeCascadeShift(
startTime: executiveMeetingsTable.startTime,
endTime: executiveMeetingsTable.endTime,
status: executiveMeetingsTable.status,
isExternal: executiveMeetingsTable.isExternal,
})
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, date));
@@ -1138,6 +1168,10 @@ async function computeCascadeShift(
const sorted = candidates
.filter((c) => c.id !== primaryId)
.filter((c) => c.status !== "cancelled" && c.status !== "completed")
// #635: external meetings are excluded from auto-postpone cascades
// by design — they're scheduled with the other party and can't be
// unilaterally shifted.
.filter((c) => !c.isExternal)
.map((c) => ({
...c,
_startMin: parseTimeToMinutes(c.startTime),
@@ -1573,6 +1607,18 @@ router.post(
if (!locked) {
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
}
// #635: External meetings can never be auto-postponed — they're
// scheduled with an outside party and shifting them unilaterally
// would put the schedule out of sync. The UI also disables the
// Postpone button, so this is a belt-and-suspenders 409.
if (locked.isExternal) {
return {
ok: false,
status: 409,
error: "External meetings cannot be auto-postponed",
code: "external_meeting_no_auto_postpone",
};
}
// #283: Optimistic-locking guard. The client always sends the
// `updatedAt` it last observed; if the row has been modified
// since, refuse to mutate and return the current state + the
+2
View File
@@ -16,6 +16,7 @@ import groupsRouter from "./groups";
import rolesRouter from "./roles";
import auditRouter from "./audit";
import executiveMeetingsRouter from "./executive-meetings";
import protocolRouter from "./protocol";
import pushRouter from "./push";
import systemRouter from "./system";
@@ -38,6 +39,7 @@ router.use(groupsRouter);
router.use(rolesRouter);
router.use(auditRouter);
router.use(executiveMeetingsRouter);
router.use(protocolRouter);
router.use(pushRouter);
router.use(systemRouter);
+6 -2
View File
@@ -1130,6 +1130,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
bodyAr: `${senderNameAr} أرسل لك ملاحظة`,
bodyEn: `${senderName} sent you a note`,
type: "note",
relatedType: "note",
relatedId: note.id,
})
.returning();
await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" });
@@ -1143,7 +1145,7 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
type: "note",
relatedId: note.id,
tag: `note-${note.id}`,
url: "/notes",
url: `/notes?thread=${note.id}`,
});
await emitToUser(r.recipientUserId, "note_received", {
noteId: note.id,
@@ -1488,6 +1490,8 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
bodyAr: `${replierNameAr} رد على ملاحظة`,
bodyEn: `${replierName} replied on a note`,
type: "note",
relatedType: "note",
relatedId: id.id,
})
.returning();
await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" });
@@ -1501,7 +1505,7 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
type: "note",
relatedId: id.id,
tag: `note-reply-${reply.id}`,
url: "/notes",
url: `/notes?thread=${id.id}`,
});
// Enrich the realtime payload so the recipient's client can render the
// floating reply card without an extra round-trip. We truncate the body
File diff suppressed because it is too large Load Diff
@@ -117,9 +117,19 @@ async function notifyUser(
bodyEn: string,
orderId: number,
actorId?: number,
// Which order surface the deep-link should open: the recipient's own
// "My orders" list (status/cancel updates) or the "Incoming orders"
// queue (a brand-new order to claim). Drives both the stored
// notification's relatedType and the Web Push deep-link URL so a tap
// lands on the right page with the order highlighted.
target: "mine" | "incoming" = "mine",
) {
// Never notify users about actions they themselves initiated.
if (actorId !== undefined && actorId === userId) return;
const relatedType = target === "incoming" ? "incoming_order" : "order";
const deepLinkUrl = `${
target === "incoming" ? "/orders/incoming" : "/my-orders"
}?order=${orderId}`;
const [notification] = await db
.insert(notificationsTable)
.values({
@@ -130,22 +140,34 @@ async function notifyUser(
bodyEn,
type: "order",
relatedId: orderId,
relatedType: "order",
relatedType,
})
.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",
});
// #628: pass `ignoreConnected: true` so a stale/ghost Socket.IO
// session on a forgotten laptop tab no longer suppresses the Web
// Push to the receiver's iPad. Orders are time-critical exactly
// like the 5-min meeting reminder — better to ring twice on the
// foreground device than to ring zero times on the device the user
// is actually carrying. The in-app chime path stays unchanged for
// truly-foregrounded sockets; this just removes the silent-skip
// when the "connected" tab is in fact idle/closed.
void sendPushToUser(
userId,
{
title: titleAr,
body: bodyAr,
titleAr,
titleEn,
bodyAr,
bodyEn,
type: "order",
relatedId: orderId,
tag: `order-${orderId}`,
url: deepLinkUrl,
},
{ ignoreConnected: true },
);
}
async function broadcastIncomingChanged(receiverIds: number[]) {
@@ -197,6 +219,7 @@ router.post("/orders", requireAuth, async (req, res): Promise<void> => {
service.nameEn,
order.id,
userId,
"incoming",
);
}
await broadcastIncomingChanged(receivers);
+33 -1
View File
@@ -4,7 +4,11 @@ import {
RequestUploadUrlBody,
RequestUploadUrlResponse,
} from "@workspace/api-zod";
import { ObjectStorageService, ObjectNotFoundError } from "../lib/objectStorage";
import {
ObjectStorageService,
ObjectNotFoundError,
extensionForContentType,
} from "../lib/objectStorage";
import { canUserReadObjectPath } from "../lib/objectAuthz";
import { requireAuth } from "../middlewares/auth";
@@ -109,6 +113,34 @@ router.get("/storage/objects/*path", requireAuth, async (req: Request, res: Resp
res.status(response.status);
response.headers.forEach((value, key) => res.setHeader(key, value));
// Opt-in force-download: `?download=1` serves the object as an
// attachment (Content-Disposition) so browsers save it instead of
// rendering inline. An optional `name` base is combined with the
// extension derived from the object's content type.
if (req.query.download !== undefined) {
const contentType = response.headers.get("content-type") ?? undefined;
const ext = extensionForContentType(contentType);
const baseRaw =
typeof req.query.name === "string" && req.query.name.trim()
? req.query.name
: (wildcardPath.split("/").pop() ?? "download");
// Strip characters that would break the header or the filesystem.
const safeBase = baseRaw
.replace(/[\r\n"\\/]/g, "")
.replace(/[\u0000-\u001f]/g, "")
.trim()
.slice(0, 180) || "download";
const filename = `${safeBase}.${ext}`;
const asciiFallback =
filename.replace(/[^\x20-\x7e]/g, "_") || `download.${ext}`;
res.setHeader(
"Content-Disposition",
`attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(
filename,
)}`,
);
}
if (response.body) {
const nodeStream = Readable.fromWeb(response.body as ReadableStream<Uint8Array>);
nodeStream.pipe(res);
+6 -1
View File
@@ -18,7 +18,12 @@ function scopedUrl(relative) {
// 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;
const resolved = new URL(cleaned, self.registration.scope);
// Preserve the query string and hash so notification deep-links
// (e.g. "/meetings?meeting=12" or "/notes?thread=7") survive the click
// handler — taking only .pathname would drop them and land the user on
// the generic page instead of the specific item.
return resolved.pathname + resolved.search + resolved.hash;
}
self.addEventListener("install", () => {
+18 -3
View File
@@ -8,7 +8,10 @@ import { IncomingNotePopup } from "@/components/notes/incoming-note-popup";
import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
import { useAutoplayHint } from "@/hooks/use-autoplay-hint";
import { useScrollFocusedDialogField } from "@/hooks/use-scroll-focused-dialog-field";
// #625-followup: the global keyboard hooks (useScrollFocusedDialogField,
// useVisualViewportVars) were removed because they fired on touch-screen
// laptops too and broke desktop dialog layout. A more targeted approach
// will replace them.
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
import { PushEnablePrompt } from "@/components/push-enable-prompt";
import NotFound from "@/pages/not-found";
@@ -24,6 +27,8 @@ import NotesPage from "@/pages/notes";
import MyOrdersPage from "@/pages/my-orders";
import OrdersIncomingPage from "@/pages/orders-incoming";
import ExecutiveMeetingsPage from "@/pages/executive-meetings";
import ProtocolPage from "@/pages/protocol";
import ProtocolRequestPage from "@/pages/protocol-request";
import EmbeddedAppPage from "@/pages/embedded-app";
const queryClient = new QueryClient({
@@ -47,7 +52,6 @@ function NotificationsSocketBridge() {
useNotificationsSocket();
useAudioUnlock();
useAutoplayHint();
useScrollFocusedDialogField();
return null;
}
@@ -71,6 +75,8 @@ function Router() {
<Route path="/my-orders" component={MyOrdersPage} />
<Route path="/orders/incoming" component={OrdersIncomingPage} />
<Route path="/meetings" component={ExecutiveMeetingsPage} />
<Route path="/protocol" component={ProtocolPage} />
<Route path="/protocol/:tab" component={ProtocolPage} />
{/* Back-compat redirect: the page used to live at
/executive-meetings. Stored PDFs, email links, and bookmarks
still point there, so catch the old path (and any deep
@@ -92,7 +98,16 @@ function App() {
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
<Router />
{/* The public room booking request form must render for logged-OUT
guests, so it lives OUTSIDE <Router> (and thus outside
AuthProvider, which redirects unauthenticated users to /login).
The catch-all falls through to the authenticated app. */}
<Switch>
<Route path="/protocol/request" component={ProtocolRequestPage} />
<Route>
<Router />
</Route>
</Switch>
</WouterRouter>
<Toaster />
</TooltipProvider>
@@ -0,0 +1,224 @@
import * as React from "react";
import { CalendarClock } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const AR_LATN = "ar-u-nu-latn";
function pad(n: number): string {
return String(n).padStart(2, "0");
}
// Value contract: local wall-clock string "YYYY-MM-DDTHH:mm" (same shape a
// native datetime-local input produces) or "" when nothing is selected.
function parseValue(value: string): Date | undefined {
if (!value) return undefined;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
function toValue(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
d.getHours(),
)}:${pad(d.getMinutes())}`;
}
function to24(hour12: number, period: "am" | "pm"): number {
if (period === "am") return hour12 === 12 ? 0 : hour12;
return hour12 === 12 ? 12 : hour12 + 12;
}
const HOURS_12 = Array.from({ length: 12 }, (_, i) => i + 1);
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
function TimeColumn({
values,
active,
onSelect,
format,
ariaLabel,
}: {
values: number[];
active: number;
onSelect: (v: number) => void;
format: (v: number) => string;
ariaLabel: string;
}) {
const activeRef = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
activeRef.current?.scrollIntoView({ block: "center" });
}, [active]);
return (
<div
role="listbox"
aria-label={ariaLabel}
className="h-40 w-14 overflow-y-auto rounded-md border p-1"
>
<div className="flex flex-col gap-1">
{values.map((v) => {
const isActive = v === active;
return (
<Button
key={v}
ref={isActive ? activeRef : undefined}
type="button"
size="sm"
variant={isActive ? "default" : "ghost"}
className="h-8 w-full shrink-0"
onClick={() => onSelect(v)}
>
{format(v)}
</Button>
);
})}
</div>
</div>
);
}
export function DateTimePicker({
id,
value,
onChange,
placeholder = "اختر التاريخ والوقت",
disabled,
minDate,
}: {
id?: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
disabled?: boolean;
minDate?: Date;
}) {
const [open, setOpen] = React.useState(false);
const selected = parseValue(value);
const hour12 = selected ? selected.getHours() % 12 || 12 : 9;
const minute = selected ? selected.getMinutes() : 0;
const period: "am" | "pm" = selected
? selected.getHours() < 12
? "am"
: "pm"
: "am";
const baseDate = (): Date => {
if (selected) return new Date(selected);
const d = minDate ? new Date(minDate) : new Date();
d.setHours(9, 0, 0, 0);
return d;
};
const commit = (next: Date) => onChange(toValue(next));
const handleDay = (day: Date | undefined) => {
if (!day) return;
const base = baseDate();
const next = new Date(day);
next.setHours(base.getHours(), base.getMinutes(), 0, 0);
commit(next);
};
const handleHour = (h: number) => {
const next = baseDate();
next.setHours(to24(h, period), next.getMinutes(), 0, 0);
commit(next);
};
const handleMinute = (m: number) => {
const next = baseDate();
next.setMinutes(m, 0, 0);
commit(next);
};
const handlePeriod = (p: "am" | "pm") => {
const next = baseDate();
next.setHours(to24(hour12, p), next.getMinutes(), 0, 0);
commit(next);
};
const label = selected
? new Intl.DateTimeFormat(AR_LATN, {
dateStyle: "medium",
timeStyle: "short",
}).format(selected)
: placeholder;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
disabled={disabled}
className={cn(
"w-full justify-between font-normal",
!selected && "text-muted-foreground",
)}
>
<span className="truncate">{label}</span>
<CalendarClock className="size-4 shrink-0 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
onSelect={handleDay}
defaultMonth={selected ?? minDate ?? new Date()}
disabled={minDate ? { before: minDate } : undefined}
autoFocus
/>
<div className="border-t p-3">
<div className="mb-2 text-center text-xs font-medium text-muted-foreground">
الوقت
</div>
<div className="flex items-stretch justify-center gap-2">
<TimeColumn
values={HOURS_12}
active={hour12}
onSelect={handleHour}
format={pad}
ariaLabel="الساعة"
/>
<div className="flex items-center text-muted-foreground">:</div>
<TimeColumn
values={MINUTES}
active={minute}
onSelect={handleMinute}
format={pad}
ariaLabel="الدقيقة"
/>
<div className="flex flex-col justify-center gap-1">
{(["am", "pm"] as const).map((p) => (
<Button
key={p}
type="button"
size="sm"
variant={period === p ? "default" : "outline"}
className="h-8 w-12"
onClick={() => handlePeriod(p)}
>
{p === "am" ? "ص" : "م"}
</Button>
))}
</div>
</div>
</div>
<div className="flex justify-end border-t p-2">
<Button type="button" size="sm" onClick={() => setOpen(false)}>
تم
</Button>
</div>
</PopoverContent>
</Popover>
);
}
@@ -80,6 +80,10 @@ type Meeting = {
// when another user mutated this meeting after it was loaded. Server
// sets it on every update; client echoes it back as expectedUpdatedAt.
updatedAt: string;
// #635: external meeting flag — the alert "Postpone" button is
// disabled when true (the server also rejects with
// `external_meeting_no_auto_postpone`).
isExternal?: boolean;
};
type DayResponse = { date: string; meetings: Meeting[] };
type AlertState = {
@@ -698,7 +702,14 @@ export function UpcomingMeetingAlert() {
size="sm"
variant="outline"
onClick={() => setPostponeOpen(true)}
disabled={savingAction !== null}
// #635: external meetings can't be auto-postponed (the
// server also rejects with external_meeting_no_auto_postpone).
disabled={savingAction !== null || meeting.isExternal === true}
title={
meeting.isExternal
? t("executiveMeetings.alert.externalCannotPostpone")
: undefined
}
data-testid="alert-postpone"
>
{t("executiveMeetings.alert.postpone")}
@@ -1145,6 +1156,20 @@ function PostponeDialog({
}
};
const handleErr = (err: unknown) => {
// #635: map the external-meeting 409 to the localized reason so
// schedule quick-action callers don't show the raw English error
// from the server.
const code =
err && typeof err === "object" && "code" in err
? (err as { code?: unknown }).code
: undefined;
if (code === "external_meeting_no_auto_postpone") {
toast({
title: t("executiveMeetings.alert.externalCannotPostpone"),
variant: "destructive",
});
return;
}
toast({
title: t("executiveMeetings.alert.saveFailed"),
description: err instanceof Error ? err.message : String(err),
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Search, Send } from "lucide-react";
import { useVisualViewport } from "@/hooks/use-visual-viewport";
@@ -43,17 +43,102 @@ export function SendNoteDialog({
const send = useSendNote();
const [search, setSearch] = useState("");
const [picked, setPicked] = useState<Set<number>>(new Set());
// Make the recipients list shrink to fit when the iPad soft keyboard
// takes over the bottom half of the screen. Without this the dialog
// keeps its full height, gets centred under the keyboard, and the
// people list ends up hidden — same root cause as #581 / the toolbar
// fix in editable-cell.tsx (visualViewport excludes the keyboard but
// not the QuickType strip, so we add a 48px buffer when the keyboard
// is up).
// #630: Keep the recipients list and the Send button visible above
// the iOS soft keyboard.
//
// Background: the previous fix relied on `useVisualViewport` to
// shrink `listMaxHeight` while the keyboard was up. That works on
// desktop Safari and on iPad Safari (regular tab), but iPad PWAs
// (Add to Home Screen) do NOT reliably resize visualViewport when
// the soft keyboard opens — see the comment block in
// `components/ui/dialog.tsx` and the reverted attempts #622#624.
// So the dialog stayed full height, kept its Radix center anchor
// (top: 50%; translate(-50%, -50%)), and the keyboard covered the
// entire bottom half — list and Send button included.
//
// The targeted, per-dialog fix here:
// • Track focus on the search input directly. Focus is the most
// reliable signal that the keyboard is (or is about to be) up,
// and it doesn't depend on visualViewport reporting anything.
// • While focused, anchor the dialog to the TOP of the viewport
// instead of the center, and cap its height to ~50dvh. That
// guarantees the entire dialog (title → input → list → buttons)
// fits in the upper half of the screen, leaving the lower half
// free for the keyboard regardless of what visualViewport says.
// • When focus leaves, drop back to Radix's normal centered layout
// so desktop and not-typing iPad keep the original look.
//
// `useVisualViewport` is still consulted as a refinement on devices
// where it DOES update (desktop touch laptops, Android Chrome, iPad
// Safari tab) so the list cap matches the actual usable height.
const vv = useVisualViewport();
const KEYBOARD_PREDICTION_INSET = vv.keyboardInset > 0 ? 48 : 0;
const [searchFocused, setSearchFocused] = useState(false);
// Device guard — only the focus-driven top-anchored override should
// fire on touch devices where a soft keyboard actually appears.
// Desktops with a hardware keyboard have no overlay to dodge, and
// architect review flagged that applying the override on every
// focus regressed their layout. `(pointer: coarse)` matches iPad,
// iPhone, and Android touch primary inputs while excluding mouse-
// driven desktop and touch laptops where the primary pointer is a
// trackpad/mouse.
const [isTouchPrimary, setIsTouchPrimary] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(pointer: coarse)");
setIsTouchPrimary(mq.matches);
const handler = (e: MediaQueryListEvent) => setIsTouchPrimary(e.matches);
mq.addEventListener?.("change", handler);
return () => mq.removeEventListener?.("change", handler);
}, []);
const keyboardLikely = searchFocused && isTouchPrimary;
// Slight delay on blur so the user can tap a list row without the
// dialog jumping back to center mid-tap (a focus/blur race that
// would shift the row out from under the finger).
const blurTimeoutRef = useRef<number | null>(null);
// Lifecycle cleanup — clear a pending blur timeout on unmount so we
// don't schedule a setState on an unmounted component (and to satisfy
// the architect's nitpick about the missing teardown).
useEffect(() => {
return () => {
if (blurTimeoutRef.current !== null) {
window.clearTimeout(blurTimeoutRef.current);
blurTimeoutRef.current = null;
}
};
}, []);
const onSearchFocus = () => {
if (blurTimeoutRef.current !== null) {
window.clearTimeout(blurTimeoutRef.current);
blurTimeoutRef.current = null;
}
setSearchFocused(true);
};
const onSearchBlur = () => {
blurTimeoutRef.current = window.setTimeout(() => {
setSearchFocused(false);
blurTimeoutRef.current = null;
}, 150);
};
// Primary signal: visualViewport actually reports a keyboard inset.
// Used on desktop touch laptops, iPad Safari (regular tab), and
// Android Chrome where vv updates correctly.
const keyboardActive = vv.keyboardInset > 0;
// Fallback signal: iPad PWA (Add to Home Screen) doesn't reliably
// update visualViewport when the keyboard opens (see comment in
// components/ui/dialog.tsx). On touch-primary devices, treat focus
// on the search input as evidence the keyboard is up so the dialog
// still gets repositioned. This is a fallback only — when vv DOES
// update, the primary branch above takes over.
const keyboardFallback = !keyboardActive && searchFocused && isTouchPrimary;
const KEYBOARD_PREDICTION_INSET =
keyboardActive || keyboardFallback ? 48 : 0;
const listMaxHeight = (() => {
const usable = (vv.height || window.innerHeight) - KEYBOARD_PREDICTION_INSET;
const baseHeight = vv.height || window.innerHeight;
const assumedUsable = keyboardFallback
? Math.min(baseHeight, window.innerHeight * 0.5)
: baseHeight;
const usable = assumedUsable - KEYBOARD_PREDICTION_INSET;
// Reserve ~220px for the dialog chrome (title + search input +
// footer + paddings) so the list never pushes the buttons off
// screen. Clamp to a sensible min/max so desktop keeps the old
@@ -62,6 +147,50 @@ export function SendNoteDialog({
return forList;
})();
// Dialog positioning override:
// • Primary path (`keyboardActive`): compute the new vertical
// centre from the *visual* viewport — anchored at
// `offsetTop + height/2`, shifted up by the QuickType strip
// allowance so the Send button isn't hidden under predictions.
// Keep Radix's `translate(-50%, -50%)` so the dialog stays
// centered around the new anchor.
// • Fallback path (`keyboardFallback`): top-anchor at 6vh with
// `translate(-50%, 0)` and cap `maxHeight: 50dvh` so the dialog
// never crosses the screen midpoint where the keyboard sits.
// • Otherwise: pass NO style so Radix's default centered layout
// is fully restored (no leftover transition, no leftover top).
const dialogStyle: React.CSSProperties | undefined = keyboardActive
? {
top: vv.offsetTop + vv.height / 2 - KEYBOARD_PREDICTION_INSET,
left: "50%",
transform: "translate(-50%, -50%)",
transition: "top 120ms ease",
}
: keyboardFallback
? {
// #634: bind `top` to `visualViewport.offsetTop` so when iPad
// PWA Safari scrolls the layout viewport down behind the
// soft keyboard, our top anchor follows the visible area
// instead of landing above it (which previously hid the
// dialog title + search input).
top: `calc(${vv.offsetTop || 0}px + 6vh)`,
// #634: pin `left: 50%` explicitly. Radix already sets it
// via Tailwind, but the inline `transform` below replaces
// Tailwind's `translate-x-[-50%] translate-y-[-50%]` so we
// also restate the horizontal anchor to defend against any
// animation/RTL interplay that was shifting the box left.
left: "50%",
transform: "translate(-50%, 0)",
maxHeight: "50dvh",
// #634: allow the dialog itself to scroll when its content
// is taller than the cap — otherwise the header could be
// clipped while only the bottom of the list/buttons stayed
// visible.
overflowY: "auto",
transition: "top 120ms ease, transform 120ms ease",
}
: undefined;
const candidates = useMemo(() => {
const me = user?.id;
return directory.filter((u) => u.id !== me);
@@ -123,7 +252,11 @@ export function SendNoteDialog({
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md" data-testid="send-note-dialog">
<DialogContent
className="max-w-md"
{...(dialogStyle ? { style: dialogStyle } : {})}
data-testid="send-note-dialog"
>
<DialogHeader className="pr-8">
<DialogTitle>{t("notes.sendNote", "Send note")}</DialogTitle>
</DialogHeader>
@@ -135,6 +268,8 @@ export function SendNoteDialog({
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={onSearchFocus}
onBlur={onSearchBlur}
placeholder={t("notes.recipientsPlaceholder", "Search people…")}
className="ps-9"
data-testid="send-note-search"
@@ -22,11 +22,18 @@ export function useAutoplayHint(): void {
const handler = (e: Event) => {
const detail = (e as CustomEvent<AutoplayBlockedDetail>).detail;
const isIos = !!detail?.isIos;
// #628: the autoplay hint was previously an auto-dismissing
// toast that the user could easily miss while looking at the
// iPad from across the room. Make it sticky (no auto-dismiss)
// so it stays on screen until acknowledged — otherwise the
// first incoming order goes silently unnoticed and the user
// never realises notifications need a tap to unlock.
toast({
title: t("notifSettings.autoplayHintTitle"),
description: isIos
? t("notifSettings.autoplayHintIos")
: t("notifSettings.autoplayHint"),
duration: Number.POSITIVE_INFINITY,
});
};
window.addEventListener(AUTOPLAY_BLOCKED_EVENT, handler);
@@ -26,7 +26,14 @@ import {
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
const SOCKET_WARMUP_MS = 3000;
// #628: warmup window after socket (re)connect during which chimes
// are suppressed so a page-load flush of pending events doesn't sound
// like an alarm. Lowered from 3000 → 1000 ms because on mobile the
// socket disconnects/reconnects often (background tab, tunnel hop,
// PWA wake-up); a 3-second window silently swallowed any order that
// arrived in the first few seconds after returning to the app. 1 s
// still covers the synchronous initial-burst at first connect.
const SOCKET_WARMUP_MS = 1000;
const nowMs = () =>
typeof performance !== "undefined" ? performance.now() : Date.now();
@@ -93,10 +100,70 @@ export function useNotificationsSocket() {
const socket = io(window.location.origin, {
path: `${BASE}/api/socket.io`,
// #632: Behind Tailscale / proxies on iPad PWA the WebSocket can
// be silently dropped by NAT/idle timeouts. Tighten the
// reconnection budget so the client retries quickly instead of
// sitting on a dead connection while orders pile up server-side.
reconnection: true,
reconnectionDelay: 500,
reconnectionDelayMax: 3000,
reconnectionAttempts: Infinity,
timeout: 10000,
});
// #632: Track whether the socket has been disconnected at any
// point so the next successful `connect` can refetch every
// realtime-driven query — events that fire while the socket was
// dead are lost forever otherwise, which is exactly the bug
// (recipients seeing orders only after force-quitting the PWA).
let wasDisconnected = false;
// #632: Replay every event-driven query key on reconnect. Mirrors
// the keys touched by individual socket.on handlers below — keep
// this list in sync when adding new realtime channels. Role and
// permission keys are included so a `role_permissions_changed`
// event that fired while the socket was dead is recovered on
// reconnect (per architect review).
const refetchRealtimeState = () => {
invalidate(getListNotificationsQueryKey());
invalidate(getGetHomeStatsQueryKey());
invalidate(getListIncomingServiceOrdersQueryKey());
invalidate(getListMyServiceOrdersQueryKey());
invalidate(["notes"]);
invalidate(["note-folders"]);
invalidate(["note-folders", "shared-with-me"]);
invalidate(["/api/executive-meetings"]);
invalidate(["/api/executive-meetings/alert-state"]);
invalidate(["/api/executive-meetings/notifications"]);
invalidate(getListAppsQueryKey());
invalidate(getGetMeQueryKey());
invalidate(getListRolesQueryKey());
invalidate(getListPermissionsQueryKey());
};
socket.on("connect", () => {
// Reset the warmup window so a flush of pending notifications
// delivered immediately after reconnect doesn't chime.
connectedAtRef.current = nowMs();
if (wasDisconnected) {
wasDisconnected = false;
refetchRealtimeState();
}
});
socket.on("disconnect", (reason) => {
wasDisconnected = true;
// Surface unexpected disconnects in the console for field
// debugging without breaking anything. `io client disconnect` is
// the cleanup path on hook teardown — skip the noise for it.
if (reason !== "io client disconnect") {
// eslint-disable-next-line no-console
console.warn("[socket] disconnected:", reason);
}
});
socket.on("connect_error", (err) => {
// eslint-disable-next-line no-console
console.warn("[socket] connect_error:", err.message);
});
socket.on("notification_created", (payload?: { type?: string }) => {
@@ -127,7 +194,18 @@ export function useNotificationsSocket() {
const vibrate = isOrder
? user.vibrationEnabledOrder
: user.vibrationEnabledMeeting;
notificationPlayer.play(soundId, { vibrate });
// #628: order chimes use a tighter throttle window so a small
// burst of incoming orders still rings for each one. Meeting
// chimes keep the default 3s window — a single meeting event
// can fan out to several listeners and we don't want a
// buzzy double-chime. Buckets keep the two channels isolated
// so an order burst can't starve a meeting alert (or vice
// versa).
notificationPlayer.play(soundId, {
vibrate,
throttleMs: isOrder ? 800 : 3000,
throttleBucket: isOrder ? "order" : "meeting",
});
});
socket.on("order_updated", () => {
@@ -199,6 +277,7 @@ export function useNotificationsSocket() {
playedNoteIdsRef.current.add(payload.noteId);
notificationPlayer.play(user.notificationSoundNote, {
vibrate: user.vibrationEnabledNote,
throttleBucket: "note",
});
}
// Only fall back to a toast when the floating popup did NOT
@@ -284,6 +363,7 @@ export function useNotificationsSocket() {
playedReplyIdsRef.current.add(replyId);
notificationPlayer.play(user.notificationSoundNote, {
vibrate: user.vibrationEnabledNote,
throttleBucket: "note",
});
}
// See `note_received` above: skip the redundant toast when the
@@ -414,7 +494,46 @@ export function useNotificationsSocket() {
},
);
// #632: When the PWA / tab comes back to the foreground, iPad's
// background suspension may have silently killed the WebSocket
// even though `socket.connected` is still `true` (half-open
// socket — the client hasn't sent a ping yet). Probing with a
// timed roundtrip is the only reliable way to tell. We use the
// socket.io v4 ack-timeout helper to round-trip a cheap event;
// if the server doesn't ack within 3s, force-cycle the transport
// so missed orders surface within ~3s of returning to the app
// instead of waiting on the ping-timeout window (~15s).
const onVisibility = () => {
if (typeof document === "undefined") return;
if (document.visibilityState !== "visible") return;
if (!socket.connected) {
wasDisconnected = true;
socket.connect();
return;
}
// Half-open probe: round-trip a cheap event through socket.io's
// built-in ack channel. The server doesn't need to register a
// handler — when no handler exists, the ack still fires with
// no args, so a missing ack within 3s means the transport is
// dead and we should force-cycle it.
socket.timeout(3000).emit(
"client_health_probe",
(err: Error | null) => {
if (err && socket.connected) {
wasDisconnected = true;
socket.disconnect().connect();
}
},
);
};
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility);
}
return () => {
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility);
}
if (rafHandle !== null) {
if (typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") {
window.cancelAnimationFrame(rafHandle);
@@ -12,11 +12,14 @@ import { useEffect } from "react";
* The field therefore stays wherever it sat in the dialog, often
* behind the keyboard.
*
* Fix: when focus lands on an input/textarea/select/contenteditable
* that lives inside a `[role="dialog"]`, scroll the target into the
* centre of the dialog. Run twice (300ms / 600ms) because iPad's
* keyboard animation finishes ~250-500ms after focus and the dialog's
* own `max-height: 100dvh` recalc happens during that window.
* Fix: when focus lands on an editable inside a `[role="dialog"]`,
* walk up to the nearest scrollable ancestor (overflow-y auto/scroll)
* inside the dialog and manually set its `scrollTop` so the target
* sits in the middle of the visible band. We do NOT rely on
* `scrollIntoView` because Safari iPadOS ignores it for descendants
* of `position: fixed` containers — the scroll never happens. We run
* the reveal twice (300ms / 600ms) to cover the iPad keyboard
* animation window and the dialog's own `max-height: 100dvh` recalc.
*
* One global listener covers every dialog kind we use (Radix
* DialogContent, the bespoke AdminFormDialog, ad-hoc role="dialog"
@@ -24,14 +27,17 @@ import { useEffect } from "react";
*/
export function useScrollFocusedDialogField(): void {
useEffect(() => {
// Gate strictly to touch devices. Desktop browsers (mouse pointer)
// never open a soft keyboard, so scrolling the focused field would
// be unwanted movement. `pointer: coarse` matches touch-primary
// devices (iPad, iPhone, Android) and is stable across orientation
// / split-view changes, unlike visualViewport heuristics.
// Gate to devices with any touch input. `pointer: coarse` alone
// misses iPads connected to a Magic Keyboard / trackpad (their
// *primary* pointer is reported as fine, so coarse never
// matches), which is exactly the device class hitting this bug.
// `any-pointer: coarse` catches "has touch at all", which is the
// condition under which a soft keyboard can appear.
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const isTouch = window.matchMedia("(pointer: coarse)").matches;
if (!isTouch) return;
const hasTouch =
window.matchMedia("(pointer: coarse)").matches ||
window.matchMedia("(any-pointer: coarse)").matches;
if (!hasTouch) return;
const isEditable = (el: Element | null): el is HTMLElement => {
if (!el || !(el instanceof HTMLElement)) return false;
@@ -40,6 +46,26 @@ export function useScrollFocusedDialogField(): void {
return el.isContentEditable;
};
// Walk up from `target` to find the nearest ancestor that actually
// scrolls vertically. Stops at the dialog root (we never want to
// scroll outside the dialog). Falls back to the dialog itself if
// no scrollable inner container exists.
const findScrollableAncestor = (
target: HTMLElement,
dialog: Element,
): HTMLElement => {
let node: HTMLElement | null = target.parentElement;
while (node && node !== dialog) {
const style = window.getComputedStyle(node);
const oy = style.overflowY;
if ((oy === "auto" || oy === "scroll") && node.scrollHeight > node.clientHeight) {
return node;
}
node = node.parentElement;
}
return dialog as HTMLElement;
};
const scrollTimers = new Set<number>();
const onFocusIn = (e: FocusEvent) => {
@@ -47,21 +73,32 @@ export function useScrollFocusedDialogField(): void {
if (!isEditable(target)) return;
const dialog = target.closest('[role="dialog"]');
if (!dialog) return;
const reveal = () => {
try {
target.scrollIntoView({ block: "center", behavior: "smooth" });
} catch {
try {
target.scrollIntoView();
} catch {
/* no-op */
}
}
const container = findScrollableAncestor(target, dialog);
const cRect = container.getBoundingClientRect();
const tRect = target.getBoundingClientRect();
// Distance from the container's top to the target's top in
// *content* coordinates: current scrollTop + (target top in
// viewport - container top in viewport).
const targetTopInContent = container.scrollTop + (tRect.top - cRect.top);
// Centre the target within the visible band of the container.
const desired = targetTopInContent - (container.clientHeight - tRect.height) / 2;
const max = container.scrollHeight - container.clientHeight;
container.scrollTop = Math.max(0, Math.min(desired, max));
};
const t1 = window.setTimeout(reveal, 300);
const t2 = window.setTimeout(reveal, 600);
scrollTimers.add(t1);
scrollTimers.add(t2);
const schedule = (delay: number) => {
const id = window.setTimeout(() => {
scrollTimers.delete(id);
reveal();
}, delay);
scrollTimers.add(id);
return id;
};
const t1 = schedule(300);
const t2 = schedule(600);
const onBlur = () => {
window.clearTimeout(t1);
window.clearTimeout(t2);
@@ -0,0 +1,56 @@
import { useEffect } from "react";
/**
* Publishes the live visual-viewport geometry as CSS custom properties
* on `<html>` so stylesheets can size dialogs/sheets correctly above
* the iOS soft keyboard.
*
* Why a JS hook (not CSS `dvh`):
* - Safari iOS/iPadOS ignores the `interactive-widget=resizes-content`
* viewport hint, so `100dvh` does NOT shrink when the soft keyboard
* opens — it stays equal to the full screen. Any layout pinned with
* `dvh` therefore sits behind the keyboard.
* - `window.visualViewport` IS updated on iOS (including standalone
* PWAs) whenever the keyboard, page-pinch zoom, or browser chrome
* resizes the visible area. It's the only reliable signal.
*
* The hook writes two vars on `document.documentElement`:
* --vv-height : current visualViewport.height in `px`
* --vv-offset-top : current visualViewport.offsetTop in `px`
* On browsers without `visualViewport` it falls back to `window.innerHeight`
* and `0`, which matches the pre-existing `100vh` behaviour.
*
* Mount once at the app root (e.g. inside `NotificationsSocketBridge`).
*/
export function useVisualViewportVars(): void {
useEffect(() => {
if (typeof window === "undefined") return;
const root = document.documentElement;
const vv = window.visualViewport ?? null;
const apply = () => {
const h = vv ? vv.height : window.innerHeight;
const top = vv ? vv.offsetTop : 0;
root.style.setProperty("--vv-height", `${Math.round(h)}px`);
root.style.setProperty("--vv-offset-top", `${Math.round(top)}px`);
};
apply();
if (vv) {
vv.addEventListener("resize", apply);
vv.addEventListener("scroll", apply);
}
window.addEventListener("resize", apply);
window.addEventListener("orientationchange", apply);
return () => {
if (vv) {
vv.removeEventListener("resize", apply);
vv.removeEventListener("scroll", apply);
}
window.removeEventListener("resize", apply);
window.removeEventListener("orientationchange", apply);
};
}, []);
}
+10 -30
View File
@@ -470,36 +470,16 @@ html[dir="rtl"] .truncate {
}
/*
* Dialog keyboard-aware sheet override.
*
* iPad/iPhone PWAs don't reliably resize `window.visualViewport` when
* the soft keyboard opens, so a JS-driven override based on it was
* silently dead on Tahani's device — the dialog stayed centred (with
* its consumer-provided `max-w-3xl translate(-50%, -50%)` classes) and
* iOS scrolled the *layout* viewport upward to reveal the focused
* input, leaving the dialog as a narrow strip pinned in the top-left
* corner.
*
* Instead we drive everything from CSS. `:has(:focus)` matches whenever
* any focusable descendant inside a `[role="dialog"]` (input, textarea,
* select, contenteditable) holds focus — and on iOS that's exactly when
* the soft keyboard is showing. We then pin the dialog as a
* full-width sheet at the top of the dynamic viewport using `100dvh`,
* which Safari shrinks automatically to exclude the keyboard. No JS
* required, !important wins the cascade against any consumer Tailwind
* classes, and the layout snaps back the moment focus leaves.
* (Reverted) Earlier attempts (#622, #623, #624) added a global
* `[role="dialog"]:has(:focus)` override + companion JS hooks to keep
* the focused field above the iOS soft keyboard. Each iteration
* regressed desktop browsers — on a touch-screen laptop the rule
* fired on every input focus and broke Radix dialog layout (narrow
* strip in the top-left). The rule, the helper class
* (`.dialog-vv-wrapper`), and the global hooks are removed here.
* Dialogs now render with their original Radix / consumer styles on
* every device. The iOS keyboard issue will be revisited with a
* targeted, per-dialog approach instead of a global override.
*/
[role="dialog"]:has(:is(input, textarea, select, [contenteditable="true"]):focus) {
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: auto !important;
width: 100vw !important;
max-width: none !important;
max-height: 100dvh !important;
transform: none !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
}
+31 -7
View File
@@ -118,7 +118,13 @@ class NotificationPlayer {
// ---- Shared state ----
private unlocked = false;
private lastPlayAt = 0;
// #628: per-bucket throttle timestamps. Was a single global value,
// which coupled channels: lowering the order throttle to 800ms
// caused rapid order chimes to keep refreshing the global timestamp
// and starve meeting/note chimes (whose 3s window kept restarting).
// Bucketing isolates each channel so order bursts can't mute
// meeting/note alerts and vice versa.
private lastPlayAtByBucket = new Map<string, number>();
private hintDispatched = false;
private get useIos(): boolean {
@@ -334,16 +340,34 @@ class NotificationPlayer {
*/
play(
soundId: NotificationSoundId,
opts: { vibrate?: boolean; bypassThrottle?: boolean } = {},
opts: {
vibrate?: boolean;
bypassThrottle?: boolean;
// #628: per-call throttle override. Order chimes drop to 800ms
// so a small burst of incoming orders (rare but real during
// service rushes) still rings for each one instead of swallowing
// every order after the first for the next 3 seconds. Other
// channels keep the original 3000ms coalescing window so a
// single meeting fanout that touches several listeners doesn't
// produce a buzzy double-chime.
throttleMs?: number;
// #628: throttle bucket key. Defaults to "default" so legacy
// callers behave as before. Callers that want isolated coalescing
// (e.g. "order" vs "meeting" vs "note") pass distinct buckets so
// a burst on one channel can't extend the silence window of
// another channel.
throttleBucket?: string;
} = {},
): void {
// Throttle: at most one sound every 3s to coalesce bursts of
// notifications into a single chime. Settings "Test sound"
// bypasses this so users can audition multiple sounds quickly.
const now =
typeof performance !== "undefined" ? performance.now() : Date.now();
if (!opts.bypassThrottle) {
if (now - this.lastPlayAt < 3000) return;
this.lastPlayAt = now;
const window =
typeof opts.throttleMs === "number" ? opts.throttleMs : 3000;
const bucket = opts.throttleBucket ?? "default";
const last = this.lastPlayAtByBucket.get(bucket) ?? 0;
if (now - last < window) return;
this.lastPlayAtByBucket.set(bucket, now);
}
const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
+168 -2
View File
@@ -971,7 +971,9 @@
"appName": "نظام Tx",
"previous": "السابق",
"next": "التالي",
"empty": "(فارغ)"
"empty": "(فارغ)",
"back": "رجوع",
"confirmDelete": "تأكيد الحذف"
},
"embeddedFrame": {
"title": "تطبيق مدمج",
@@ -1246,6 +1248,7 @@
"atTime": "في {{time}}",
"done": "تم",
"postpone": "تأجيل",
"externalCannotPostpone": "لا يمكن تأجيل اجتماع خارجي تلقائياً.",
"dismiss": "تجاهل التنبيه",
"dragHandle": "اسحب للتحريك",
"dismissToast": "تم تجاهل التنبيه",
@@ -1474,7 +1477,11 @@
"platform": "المنصة",
"status": "الحالة",
"isHighlighted": "تمييز (لون أحمر)",
"notes": "ملاحظات"
"notes": "ملاحظات",
"isExternal": "اجتماع خارجي",
"isExternalHint": "يصبح صف الاجتماع باللون الأحمر تلقائياً، ولا يدخل في التأجيل التلقائي.",
"isExternalOn": "نعم",
"isExternalOff": "لا"
},
"attendees": {
"label": "قائمة الحضور",
@@ -1651,5 +1658,164 @@
"saved": "تم الحفظ",
"reset": "إعادة الافتراضي"
}
},
"protocol": {
"title": "العلاقات العامة والمراسم",
"notes": "ملاحظات",
"statusLabel": "الحالة",
"confirmReject": "هل أنت متأكد من رفض هذا الطلب؟",
"confirmDelete": "لا يمكن التراجع عن هذا الإجراء.",
"tabs": {
"dashboard": "لوحة المعلومات",
"bookings": "حجز القاعات",
"external": "اجتماعات خارجية",
"giftsGroup": "الهدايا",
"gifts": "المستودع",
"issues": "الإهداءات",
"photos": "الصور",
"reports": "التقارير",
"audit": "سجل النشاط",
"rooms": "القاعات"
},
"status": {
"pending": "قيد الانتظار",
"approved": "معتمد",
"rejected": "مرفوض",
"cancelled": "ملغى",
"issued": "تم الصرف",
"scheduled": "مجدول",
"completed": "منجز"
},
"giftKind": {
"gift": "هدية",
"shield": "درع"
},
"actions": {
"approve": "اعتماد",
"reject": "رفض",
"cancel": "إلغاء",
"approveIssue": "اعتماد وصرف"
},
"errors": {
"generic": "تعذّر إتمام العملية."
},
"dashboard": {
"activeRooms": "القاعات المتاحة",
"todayBookings": "حجوزات اليوم",
"pendingBookings": "حجوزات بانتظار الاعتماد",
"upcomingExternal": "لقاءات خارجية قادمة",
"giftStock": "إجمالي مخزون الهدايا",
"pendingIssues": "إهداءات معلّقة",
"roomsAvailableNow": "القاعات المتاحة الآن",
"giftsIssuedThisMonth": "الدروع المصروفة هذا الشهر"
},
"bookings": {
"new": "حجز جديد",
"edit": "تعديل الحجز",
"empty": "لا توجد حجوزات.",
"requester": "مقدّم الطلب",
"org": "الجهة",
"phone": "الهاتف",
"attendeeCount": "العدد المتوقع للحضور",
"publicRequest": "طلب عام",
"reference": "رقم الحجز",
"department": "الإدارة",
"entity": "اسم الجهة",
"purpose": "الغرض من الاجتماع",
"meetingType": "نوع الاجتماع",
"meetingTypes": {
"internal": "اجتماع داخلي",
"external": "اجتماع مع جهة خارجية"
},
"keyAttendees": "أبرز الحضور",
"attendeeSides": {
"internal": "من الهيئة",
"external": "خارج الهيئة"
},
"searchPlaceholder": "ابحث برقم الحجز أو الهاتف…",
"searchEmpty": "لا توجد حجوزات مطابقة.",
"roomLabel": "القاعة",
"titleLabel": "عنوان الحجز",
"startsAt": "من",
"endsAt": "إلى",
"viewList": "قائمة",
"viewCalendar": "تقويم",
"today": "اليوم",
"book": "حجز",
"emptyDay": "لا توجد حجوزات في هذا اليوم.",
"shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)",
"copyLink": "نسخ الرابط",
"linkCopied": "تم نسخ الرابط",
"linkCopyFailed": "تعذّر نسخ الرابط"
},
"external": {
"new": "لقاء جديد",
"edit": "تعديل اللقاء",
"empty": "لا توجد لقاءات خارجية.",
"titleLabel": "عنوان اللقاء",
"party": "الجهة",
"location": "المكان",
"startsAt": "التاريخ والوقت"
},
"gifts": {
"new": "صنف جديد",
"edit": "تعديل الصنف",
"empty": "لا توجد أصناف.",
"stock": "المخزون",
"nameAr": "الاسم (عربي)",
"nameEn": "الاسم (إنجليزي)",
"kind": "النوع"
},
"rooms": {
"new": "قاعة جديدة",
"edit": "تعديل القاعة",
"title": "القاعات",
"inactive": "غير مفعّلة",
"nameAr": "الاسم (عربي)",
"nameEn": "الاسم (إنجليزي)",
"capacity": "السعة",
"location": "الموقع",
"active": "مفعّلة",
"isActive": "مفعّلة",
"empty": "لا توجد قاعات."
},
"issues": {
"new": "إهداء جديد",
"empty": "لا توجد إهداءات.",
"recipient": "المستفيد",
"gift": "الصنف",
"occasion": "المناسبة",
"quantity": "الكمية"
},
"photos": {
"newAlbum": "ألبوم جديد",
"editAlbum": "تعديل الألبوم",
"albumName": "اسم الألبوم",
"linkMeeting": "ربط بلقاء خارجي",
"noMeeting": "بدون ربط",
"addPhotos": "إضافة صور",
"download": "تنزيل",
"downloadAll": "تنزيل الكل",
"uploading": "جارٍ الرفع",
"emptyAlbums": "لا توجد ألبومات.",
"emptyPhotos": "لا توجد صور في هذا الألبوم.",
"confirmDeleteAlbum": "سيتم حذف الألبوم وجميع صوره. لا يمكن التراجع.",
"confirmDeletePhoto": "سيتم حذف هذه الصورة. لا يمكن التراجع."
},
"reports": {
"byRoom": "الحجوزات حسب القاعة",
"byGift": "الصرف حسب الصنف",
"room": "القاعة",
"gift": "الصنف",
"total": "الإجمالي",
"requests": "الطلبات",
"issued": "المصروفة",
"issuedQty": "الكمية المصروفة",
"empty": "لا توجد بيانات.",
"byExternal": "اللقاءات الخارجية حسب الحالة"
},
"audit": {
"empty": "لا يوجد نشاط."
}
}
}
+168 -2
View File
@@ -883,7 +883,9 @@
"appName": "Tx OS",
"previous": "Previous",
"next": "Next",
"empty": "(empty)"
"empty": "(empty)",
"back": "Back",
"confirmDelete": "Confirm delete"
},
"embeddedFrame": {
"title": "Embedded app",
@@ -1174,6 +1176,7 @@
"atTime": "at {{time}}",
"done": "Done",
"postpone": "Postpone",
"externalCannotPostpone": "External meetings can't be auto-postponed.",
"dismiss": "Dismiss alert",
"dragHandle": "Drag to move",
"dismissToast": "Alert dismissed",
@@ -1347,7 +1350,11 @@
"platform": "Platform",
"status": "Status",
"isHighlighted": "Highlight (red)",
"notes": "Notes"
"notes": "Notes",
"isExternal": "External meeting",
"isExternalHint": "Row turns red automatically and is excluded from auto-postpone.",
"isExternalOn": "Yes",
"isExternalOff": "No"
},
"attendees": {
"label": "Attendees",
@@ -1524,5 +1531,164 @@
"saved": "Saved",
"reset": "Reset to default"
}
},
"protocol": {
"title": "Public Relations & Protocol",
"notes": "Notes",
"statusLabel": "Status",
"confirmReject": "Are you sure you want to reject this request?",
"confirmDelete": "This action cannot be undone.",
"tabs": {
"dashboard": "Dashboard",
"bookings": "Room Bookings",
"external": "External Meetings",
"giftsGroup": "Gifts",
"gifts": "Warehouse",
"issues": "Dedications",
"photos": "Photos",
"reports": "Reports",
"audit": "Activity Log",
"rooms": "Rooms"
},
"status": {
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"cancelled": "Cancelled",
"issued": "Issued",
"scheduled": "Scheduled",
"completed": "Completed"
},
"giftKind": {
"gift": "Gift",
"shield": "Shield"
},
"actions": {
"approve": "Approve",
"reject": "Reject",
"cancel": "Cancel",
"approveIssue": "Approve & issue"
},
"errors": {
"generic": "The operation could not be completed."
},
"dashboard": {
"activeRooms": "Active rooms",
"todayBookings": "Today's bookings",
"pendingBookings": "Bookings awaiting approval",
"upcomingExternal": "Upcoming external meetings",
"giftStock": "Total gift stock",
"pendingIssues": "Pending dedications",
"roomsAvailableNow": "Rooms available now",
"giftsIssuedThisMonth": "Gifts issued this month"
},
"bookings": {
"new": "New booking",
"edit": "Edit booking",
"empty": "No bookings yet.",
"requester": "Requester",
"org": "Organization",
"phone": "Phone",
"attendeeCount": "Expected attendees",
"publicRequest": "Public request",
"reference": "Booking ref",
"department": "Department",
"entity": "Entity",
"purpose": "Meeting purpose",
"meetingType": "Meeting type",
"meetingTypes": {
"internal": "Internal meeting",
"external": "Meeting with external entity"
},
"keyAttendees": "Key attendees",
"attendeeSides": {
"internal": "From the authority",
"external": "External"
},
"searchPlaceholder": "Search by booking ref or phone…",
"searchEmpty": "No matching bookings.",
"roomLabel": "Room",
"titleLabel": "Booking title",
"startsAt": "From",
"endsAt": "To",
"viewList": "List",
"viewCalendar": "Calendar",
"today": "Today",
"book": "Book",
"emptyDay": "No bookings on this day.",
"shareLinkTitle": "Public booking link (share with guests)",
"copyLink": "Copy link",
"linkCopied": "Link copied",
"linkCopyFailed": "Could not copy the link"
},
"external": {
"new": "New meeting",
"edit": "Edit meeting",
"empty": "No external meetings yet.",
"titleLabel": "Meeting title",
"party": "Party",
"location": "Location",
"startsAt": "Date & time"
},
"gifts": {
"new": "New item",
"edit": "Edit item",
"empty": "No items yet.",
"stock": "Stock",
"nameAr": "Name (Arabic)",
"nameEn": "Name (English)",
"kind": "Kind"
},
"rooms": {
"new": "New room",
"edit": "Edit room",
"title": "Rooms",
"inactive": "inactive",
"nameAr": "Name (Arabic)",
"nameEn": "Name (English)",
"capacity": "Capacity",
"location": "Location",
"active": "Active",
"isActive": "Active",
"empty": "No rooms."
},
"issues": {
"new": "New dedication",
"empty": "No dedications yet.",
"recipient": "Recipient",
"gift": "Item",
"occasion": "Occasion",
"quantity": "Quantity"
},
"photos": {
"newAlbum": "New album",
"editAlbum": "Edit album",
"albumName": "Album name",
"linkMeeting": "Link to external meeting",
"noMeeting": "No link",
"addPhotos": "Add photos",
"download": "Download",
"downloadAll": "Download all",
"uploading": "Uploading",
"emptyAlbums": "No albums yet.",
"emptyPhotos": "No photos in this album.",
"confirmDeleteAlbum": "This deletes the album and all its photos. This cannot be undone.",
"confirmDeletePhoto": "This photo will be deleted. This cannot be undone."
},
"reports": {
"byRoom": "Bookings by room",
"byGift": "Issuance by item",
"room": "Room",
"gift": "Item",
"total": "Total",
"requests": "Requests",
"issued": "Issued",
"issuedQty": "Issued qty",
"empty": "No data.",
"byExternal": "External meetings by status"
},
"audit": {
"empty": "No activity yet."
}
}
}
+147 -24
View File
@@ -13,7 +13,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { useLocation, useSearch } from "wouter";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
@@ -194,6 +194,10 @@ type Meeting = {
// is the same for every viewer. NULL/missing = "default" (no tint).
// Allowed values come from ROW_COLOR_OPTIONS below.
rowColor?: string | null;
// #635: External meeting flag. When true, the row is force-tinted red
// and the alert-postpone button is disabled (the server also rejects
// /postpone-minutes with `external_meeting_no_auto_postpone`).
isExternal?: boolean;
// #486: optimistic-lock token used by /executive-meetings/swap-times
// (the schedule row quick-actions Move up / Move down popover) and
// by the existing PostponeDialog when reused from the schedule.
@@ -1361,7 +1365,12 @@ function ScheduleSection({
const rowColors = useMemo<Record<number, string>>(() => {
const out: Record<number, string> = {};
for (const m of meetings) {
if (m.rowColor) out[m.id] = m.rowColor;
// #635: external meetings are always tinted red regardless of
// (and overriding) any manually-set rowColor, so the visual cue
// matches the flag even on legacy rows whose rowColor was set
// before isExternal existed.
if (m.isExternal) out[m.id] = "red";
else if (m.rowColor) out[m.id] = m.rowColor;
}
return out;
}, [meetings]);
@@ -2441,6 +2450,84 @@ function ScheduleSection({
};
}, [highlightEditRowId, meetings]);
// --- Deep-link: /meetings?meeting=<id> (optionally &date=YYYY-MM-DD) ---
// Opened from a notification (in-app list or system Web Push). We switch
// the schedule to the meeting's day, then scroll + flash its row reusing
// the same accent-ring as the row "Edit" affordance. When no date is
// supplied we resolve it from the meeting id via the API so the same link
// works regardless of which day is currently shown.
const deepLinkSearch = useSearch();
const deepLinkHandledRef = useRef<string | null>(null);
const [pendingDeepLinkMeetingId, setPendingDeepLinkMeetingId] = useState<
number | null
>(null);
useEffect(() => {
const params = new URLSearchParams(deepLinkSearch);
const meetingParam = params.get("meeting");
if (!meetingParam) return;
if (deepLinkHandledRef.current === meetingParam) return;
const id = Number(meetingParam);
if (!Number.isInteger(id) || id <= 0) return;
deepLinkHandledRef.current = meetingParam;
setPendingDeepLinkMeetingId(id);
const dateParam = params.get("date");
if (dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam)) {
if (dateParam !== date) onDateChange(dateParam);
return;
}
let cancelled = false;
void apiJson<{ meetingDate?: string }>(`/api/executive-meetings/${id}`)
.then((m) => {
if (cancelled) return;
if (m?.meetingDate && m.meetingDate !== date) onDateChange(m.meetingDate);
})
.catch(() => {
// Meeting may have been deleted/moved — leave the user on the
// current day rather than surfacing an error for a stale link.
});
return () => {
cancelled = true;
};
}, [deepLinkSearch, date, onDateChange]);
useEffect(() => {
if (pendingDeepLinkMeetingId == null) return;
if (!meetings.some((m) => m.id === pendingDeepLinkMeetingId)) return;
setHighlightEditRowId(pendingDeepLinkMeetingId);
setPendingDeepLinkMeetingId(null);
deepLinkHandledRef.current = null;
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
params.delete("meeting");
params.delete("date");
const qs = params.toString();
const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
window.history.replaceState(null, "", next);
}
}, [pendingDeepLinkMeetingId, meetings]);
useEffect(() => {
if (pendingDeepLinkMeetingId == null) return;
// Safety net for stale links: if the target meeting never materializes
// (deleted, moved to another day we can't resolve, or simply not in the
// loaded set), clear the pending state and strip the param after a grace
// period so the URL doesn't linger and a later valid link still works.
const stale = window.setTimeout(() => {
setPendingDeepLinkMeetingId(null);
deepLinkHandledRef.current = null;
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
params.delete("meeting");
params.delete("date");
const qs = params.toString();
window.history.replaceState(
null,
"",
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
);
}
}, 8000);
return () => window.clearTimeout(stale);
}, [pendingDeepLinkMeetingId]);
// #489: Drag-from-anywhere on the row rotates meeting CONTENT through
// the day's fixed time slots. The (startTime, endTime, dailyNumber)
// tuple of each chronological slot stays anchored to its physical
@@ -3403,10 +3490,11 @@ function ScheduleSection({
sortOrder: idx,
kind: a.kind ?? "person",
})),
// #635/#639: external meeting flag — coerce to real
// boolean so an undefined state never silently drops
// the field from the PATCH body.
isExternal: Boolean(editingMeeting.isExternal),
};
if (editingMeeting.dailyNumber.trim()) {
body.dailyNumber = Number(editingMeeting.dailyNumber);
}
try {
await apiJson(
`/api/executive-meetings/${editingMeeting.id}`,
@@ -4549,7 +4637,7 @@ function MeetingRow({
return (
<td
key="meeting"
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
className={`border border-gray-300 px-3 py-3 align-middle text-center ${meeting.isExternal ? "text-[#dc2626]" : "text-[#0B1E3F]"} relative group`}
style={tintedCellStyle(col, true)}
>
<EditableCell
@@ -4557,14 +4645,20 @@ function MeetingRow({
onSave={onSaveTitle}
ariaLabel={t("executiveMeetings.editMeetingTitle")}
dir={isRtl ? "rtl" : "ltr"}
className="font-medium leading-snug break-words"
// Center the title horizontally for the view-mode wrapper AND
// any inner `<p>` Tiptap saved — `safeHtml` keeps `style`
// attributes (so users can colour / size text), which means a
// stray `text-align` saved by the editor would otherwise leak
// into the cell. The `!` modifier outranks the inline style so
// the title always stays centered regardless of what's stored.
className="font-medium leading-snug break-words text-center [&_p]:!text-center"
disabled={!canMutate}
testId={`em-edit-title-${meeting.id}`}
startInEditMode={autoEditTitle}
onStartedEditing={onAutoEditTitleConsumed}
/>
{meeting.location && (
<div className="text-[11px] text-muted-foreground mt-0.5 break-words">
<div className="text-[11px] text-muted-foreground mt-0.5 break-words text-center">
{meeting.location}
</div>
)}
@@ -4576,7 +4670,7 @@ function MeetingRow({
return (
<td
key="attendees"
className="border border-gray-300 px-3 py-3 align-middle relative group"
className="border border-gray-300 px-3 py-3 align-middle text-center relative group"
style={tintedCellStyle(col, false)}
>
<AttendeesCell
@@ -4661,7 +4755,7 @@ function MeetingRow({
}}
ariaLabel={t("executiveMeetings.merge.editLabel")}
dir={isRtl ? "rtl" : "ltr"}
className="font-medium leading-snug break-words"
className="font-medium leading-snug break-words text-center [&_p]:!text-center"
disabled={!canMutate}
placeholder={t("executiveMeetings.merge.placeholder")}
testId={`em-merge-edit-${meeting.id}`}
@@ -4984,6 +5078,12 @@ function MeetingRow({
setQuickOpen(false);
onQuickPostpone?.();
}}
disabled={meeting.isExternal}
title={
meeting.isExternal
? t("executiveMeetings.alert.externalCannotPostpone")
: undefined
}
className={`flex-1 min-w-0 gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
data-testid={`em-row-quick-postpone-${meeting.id}`}
>
@@ -5862,7 +5962,7 @@ function AttendeeFlow({
}
if (items.length === 0 && !showAdd && !isPending) {
return <span className="text-xs text-gray-500"></span>;
return <span className="block text-center text-xs text-gray-500"></span>;
}
// Locate the listIdx where the inline pending ghost should appear, when
@@ -6022,7 +6122,7 @@ function AttendeeFlow({
};
return (
<ul className="flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug">
<ul className={`flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug`}>
{items.flatMap(({ a, i }, listIdx) => {
const isSub = (a.kind ?? "person") === "subheading";
const nodes: ReactNode[] = [];
@@ -6266,7 +6366,6 @@ type MeetingFormState = {
titleAr: string;
titleEn: string;
meetingDate: string;
dailyNumber: string;
startTime: string;
endTime: string;
location: string;
@@ -6276,6 +6375,9 @@ type MeetingFormState = {
isHighlighted: boolean;
notes: string;
attendees: Attendee[];
// #635: external meeting flag — when true the row is force-tinted red
// and the alert-postpone button is disabled.
isExternal: boolean;
};
// Generate a stable client-only attendee id for the manage-dialog DnD.
@@ -6294,7 +6396,6 @@ function emptyMeetingForm(date: string): MeetingFormState {
titleAr: "",
titleEn: "",
meetingDate: date,
dailyNumber: "",
startTime: "",
endTime: "",
location: "",
@@ -6304,6 +6405,7 @@ function emptyMeetingForm(date: string): MeetingFormState {
isHighlighted: false,
notes: "",
attendees: [],
isExternal: false,
};
}
@@ -6602,7 +6704,6 @@ function ManageSection({
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 ?? "",
@@ -6622,6 +6723,7 @@ function ManageSection({
name: htmlToPlainText(a.name),
_sid: genAttendeeSid(),
})),
isExternal: m.isExternal ?? false,
});
}
@@ -6675,8 +6777,13 @@ function ManageSection({
// schedule survive a save through the Manage dialog.
kind: a.kind ?? "person",
})),
// #635/#639: send the external flag as a real boolean so the
// server can force rowColor='red' and the cascade/postpone rules
// know to skip it. Coerce defensively — a stale render where
// state.isExternal is undefined would otherwise silently send
// `undefined` and the server PATCH would skip the column.
isExternal: Boolean(editing.isExternal),
};
if (editing.dailyNumber.trim()) body.dailyNumber = Number(editing.dailyNumber);
try {
if (editing.id == null) {
await apiJson(`/api/executive-meetings`, { method: "POST", body });
@@ -7553,16 +7660,31 @@ function MeetingFormDialog({
onChange={(e) => update("meetingDate", e.target.value)}
/>
</FormRow>
{/* #635/#639: external-meeting toggle replaces the
daily-number input (numbering is now fully auto-assigned
by the server). When ON the row is force-tinted red and
the alert-postpone button is disabled.
`full` so it spans both grid columns on its own row
prevents the toggle from sharing a row with the date
input and colliding with the time pickers below on
narrow screens. */}
<FormRow
label={t("executiveMeetings.manage.field.dailyNumber")}
hint={t("executiveMeetings.manage.field.dailyNumberHint")}
label={t("executiveMeetings.manage.field.isExternal")}
hint={t("executiveMeetings.manage.field.isExternalHint")}
full
>
<Input
type="number"
min={1}
value={state.dailyNumber}
onChange={(e) => update("dailyNumber", e.target.value)}
/>
<div className="flex items-center gap-3 py-1">
<Switch
checked={state.isExternal}
onCheckedChange={(v) => update("isExternal", Boolean(v))}
data-testid="em-form-isExternal"
/>
<span className="text-sm text-gray-700">
{state.isExternal
? t("executiveMeetings.manage.field.isExternalOn")
: t("executiveMeetings.manage.field.isExternalOff")}
</span>
</div>
</FormRow>
<FormRow
label={t("executiveMeetings.manage.field.startTime")}
@@ -7843,6 +7965,7 @@ function AuditDiffSummary({
"platform",
"status",
"isHighlighted",
"isExternal",
"notes",
"assignedTo",
"taskType",
+27 -1
View File
@@ -18,6 +18,7 @@ import {
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { usePushSubscription } from "@/hooks/use-push-subscription";
import { useReceivedNotes } from "@/lib/notes-api";
import { useIncomingNotePopup } from "@/contexts/IncomingNotePopupContext";
import {
@@ -491,6 +492,7 @@ export default function HomePage() {
const notesPulse = notePopupQueueLength > 0;
const logout = useLogout();
const pushSub = usePushSubscription();
const openApp = (app: App) => {
// Fire-and-forget: keepalive ensures the request survives any
@@ -521,7 +523,31 @@ export default function HomePage() {
? (user?.displayNameAr ?? user?.username ?? "")
: (user?.displayNameEn ?? user?.username ?? "");
const handleLogout = () => {
const handleLogout = async () => {
// #626: tear down this browser's Web Push subscription BEFORE
// hitting /auth/logout, so the local pushManager endpoint and the
// server row both go away. The server-side logout also deletes
// every push_subscriptions row for this user as a safety net, but
// doing the browser unsubscribe first means the OS-level
// subscription is gone too — otherwise the SW would keep an
// orphan endpoint that the next signed-in user could inherit on
// resubscribe.
// Race the browser unsubscribe against a 1.5s timeout. `disable()`
// awaits `navigator.serviceWorker.ready`, which can hang forever
// if the SW never activates (rare but observed on first iOS PWA
// launch). A stalled unsubscribe must not block the user from
// signing out — the server-side delete in /auth/logout is the
// authoritative cleanup, so a missed browser unsubscribe just
// leaves an orphan pushManager endpoint that the OS will GC on
// its own schedule.
try {
await Promise.race([
pushSub.disable(),
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
]);
} catch {
// Best-effort: a failed unsubscribe must not block sign-out.
}
logout.mutate(undefined, {
onSuccess: () => {
queryClient.clear();
+58 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { useLocation, useSearch } from "wouter";
import { useQueryClient } from "@tanstack/react-query";
import {
useListMyServiceOrders,
@@ -159,10 +159,12 @@ function OrderCard({
order,
onDelete,
onReorder,
highlighted = false,
}: {
order: ServiceOrder;
onDelete: (id: number) => void;
onReorder: (order: ServiceOrder) => void;
highlighted?: boolean;
}) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
@@ -236,7 +238,13 @@ function OrderCard({
};
return (
<div className="glass-panel rounded-xl p-4 flex flex-col gap-3">
<div
data-order-id={order.id}
className={cn(
"glass-panel rounded-xl p-4 flex flex-col gap-3 transition-shadow",
highlighted && "ring-2 ring-primary ring-offset-2",
)}
>
<div className="flex items-start gap-3">
<OrderImage src={img} alt={name} />
<div className="flex-1 min-w-0">
@@ -433,6 +441,53 @@ export default function MyOrdersPage() {
)
: [];
// Deep-link from a notification: /my-orders?order=<id> scrolls to and
// flashes the matching order card, then strips the param so re-renders
// don't re-trigger the jump.
const orderSearch = useSearch();
const [highlightOrderId, setHighlightOrderId] = useState<number | null>(null);
const highlightHandledRef = useRef<string | null>(null);
useEffect(() => {
const params = new URLSearchParams(orderSearch);
const orderParam = params.get("order");
if (!orderParam) return;
if (highlightHandledRef.current === orderParam) return;
const id = Number(orderParam);
if (!Number.isInteger(id) || id <= 0) return;
highlightHandledRef.current = orderParam;
setHighlightOrderId(id);
if (typeof window !== "undefined") {
params.delete("order");
const qs = params.toString();
window.history.replaceState(
null,
"",
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
);
}
}, [orderSearch]);
useEffect(() => {
if (highlightOrderId == null) return;
if (!sortedOrders.some((o) => o.id === highlightOrderId)) return;
const rafId = window.requestAnimationFrame(() => {
const el = document.querySelector(`[data-order-id="${highlightOrderId}"]`);
if (el && "scrollIntoView" in el) {
(el as HTMLElement).scrollIntoView({
block: "center",
behavior: "smooth",
});
}
});
const clear = window.setTimeout(() => {
setHighlightOrderId(null);
highlightHandledRef.current = null;
}, 2000);
return () => {
window.cancelAnimationFrame(rafId);
window.clearTimeout(clear);
};
}, [highlightOrderId, sortedOrders]);
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">
@@ -488,6 +543,7 @@ export default function MyOrdersPage() {
<OrderCard
key={o.id}
order={o}
highlighted={o.id === highlightOrderId}
onDelete={(id) => scheduleDelete([id])}
onReorder={(order) =>
setReorderTarget({
@@ -14,6 +14,34 @@ import { AppDock } from "@/components/app-dock";
import { formatDateTime } from "@/lib/i18n-format";
import { useAuth } from "@/contexts/AuthContext";
// Map a notification to the in-app deep-link it should open when tapped.
// Mirrors the Web Push `url` built on the server so the in-app list and a
// system push notification land on the exact same item: a note opens its
// thread, a meeting opens that meeting on its day, and an order opens the
// right order surface ("Incoming orders" for a fresh order to claim,
// otherwise the recipient's "My orders"). Returns null when there is no
// specific destination (the tap then only marks the item read).
function notificationDestination(n: {
type: string;
relatedType?: string | null;
relatedId?: number | null;
}): string | null {
const id = n.relatedId;
switch (n.type) {
case "note":
return id ? `/notes?thread=${id}` : "/notes";
case "executive_meeting":
return id ? `/meetings?meeting=${id}` : "/meetings";
case "order": {
const base =
n.relatedType === "incoming_order" ? "/orders/incoming" : "/my-orders";
return id ? `${base}?order=${id}` : base;
}
default:
return null;
}
}
export default function NotificationsPage() {
const { t, i18n } = useTranslation();
const [, setLocation] = useLocation();
@@ -53,10 +81,15 @@ export default function NotificationsPage() {
const handleNotificationClick = (n: {
id: number;
isRead: boolean;
type: string;
relatedType?: string | null;
relatedId?: number | null;
}) => {
if (!n.isRead) {
handleMarkOne(n.id);
}
const dest = notificationDestination(n);
if (dest) setLocation(dest);
};
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? 0;
+56 -2
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { useLocation, useSearch } from "wouter";
import { useQueryClient } from "@tanstack/react-query";
import {
useListIncomingServiceOrders,
@@ -105,6 +105,7 @@ function IncomingOrderCard({
selected,
onToggleSelected,
onRequestDelete,
highlighted = false,
}: {
order: ServiceOrder;
meId: number;
@@ -112,6 +113,7 @@ function IncomingOrderCard({
selected: boolean;
onToggleSelected: (id: number) => void;
onRequestDelete: (id: number) => void;
highlighted?: boolean;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -279,6 +281,7 @@ function IncomingOrderCard({
className={cn(
"glass-panel rounded-xl p-3 flex flex-col gap-3 transition-colors",
selected && "ring-2 ring-amber-400 bg-amber-50/40",
!selected && highlighted && "ring-2 ring-primary ring-offset-2",
)}
data-testid={`incoming-order-card-${order.id}`}
>
@@ -406,6 +409,55 @@ export default function OrdersIncomingPage() {
[mineIds, unclaimedIds],
);
// Deep-link from a notification: /orders/incoming?order=<id> scrolls to
// and flashes the matching order card, then strips the param so
// re-renders don't re-trigger the jump.
const orderSearch = useSearch();
const [highlightOrderId, setHighlightOrderId] = useState<number | null>(null);
const highlightHandledRef = useRef<string | null>(null);
useEffect(() => {
const params = new URLSearchParams(orderSearch);
const orderParam = params.get("order");
if (!orderParam) return;
if (highlightHandledRef.current === orderParam) return;
const id = Number(orderParam);
if (!Number.isInteger(id) || id <= 0) return;
highlightHandledRef.current = orderParam;
setHighlightOrderId(id);
if (typeof window !== "undefined") {
params.delete("order");
const qs = params.toString();
window.history.replaceState(
null,
"",
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
);
}
}, [orderSearch]);
useEffect(() => {
if (highlightOrderId == null) return;
if (![...mine, ...unclaimed].some((o) => o.id === highlightOrderId)) return;
const rafId = window.requestAnimationFrame(() => {
const el = document.querySelector(
`[data-testid="incoming-order-card-${highlightOrderId}"]`,
);
if (el && "scrollIntoView" in el) {
(el as HTMLElement).scrollIntoView({
block: "center",
behavior: "smooth",
});
}
});
const clear = window.setTimeout(() => {
setHighlightOrderId(null);
highlightHandledRef.current = null;
}, 2000);
return () => {
window.cancelAnimationFrame(rafId);
window.clearTimeout(clear);
};
}, [highlightOrderId, mine, unclaimed]);
const [selected, setSelected] = useState<Set<number>>(new Set());
// confirmIds === null → no dialog; otherwise the ids that confirming will delete.
const [confirmIds, setConfirmIds] = useState<number[] | null>(null);
@@ -610,6 +662,7 @@ export default function OrdersIncomingPage() {
key={o.id}
order={o}
meId={meId}
highlighted={o.id === highlightOrderId}
onActionDone={refetch}
selected={validSelected.has(o.id)}
onToggleSelected={toggleOne}
@@ -649,6 +702,7 @@ export default function OrdersIncomingPage() {
key={o.id}
order={o}
meId={meId}
highlighted={o.id === highlightOrderId}
onActionDone={refetch}
selected={validSelected.has(o.id)}
onToggleSelected={toggleOne}
@@ -0,0 +1,602 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CalendarClock,
CheckCircle2,
Loader2,
Plus,
X,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DateTimePicker } from "@/components/date-time-picker";
const API = `${import.meta.env.BASE_URL}api`;
type PublicRoom = { id: number; nameAr: string; nameEn: string };
type MeetingType = "internal" | "external";
type AttendeeSide = "internal" | "external";
type AttendeeRow = { name: string; position: string };
type FormState = {
requesterName: string;
title: string;
requesterPhone: string;
department: string;
meetingType: MeetingType;
requesterOrg: string;
attendeeCount: string;
purpose: string;
roomId: string;
startsAt: string;
endsAt: string;
notes: string;
website: string; // honeypot
};
const EMPTY: FormState = {
requesterName: "",
title: "",
requesterPhone: "",
department: "",
meetingType: "internal",
requesterOrg: "",
attendeeCount: "",
purpose: "",
roomId: "",
startsAt: "",
endsAt: "",
notes: "",
website: "",
};
const EMPTY_ROW: AttendeeRow = { name: "", position: "" };
function localToIso(v: string): string {
return new Date(v).toISOString();
}
export default function ProtocolRequestPage() {
// This page is public (no auth). Force RTL Arabic regardless of any
// stored language preference so a guest always sees the intended form.
useEffect(() => {
const prevDir = document.documentElement.dir;
const prevLang = document.documentElement.lang;
document.documentElement.dir = "rtl";
document.documentElement.lang = "ar";
return () => {
// Restore the app's direction/language so navigating away from this
// public page doesn't leave the rest of the app forced into RTL Arabic.
document.documentElement.dir = prevDir;
document.documentElement.lang = prevLang;
};
}, []);
const rooms = useQuery<PublicRoom[]>({
queryKey: ["protocol-public", "rooms"],
queryFn: async () => {
const r = await fetch(`${API}/protocol/public/rooms`);
if (!r.ok) throw new Error("rooms");
return r.json();
},
});
const [form, setForm] = useState<FormState>(EMPTY);
// Key attendees ("أبرز الحضور") are two separate lists: من الهيئة (internal)
// and خارج الهيئة (external). Each row is a name + position.
const [internalAttendees, setInternalAttendees] = useState<AttendeeRow[]>([
{ ...EMPTY_ROW },
]);
const [externalAttendees, setExternalAttendees] = useState<AttendeeRow[]>([
{ ...EMPTY_ROW },
]);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [reference, setReference] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const updateRow = (
side: AttendeeSide,
idx: number,
field: keyof AttendeeRow,
value: string,
) => {
const setter =
side === "internal" ? setInternalAttendees : setExternalAttendees;
setter((rows) =>
rows.map((r, i) => (i === idx ? { ...r, [field]: value } : r)),
);
};
const addRow = (side: AttendeeSide) => {
const setter =
side === "internal" ? setInternalAttendees : setExternalAttendees;
setter((rows) => [...rows, { ...EMPTY_ROW }]);
};
const removeRow = (side: AttendeeSide, idx: number) => {
const setter =
side === "internal" ? setInternalAttendees : setExternalAttendees;
setter((rows) =>
rows.length <= 1 ? [{ ...EMPTY_ROW }] : rows.filter((_, i) => i !== idx),
);
};
const isExternal = form.meetingType === "external";
const canSubmit = useMemo(() => {
return (
form.requesterName.trim().length > 0 &&
form.title.trim().length > 0 &&
form.requesterPhone.trim().length >= 3 &&
form.roomId !== "" &&
form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) < new Date(form.endsAt) &&
(!isExternal || form.requesterOrg.trim().length > 0)
);
}, [form, isExternal]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!canSubmit || submitting) return;
setSubmitting(true);
try {
const keyAttendees = [
...internalAttendees
.filter((r) => r.name.trim() !== "")
.map((r) => ({
name: r.name.trim(),
position: r.position.trim(),
side: "internal" as const,
})),
...externalAttendees
.filter((r) => r.name.trim() !== "")
.map((r) => ({
name: r.name.trim(),
position: r.position.trim(),
side: "external" as const,
})),
];
const res = await fetch(`${API}/protocol/public/booking-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: Number(form.roomId),
title: form.title.trim(),
requesterName: form.requesterName.trim(),
requesterPhone: form.requesterPhone.trim(),
department: form.department.trim() || null,
meetingType: form.meetingType,
requesterOrg: isExternal ? form.requesterOrg.trim() : null,
purpose: form.purpose.trim() || null,
attendeeCount:
form.attendeeCount.trim() === ""
? null
: Number(form.attendeeCount),
keyAttendees,
startsAt: localToIso(form.startsAt),
endsAt: localToIso(form.endsAt),
notes: form.notes.trim() || null,
website: form.website,
}),
});
if (res.ok) {
try {
const data = await res.json();
if (data?.bookingReference && typeof data.bookingReference === "string") {
setReference(data.bookingReference);
}
} catch {
// no reference in body — still a success
}
setDone(true);
return;
}
let msg = "تعذّر إرسال الطلب، يرجى المحاولة مرة أخرى.";
try {
const data = await res.json();
if (data?.error && typeof data.error === "string") msg = data.error;
} catch {
// keep default message
}
if (res.status === 429) {
msg = "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.";
}
setError(msg);
} catch {
setError("تعذّر الاتصال بالخادم، تحقق من الاتصال وحاول مجدداً.");
} finally {
setSubmitting(false);
}
};
const resetForm = () => {
setForm(EMPTY);
setInternalAttendees([{ ...EMPTY_ROW }]);
setExternalAttendees([{ ...EMPTY_ROW }]);
setReference(null);
setDone(false);
};
if (done) {
return (
<div
dir="rtl"
className="min-h-screen bg-slate-50 flex items-center justify-center p-4"
>
<div className="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-sm p-8 text-center">
<CheckCircle2 className="w-14 h-14 text-emerald-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-slate-800 mb-2">
تم استلام طلبك
</h1>
<p className="text-slate-500 leading-relaxed">
سيقوم فريق المراسم بمراجعة طلب الحجز والتواصل معك على رقم الهاتف
المُدخل لتأكيد الموعد.
</p>
{reference && (
<div className="mt-5 rounded-xl bg-sky-50 border border-sky-200 px-4 py-4">
<p className="text-sm text-slate-500 mb-1">رقم الحجز</p>
<p
className="text-2xl font-bold tracking-wider text-sky-700"
dir="ltr"
>
{reference}
</p>
<p className="text-xs text-slate-500 mt-2">
احتفظ بهذا الرقم لمتابعة حالة طلبك.
</p>
</div>
)}
<Button className="mt-6" variant="outline" onClick={resetForm}>
إرسال طلب آخر
</Button>
</div>
</div>
);
}
return (
<div dir="rtl" className="min-h-screen bg-slate-50 py-8 px-4">
<div className="w-full max-w-xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<div className="w-11 h-11 rounded-xl bg-sky-500/10 text-sky-600 flex items-center justify-center">
<CalendarClock className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-800">طلب حجز قاعة</h1>
<p className="text-sm text-slate-500">
يرجى تعبئة البيانات وسيتواصل معك فريق المراسم للتأكيد.
</p>
</div>
</div>
<form
onSubmit={onSubmit}
className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 space-y-5"
>
{/* Honeypot: hidden from real users, catches bots. */}
<div className="hidden" aria-hidden="true">
<label>
Website
<input
type="text"
tabIndex={-1}
autoComplete="off"
value={form.website}
onChange={(e) => set("website", e.target.value)}
/>
</label>
</div>
<div className="space-y-2">
<Label htmlFor="requesterName">
الاسم <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterName"
value={form.requesterName}
onChange={(e) => set("requesterName", e.target.value)}
placeholder="الاسم الثلاثي"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="requesterPhone">
رقم الهاتف للتواصل <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterPhone"
type="tel"
inputMode="tel"
dir="ltr"
className="text-right"
value={form.requesterPhone}
onChange={(e) => set("requesterPhone", e.target.value)}
placeholder="05xxxxxxxx"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="room">
القاعة <span className="text-rose-500">*</span>
</Label>
<Select value={form.roomId} onValueChange={(v) => set("roomId", v)}>
<SelectTrigger id="room">
<SelectValue
placeholder={
rooms.isLoading ? "جارٍ التحميل…" : "اختر القاعة"
}
/>
</SelectTrigger>
<SelectContent>
{(rooms.data ?? []).map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{r.nameAr || r.nameEn}
</SelectItem>
))}
</SelectContent>
</Select>
{rooms.isError && (
<p className="text-xs text-rose-500">
تعذّر تحميل قائمة القاعات، يرجى تحديث الصفحة.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="department">الإدارة</Label>
<Input
id="department"
value={form.department}
onChange={(e) => set("department", e.target.value)}
placeholder="الإدارة أو القسم (اختياري)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="title">
عنوان الاجتماع <span className="text-rose-500">*</span>
</Label>
<Input
id="title"
value={form.title}
onChange={(e) => set("title", e.target.value)}
placeholder="موضوع أو عنوان الاجتماع"
required
/>
</div>
<div className="space-y-2">
<Label>
نوع الاجتماع <span className="text-rose-500">*</span>
</Label>
<div className="grid grid-cols-2 gap-2">
{(
[
{ value: "internal", label: "اجتماع داخلي" },
{ value: "external", label: "اجتماع مع جهة خارجية" },
] as const
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => set("meetingType", opt.value)}
className={`rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors ${
form.meetingType === opt.value
? "border-sky-500 bg-sky-50 text-sky-700"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{isExternal && (
<div className="space-y-2">
<Label htmlFor="requesterOrg">
اسم الجهة <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterOrg"
value={form.requesterOrg}
onChange={(e) => set("requesterOrg", e.target.value)}
placeholder="اسم الجهة الخارجية"
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="attendeeCount">العدد المتوقع للحضور</Label>
<Input
id="attendeeCount"
type="number"
inputMode="numeric"
min={1}
value={form.attendeeCount}
onChange={(e) => set("attendeeCount", e.target.value)}
placeholder="العدد المتوقع للحضور (اختياري)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="purpose">الغرض من الاجتماع</Label>
<Textarea
id="purpose"
value={form.purpose}
onChange={(e) => set("purpose", e.target.value)}
placeholder="اشرح الغرض من الاجتماع (اختياري)"
rows={2}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startsAt">
من <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="startsAt"
value={form.startsAt}
onChange={(v) => set("startsAt", v)}
placeholder="اختر تاريخ ووقت البداية"
/>
</div>
<div className="space-y-2">
<Label htmlFor="endsAt">
إلى <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="endsAt"
value={form.endsAt}
onChange={(v) => set("endsAt", v)}
placeholder="اختر تاريخ ووقت النهاية"
minDate={form.startsAt ? new Date(form.startsAt) : undefined}
/>
</div>
</div>
{form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) >= new Date(form.endsAt) && (
<p className="text-xs text-rose-500 -mt-2">
يجب أن يكون وقت البداية قبل وقت النهاية.
</p>
)}
<div className="space-y-3">
<Label>أبرز الحضور</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<AttendeeColumn
title="من الهيئة"
rows={internalAttendees}
onChange={(idx, field, value) =>
updateRow("internal", idx, field, value)
}
onAdd={() => addRow("internal")}
onRemove={(idx) => removeRow("internal", idx)}
/>
<AttendeeColumn
title="خارج الهيئة"
rows={externalAttendees}
onChange={(idx, field, value) =>
updateRow("external", idx, field, value)
}
onAdd={() => addRow("external")}
onRemove={(idx) => removeRow("external", idx)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="notes">ملاحظات</Label>
<Textarea
id="notes"
value={form.notes}
onChange={(e) => set("notes", e.target.value)}
placeholder="أي تفاصيل إضافية (اختياري)"
rows={3}
/>
</div>
<div className="flex items-start gap-2 rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
للإحاطة: في حال الحاجة للقاعة سيتم إلغاء الاجتماع أو تعديل موقعه.
</span>
</div>
{error && (
<div className="rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2">
{error}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={!canSubmit || submitting}
>
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
إرسال الطلب
</Button>
</form>
</div>
</div>
);
}
function AttendeeColumn({
title,
rows,
onChange,
onAdd,
onRemove,
}: {
title: string;
rows: AttendeeRow[];
onChange: (idx: number, field: keyof AttendeeRow, value: string) => void;
onAdd: () => void;
onRemove: (idx: number) => void;
}) {
return (
<div className="rounded-lg border border-slate-200 p-3 space-y-3">
<p className="text-sm font-semibold text-slate-700">{title}</p>
<div className="space-y-3">
{rows.map((row, idx) => (
<div key={idx} className="space-y-2">
<div className="flex items-center gap-2">
<Input
value={row.name}
onChange={(e) => onChange(idx, "name", e.target.value)}
placeholder="الاسم"
/>
{rows.length > 1 && (
<button
type="button"
onClick={() => onRemove(idx)}
className="shrink-0 text-slate-400 hover:text-rose-500 p-1"
aria-label="حذف الصف"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<Input
value={row.position}
onChange={(e) => onChange(idx, "position", e.target.value)}
placeholder="المنصب"
/>
</div>
))}
</div>
<button
type="button"
onClick={onAdd}
className="flex items-center gap-1 text-sm font-medium text-sky-600 hover:text-sky-700"
>
<Plus className="w-4 h-4" />
إضافة صف
</button>
</div>
);
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -8,6 +8,10 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-tx}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tx_dev_password}
POSTGRES_DB: ${POSTGRES_DB:-tx}
# #626: pin container TZ so PG's `now()` (used by audit logs
# only — meeting_date/start_time are stored as date/time WITHOUT
# timezone) matches the operator's wall clock instead of UTC.
TZ: Asia/Riyadh
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -63,6 +67,13 @@ services:
environment:
NODE_ENV: production
PORT: 8080
# #626: pin container TZ to Riyadh so JS `new Date()` calls in
# the meeting scheduler interpret the DB's naive date+time
# columns against the operator's local clock. Without this the
# container runs in UTC and the 5-minute upcoming-meeting window
# is offset by 3 hours, causing reminders to land at the wrong
# wall-clock time.
TZ: Asia/Riyadh
DATABASE_URL: postgres://${POSTGRES_USER:-tx}:${POSTGRES_PASSWORD:-tx_dev_password}@postgres:5432/${POSTGRES_DB:-tx}
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required — copy .env.docker.example to .env and edit it}
LOCAL_STORAGE_SIGNING_SECRET: ${LOCAL_STORAGE_SIGNING_SECRET:-${SESSION_SECRET}}
@@ -98,6 +109,12 @@ services:
restart: unless-stopped
depends_on:
- api
environment:
# #626: pin container TZ for consistency with the api service.
# The web image only serves static assets so this is cosmetic
# (affects access-log timestamps only), but keeping it aligned
# avoids confusion when reading logs side-by-side.
TZ: Asia/Riyadh
# No host port binding — Caddy is the single public edge.
expose:
- "80"
+42
View File
@@ -81,6 +81,48 @@ async function main(): Promise<void> {
);
}
// Backfill booking references BEFORE the schema push enforces the
// NOT NULL + unique constraint on protocol_room_bookings.booking_reference.
// Without this, pushing the constraint against a populated table (any
// pre-existing booking predates the column) would fail. Reference format
// mirrors makeBookingReference() in the API: RB-{year}-{pad4(id)}, which
// is unique because id is unique. Idempotent: only touches NULL rows.
if (await tableExists(client, "protocol_room_bookings")) {
const hasColumn = await client.query(
`SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'protocol_room_bookings'
AND column_name = 'booking_reference'`,
);
if ((hasColumn.rowCount ?? 0) > 0) {
const backfilled = await client.query(
`UPDATE protocol_room_bookings
SET booking_reference =
'RB-' || EXTRACT(YEAR FROM created_at)::int
|| '-' || LPAD(id::text, 4, '0')
WHERE booking_reference IS NULL`,
);
const filled = backfilled.rowCount ?? 0;
if (filled > 0) {
console.log(
`[pre-push] Backfilled ${filled} protocol_room_bookings booking_reference value(s).`,
);
} else {
console.log(
"[pre-push] No protocol_room_bookings booking_reference to backfill.",
);
}
} else {
console.log(
"[pre-push] protocol_room_bookings.booking_reference column not present yet — skipping backfill.",
);
}
} else {
console.log(
"[pre-push] protocol_room_bookings table not present yet — skipping booking_reference backfill.",
);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
+1
View File
@@ -27,6 +27,7 @@ export const BUILTIN_APP_SLUGS = [
"executive-meetings",
"calendar",
"documents",
"protocol",
] as const;
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];
+6
View File
@@ -73,6 +73,12 @@ export const executiveMeetingsTable = pgTable(
// signal about the meeting (urgent / VIP / etc.) rather than a
// personal viewing preference.
rowColor: varchar("row_color", { length: 16 }),
// #635: External meetings (اجتماع خارجي). When true, the row is
// tinted red in the schedule grid and is excluded from the
// auto-postpone cascade (the alert "Postpone" button is disabled
// and `computeCascadeShift` skips it as a follower). Default false
// preserves legacy rows as "internal" meetings.
isExternal: boolean("is_external").notNull().default(false),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
+1
View File
@@ -14,4 +14,5 @@ export * from "./audit-logs";
export * from "./role-audit";
export * from "./permission-audit";
export * from "./executive-meetings";
export * from "./protocol";
export * from "./push-subscriptions";
+426
View File
@@ -0,0 +1,426 @@
import {
pgTable,
serial,
integer,
varchar,
text,
timestamp,
jsonb,
boolean,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
import { usersTable } from "./users";
// ---------------------------------------------------------------------------
// Public Relations & Protocol module ("العلاقات العامة والمراسم").
//
// A completely standalone module. Every table is prefixed `protocol_` and has
// no foreign keys into the Executive Meetings tables, so the two modules stay
// fully independent. Only `usersTable` (the shared account table) is
// referenced, and always with `on delete set null` so removing a user never
// cascades into protocol history.
// ---------------------------------------------------------------------------
// Status values for room bookings and gift issuances. The overlap /
// conflict-prevention rule only considers "pending" and "approved" rows as
// occupying a room; "rejected" and "cancelled" free the slot again.
export const PROTOCOL_BOOKING_STATUSES = [
"pending",
"approved",
"rejected",
"cancelled",
] as const;
export type ProtocolBookingStatus =
(typeof PROTOCOL_BOOKING_STATUSES)[number];
// A room booking is either an internal meeting or a meeting with an external
// entity. When external, the requester's organization (requesterOrg / اسم
// الجهة) is required.
export const PROTOCOL_MEETING_TYPES = ["internal", "external"] as const;
export type ProtocolMeetingType = (typeof PROTOCOL_MEETING_TYPES)[number];
// Which "side" a key attendee belongs to: from the authority (من الهيئة) or
// from outside it (خارج الهيئة).
export const PROTOCOL_ATTENDEE_SIDES = ["internal", "external"] as const;
export type ProtocolAttendeeSide = (typeof PROTOCOL_ATTENDEE_SIDES)[number];
// A notable attendee ("أبرز الحضور") captured on the booking request. Stored
// as a JSON array on the booking row.
export type ProtocolKeyAttendee = {
name: string;
position: string;
side: ProtocolAttendeeSide;
};
export const PROTOCOL_ISSUE_STATUSES = [
"pending",
"approved",
"rejected",
"issued",
] as const;
export type ProtocolIssueStatus = (typeof PROTOCOL_ISSUE_STATUSES)[number];
// Gift catalogue kinds: a regular gift or a commemorative shield (درع).
export const PROTOCOL_GIFT_KINDS = ["gift", "shield"] as const;
export type ProtocolGiftKind = (typeof PROTOCOL_GIFT_KINDS)[number];
// ---------------------------------------------------------------------------
// Rooms
// ---------------------------------------------------------------------------
export const protocolRoomsTable = pgTable(
"protocol_rooms",
{
id: serial("id").primaryKey(),
nameAr: varchar("name_ar", { length: 200 }).notNull(),
nameEn: varchar("name_en", { length: 200 }).notNull().default(""),
capacity: integer("capacity"),
location: varchar("location", { length: 300 }),
isActive: boolean("is_active").notNull().default(true),
sortOrder: integer("sort_order").notNull().default(0),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
activeIdx: index("protocol_rooms_active_idx").on(t.isActive),
}),
);
// ---------------------------------------------------------------------------
// Room bookings
// ---------------------------------------------------------------------------
export const protocolRoomBookingsTable = pgTable(
"protocol_room_bookings",
{
id: serial("id").primaryKey(),
roomId: integer("room_id")
.notNull()
.references(() => protocolRoomsTable.id, { onDelete: "cascade" }),
title: varchar("title", { length: 500 }).notNull(),
requesterName: varchar("requester_name", { length: 300 }),
// Guest-supplied contact phone. Only populated for public
// (self-service) booking requests; internal bookings leave it null.
requesterPhone: varchar("requester_phone", { length: 40 }),
// The requester's organization / entity (الجهة). Optional even for
// public requests; always null for internal bookings unless set.
requesterOrg: varchar("requester_org", { length: 300 }),
// Expected number of attendees (العدد المتوقع للحضور). Optional; supplied
// by the guest on a public request or by staff on an internal booking.
attendeeCount: integer("attendee_count"),
// The requester's department / administration (الإدارة). Optional.
department: varchar("department", { length: 300 }),
// Whether the meeting is internal or with an external entity. When
// "external", requesterOrg (اسم الجهة) is required. Defaults to internal.
meetingType: varchar("meeting_type", { length: 20 })
.notNull()
.default("internal"),
// Free-text purpose of the meeting (الغرض من الاجتماع). Optional.
purpose: text("purpose"),
// Notable attendees (أبرز الحضور): array of { name, position, side }.
keyAttendees: jsonb("key_attendees")
.$type<ProtocolKeyAttendee[]>()
.notNull()
.default([]),
// Human-friendly, unique booking reference (رقم الحجز) shown to the
// requester on success and searchable by staff. Generated on insert and
// never null — every booking (public or internal) has one.
bookingReference: varchar("booking_reference", { length: 40 }).notNull(),
// Provenance marker. "internal" for bookings created by authenticated
// protocol staff through the app; "public" for guest requests coming in
// via the no-login booking-request link. Defaults to "internal" so all
// pre-existing rows are treated as internal.
source: varchar("source", { length: 20 }).notNull().default("internal"),
// Full timestamps so bookings can span any part of a day. The overlap
// rule compares [startsAt, endsAt) intervals per room.
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
status: varchar("status", { length: 20 }).notNull().default("pending"),
notes: text("notes"),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
approvedBy: integer("approved_by").references(() => usersTable.id, {
onDelete: "set null",
}),
approvedAt: timestamp("approved_at", { withTimezone: true }),
rejectionReason: text("rejection_reason"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
roomIdx: index("protocol_bookings_room_idx").on(t.roomId),
startIdx: index("protocol_bookings_start_idx").on(t.startsAt),
statusIdx: index("protocol_bookings_status_idx").on(t.status),
referenceIdx: uniqueIndex("protocol_bookings_reference_idx").on(
t.bookingReference,
),
phoneIdx: index("protocol_bookings_phone_idx").on(t.requesterPhone),
}),
);
// ---------------------------------------------------------------------------
// External protocol meetings (اجتماعات ومراسم خارجية) — independent from the
// Executive Meetings module's is_external flag.
// ---------------------------------------------------------------------------
export const protocolExternalMeetingsTable = pgTable(
"protocol_external_meetings",
{
id: serial("id").primaryKey(),
title: varchar("title", { length: 500 }).notNull(),
// The visiting party / external delegation.
partyName: varchar("party_name", { length: 300 }),
location: varchar("location", { length: 300 }),
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
endsAt: timestamp("ends_at", { withTimezone: true }),
// Lifecycle: pending -> approved | rejected, then optionally
// completed | cancelled. Mirrors the booking / gift-issue approval flow.
status: varchar("status", { length: 20 }).notNull().default("pending"),
notes: text("notes"),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
approvedBy: integer("approved_by").references(() => usersTable.id, {
onDelete: "set null",
}),
approvedAt: timestamp("approved_at", { withTimezone: true }),
rejectionReason: text("rejection_reason"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
startIdx: index("protocol_ext_meetings_start_idx").on(t.startsAt),
statusIdx: index("protocol_ext_meetings_status_idx").on(t.status),
}),
);
// ---------------------------------------------------------------------------
// Gifts & shields catalogue
// ---------------------------------------------------------------------------
export const protocolGiftsTable = pgTable(
"protocol_gifts",
{
id: serial("id").primaryKey(),
nameAr: varchar("name_ar", { length: 300 }).notNull(),
nameEn: varchar("name_en", { length: 300 }).notNull().default(""),
kind: varchar("kind", { length: 20 }).notNull().default("gift"),
descriptionAr: text("description_ar"),
descriptionEn: text("description_en"),
imageUrl: varchar("image_url", { length: 500 }),
// Running stock count. Issuing an approved gift decrements this.
quantityInStock: integer("quantity_in_stock").notNull().default(0),
isActive: boolean("is_active").notNull().default(true),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
kindIdx: index("protocol_gifts_kind_idx").on(t.kind),
}),
);
// ---------------------------------------------------------------------------
// Gift issuances (صرف الهدايا/الدروع)
// ---------------------------------------------------------------------------
export const protocolGiftIssuesTable = pgTable(
"protocol_gift_issues",
{
id: serial("id").primaryKey(),
giftId: integer("gift_id")
.notNull()
.references(() => protocolGiftsTable.id, { onDelete: "restrict" }),
recipientName: varchar("recipient_name", { length: 300 }).notNull(),
occasion: varchar("occasion", { length: 300 }),
quantity: integer("quantity").notNull().default(1),
issuedAt: timestamp("issued_at", { withTimezone: true }),
status: varchar("status", { length: 20 }).notNull().default("pending"),
notes: text("notes"),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
approvedBy: integer("approved_by").references(() => usersTable.id, {
onDelete: "set null",
}),
approvedAt: timestamp("approved_at", { withTimezone: true }),
rejectionReason: text("rejection_reason"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
giftIdx: index("protocol_gift_issues_gift_idx").on(t.giftId),
statusIdx: index("protocol_gift_issues_status_idx").on(t.status),
}),
);
// ---------------------------------------------------------------------------
// Visit photo albums (صور الزيارات) — "تصوير" tab.
//
// An album groups photographs of a visit. It may optionally be linked to an
// external protocol meeting (اللقاءات الخارجية); the link is `set null` so
// deleting the meeting never removes the album. The album cover is derived at
// query time from the first photo (by sortOrder), so there is no circular
// album <-> photo foreign key to keep in sync.
// ---------------------------------------------------------------------------
export const protocolPhotoAlbumsTable = pgTable(
"protocol_photo_albums",
{
id: serial("id").primaryKey(),
name: varchar("name", { length: 300 }).notNull(),
externalMeetingId: integer("external_meeting_id").references(
() => protocolExternalMeetingsTable.id,
{ onDelete: "set null" },
),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
meetingIdx: index("protocol_albums_meeting_idx").on(t.externalMeetingId),
}),
);
// ---------------------------------------------------------------------------
// Photos inside an album. Each photo references a private object-storage path
// (`/objects/<id>`) served through the authorized object endpoint. Deleting an
// album cascades to its photos.
// ---------------------------------------------------------------------------
export const protocolPhotosTable = pgTable(
"protocol_photos",
{
id: serial("id").primaryKey(),
albumId: integer("album_id")
.notNull()
.references(() => protocolPhotoAlbumsTable.id, { onDelete: "cascade" }),
objectPath: varchar("object_path", { length: 500 }).notNull(),
sortOrder: integer("sort_order").notNull().default(0),
uploadedBy: integer("uploaded_by").references(() => usersTable.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
albumIdx: index("protocol_photos_album_idx").on(t.albumId),
pathIdx: index("protocol_photos_path_idx").on(t.objectPath),
}),
);
// ---------------------------------------------------------------------------
// Audit log (scoped to the protocol module only)
// ---------------------------------------------------------------------------
export const protocolAuditLogsTable = pgTable(
"protocol_audit_logs",
{
id: serial("id").primaryKey(),
action: varchar("action", { length: 100 }).notNull(),
entityType: varchar("entity_type", { length: 50 }).notNull(),
entityId: integer("entity_id"),
oldValue: jsonb("old_value"),
newValue: jsonb("new_value"),
performedBy: integer("performed_by").references(() => usersTable.id, {
onDelete: "set null",
}),
performedAt: timestamp("performed_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
entityIdx: index("protocol_audit_entity_idx").on(t.entityType, t.entityId),
performedAtIdx: index("protocol_audit_performed_at_idx").on(t.performedAt),
}),
);
// ---------------------------------------------------------------------------
// Insert schemas / row types
// ---------------------------------------------------------------------------
export const insertProtocolRoomSchema = createInsertSchema(
protocolRoomsTable,
).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertProtocolRoom = z.infer<typeof insertProtocolRoomSchema>;
export type ProtocolRoom = typeof protocolRoomsTable.$inferSelect;
export const insertProtocolRoomBookingSchema = createInsertSchema(
protocolRoomBookingsTable,
).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertProtocolRoomBooking = z.infer<
typeof insertProtocolRoomBookingSchema
>;
export type ProtocolRoomBooking = typeof protocolRoomBookingsTable.$inferSelect;
export const insertProtocolExternalMeetingSchema = createInsertSchema(
protocolExternalMeetingsTable,
).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertProtocolExternalMeeting = z.infer<
typeof insertProtocolExternalMeetingSchema
>;
export type ProtocolExternalMeeting =
typeof protocolExternalMeetingsTable.$inferSelect;
export const insertProtocolGiftSchema = createInsertSchema(
protocolGiftsTable,
).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertProtocolGift = z.infer<typeof insertProtocolGiftSchema>;
export type ProtocolGift = typeof protocolGiftsTable.$inferSelect;
export const insertProtocolGiftIssueSchema = createInsertSchema(
protocolGiftIssuesTable,
).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertProtocolGiftIssue = z.infer<
typeof insertProtocolGiftIssueSchema
>;
export type ProtocolGiftIssue = typeof protocolGiftIssuesTable.$inferSelect;
export const insertProtocolPhotoAlbumSchema = createInsertSchema(
protocolPhotoAlbumsTable,
).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertProtocolPhotoAlbum = z.infer<
typeof insertProtocolPhotoAlbumSchema
>;
export type ProtocolPhotoAlbum = typeof protocolPhotoAlbumsTable.$inferSelect;
export const insertProtocolPhotoSchema = createInsertSchema(
protocolPhotosTable,
).omit({ id: true, createdAt: true });
export type InsertProtocolPhoto = z.infer<typeof insertProtocolPhotoSchema>;
export type ProtocolPhoto = typeof protocolPhotosTable.$inferSelect;
export type ProtocolAuditLog = typeof protocolAuditLogsTable.$inferSelect;
+378
View File
@@ -90,6 +90,9 @@ importers:
arabic-persian-reshaper:
specifier: 1.0.1
version: 1.0.1
archiver:
specifier: ^8.0.0
version: 8.0.0
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
@@ -148,6 +151,9 @@ importers:
specifier: 'catalog:'
version: 3.25.76
devDependencies:
'@types/archiver':
specifier: ^8.0.0
version: 8.0.0
'@types/bcryptjs':
specifier: ^3.0.0
version: 3.0.0
@@ -2434,6 +2440,7 @@ packages:
'@smithy/core@3.24.1':
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
engines: {node: '>=18.0.0'}
deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025
'@smithy/credential-provider-imds@4.3.1':
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
@@ -2888,6 +2895,9 @@ packages:
'@transloadit/prettier-bytes@0.3.5':
resolution: {integrity: sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==}
'@types/archiver@8.0.0':
resolution: {integrity: sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -2996,6 +3006,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/readdir-glob@1.1.5':
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
'@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
@@ -3101,6 +3114,10 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -3143,6 +3160,10 @@ packages:
arabic-persian-reshaper@1.0.1:
resolution: {integrity: sha512-VYBjkhz6o4W1Xt4mD2LAReljJpLSw5CUZMqSBDIQRvFgUSlTKEYghapgBWvkeMWF4W+KF3Fm+/z8EywJU4PBeg==}
archiver@8.0.0:
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
engines: {node: '>=18'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -3153,13 +3174,69 @@ packages:
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
b4a@1.8.1:
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
bare-events@2.9.1:
resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-fs@4.7.3:
resolution: {integrity: sha512-xRgplks8SvcKkdlv2M6Z2LZmRsmqd+x0nXXGXeMEjwdibj1HSDrlnqBRLeYdMvsgCox7Bq0e+DHwfczOfsn6IA==}
engines: {bare: '>=1.16.0'}
peerDependencies:
bare-buffer: '*'
peerDependenciesMeta:
bare-buffer:
optional: true
bare-os@3.9.3:
resolution: {integrity: sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==}
engines: {bare: '>=1.14.0'}
bare-path@3.0.1:
resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==}
bare-stream@2.13.3:
resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==}
peerDependencies:
bare-abort-controller: '*'
bare-buffer: '*'
bare-events: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-buffer:
optional: true
bare-events:
optional: true
bare-url@2.4.5:
resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==}
base64-js@0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'}
@@ -3196,6 +3273,10 @@ packages:
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
@@ -3211,12 +3292,19 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
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==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -3270,6 +3358,10 @@ packages:
compare-versions@6.1.1:
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
compress-commons@7.0.1:
resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==}
engines: {node: '>=18'}
connect-pg-simple@10.0.0:
resolution: {integrity: sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=22.0.0}
@@ -3303,10 +3395,22 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
crc32-stream@7.0.1:
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -3662,12 +3766,23 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
@@ -3699,6 +3814,9 @@ packages:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -3873,6 +3991,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
@@ -3937,6 +4058,9 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -3984,6 +4108,10 @@ packages:
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
lazystream@1.0.1:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
leven@4.1.0:
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -4145,6 +4273,10 @@ packages:
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -4201,6 +4333,10 @@ packages:
resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
npm-run-path@6.0.0:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'}
@@ -4417,9 +4553,16 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -4592,6 +4735,17 @@ packages:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdir-glob@3.0.0:
resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==}
engines: {node: '>=18'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -4610,6 +4764,7 @@ packages:
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: 19.1.0
react-dom: 19.1.0
@@ -4654,6 +4809,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -4771,10 +4929,19 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
streamx@2.28.0:
resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -4805,6 +4972,15 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tar-stream@3.2.0:
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
teex@1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
@@ -4829,6 +5005,7 @@ packages:
tsconfck@3.1.6:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
deprecated: unmaintained
hasBin: true
peerDependencies:
typescript: ^5.0.0
@@ -5058,6 +5235,10 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
zip-stream@7.0.5:
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
engines: {node: '>=18'}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -7417,6 +7598,11 @@ snapshots:
'@transloadit/prettier-bytes@0.3.5': {}
'@types/archiver@8.0.0':
dependencies:
'@types/node': 25.3.5
'@types/readdir-glob': 1.1.5
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.0
@@ -7548,6 +7734,10 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 25.3.5
'@types/retry@0.12.2': {}
'@types/sanitize-html@2.16.1':
@@ -7673,6 +7863,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -7708,6 +7902,22 @@ snapshots:
arabic-persian-reshaper@1.0.1: {}
archiver@8.0.0:
dependencies:
async: 3.2.6
buffer-crc32: 1.0.0
is-stream: 4.0.1
lazystream: 1.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
readdir-glob: 3.0.0
tar-stream: 3.2.0
zip-stream: 7.0.5
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
argparse@2.0.1: {}
aria-hidden@1.2.6:
@@ -7721,10 +7931,49 @@ snapshots:
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
async@3.2.6: {}
atomic-sleep@1.0.0: {}
b4a@1.8.1: {}
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
bare-events@2.9.1: {}
bare-fs@4.7.3:
dependencies:
bare-events: 2.9.1
bare-path: 3.0.1
bare-stream: 2.13.3(bare-events@2.9.1)
bare-url: 2.4.5
fast-fifo: 1.3.2
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
bare-os@3.9.3: {}
bare-path@3.0.1:
dependencies:
bare-os: 3.9.3
bare-stream@2.13.3(bare-events@2.9.1):
dependencies:
b4a: 1.8.1
streamx: 2.28.0
teex: 1.0.1
optionalDependencies:
bare-events: 2.9.1
transitivePeerDependencies:
- react-native-b4a
bare-url@2.4.5:
dependencies:
bare-path: 3.0.1
base64-js@0.0.8: {}
base64-js@1.5.1: {}
@@ -7761,6 +8010,10 @@ snapshots:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.7:
dependencies:
balanced-match: 4.0.4
braces@3.0.3:
dependencies:
fill-range: 7.1.1
@@ -7781,10 +8034,17 @@ snapshots:
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2:
@@ -7835,6 +8095,14 @@ snapshots:
compare-versions@6.1.1: {}
compress-commons@7.0.1:
dependencies:
crc-32: 1.2.2
crc32-stream: 7.0.1
is-stream: 4.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
connect-pg-simple@10.0.0:
dependencies:
pg: 8.20.0
@@ -7860,11 +8128,20 @@ snapshots:
cookie@0.7.2: {}
core-util-is@1.0.3: {}
cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
crc-32@1.2.2: {}
crc32-stream@7.0.1:
dependencies:
crc-32: 1.2.2
readable-stream: 4.7.0
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -8182,10 +8459,20 @@ snapshots:
etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {}
events-universal@1.0.1:
dependencies:
bare-events: 2.9.1
transitivePeerDependencies:
- bare-abort-controller
events@3.3.0: {}
execa@9.6.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
@@ -8260,6 +8547,8 @@ snapshots:
fast-equals@5.4.0: {}
fast-fifo@1.3.2: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -8452,6 +8741,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
ignore@7.0.5: {}
inherits@2.0.4: {}
@@ -8489,6 +8780,8 @@ snapshots:
is-unicode-supported@2.1.0: {}
isarray@1.0.0: {}
isexe@2.0.0: {}
jiti@2.6.1: {}
@@ -8528,6 +8821,10 @@ snapshots:
jwa: 2.0.1
safe-buffer: 5.2.1
lazystream@1.0.1:
dependencies:
readable-stream: 2.3.8
leven@4.1.0: {}
lightningcss-android-arm64@1.31.1:
@@ -8656,6 +8953,10 @@ snapshots:
minimalistic-assert@1.0.1: {}
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.7
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.2
@@ -8693,6 +8994,8 @@ snapshots:
nodemailer@8.0.7: {}
normalize-path@3.0.0: {}
npm-run-path@6.0.0:
dependencies:
path-key: 4.0.0
@@ -8935,8 +9238,12 @@ snapshots:
dependencies:
parse-ms: 4.0.0
process-nextick-args@2.0.1: {}
process-warning@5.0.0: {}
process@0.11.10: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -9132,6 +9439,28 @@ snapshots:
react@19.1.0: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
readdir-glob@3.0.0:
dependencies:
minimatch: 10.2.5
readdirp@4.1.2: {}
readdirp@5.0.0: {}
@@ -9216,6 +9545,8 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
safe-stable-stringify@2.5.0: {}
@@ -9369,8 +9700,25 @@ snapshots:
statuses@2.0.2: {}
streamx@2.28.0:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
string-argv@0.3.2: {}
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@@ -9391,6 +9739,30 @@ snapshots:
tapable@2.3.0: {}
tar-stream@3.2.0:
dependencies:
b4a: 1.8.1
bare-fs: 4.7.3
fast-fifo: 1.3.2
streamx: 2.28.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
teex@1.0.1:
dependencies:
streamx: 2.28.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
text-decoder@1.2.7:
dependencies:
b4a: 1.8.1
transitivePeerDependencies:
- react-native-b4a
thread-stream@3.1.0:
dependencies:
real-require: 0.2.0
@@ -9590,6 +9962,12 @@ snapshots:
yoctocolors@2.1.2: {}
zip-stream@7.0.5:
dependencies:
compress-commons: 7.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
zod@3.25.76: {}
zod@4.3.6: {}
+206 -1
View File
@@ -18,8 +18,9 @@ import {
executiveMeetingsTable,
executiveMeetingAttendeesTable,
executiveMeetingNotificationsTable,
protocolRoomsTable,
} from "@workspace/db";
import { eq, sql } from "drizzle-orm";
import { eq, inArray, sql } from "drizzle-orm";
import bcrypt from "bcryptjs";
async function main() {
@@ -327,6 +328,19 @@ async function main() {
isSystem: false,
sortOrder: 7,
},
{
slug: "protocol",
nameAr: "العلاقات العامة والمراسم",
nameEn: "Public Relations & Protocol",
descriptionAr: "حجز القاعات والاجتماعات الخارجية والهدايا والدروع",
descriptionEn: "Room bookings, external meetings, gifts and shields",
iconName: "Handshake",
route: "/protocol",
color: "#0ea5e9",
isActive: true,
isSystem: false,
sortOrder: 9,
},
];
// Drift guard: every built-in slug (those with hardcoded routes in the
@@ -345,6 +359,7 @@ async function main() {
"executive-meetings": "/meetings",
calendar: "/calendar",
documents: "/documents",
protocol: "/protocol",
};
for (const a of apps) {
if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) {
@@ -763,6 +778,196 @@ async function main() {
}
}
// Public Relations & Protocol: roles.
const protocolRoles = [
{
name: "protocol_super_admin",
descriptionAr: "المدير العام للعلاقات العامة والمراسم",
descriptionEn: "Protocol Super Admin",
isSystem: 1,
},
{
name: "protocol_pr_manager",
descriptionAr: "مدير العلاقات العامة",
descriptionEn: "PR Manager",
isSystem: 1,
},
{
name: "protocol_officer",
descriptionAr: "موظف المراسم",
descriptionEn: "Protocol Officer",
isSystem: 1,
},
{
name: "protocol_requester",
descriptionAr: "مقدم الطلب",
descriptionEn: "Requester",
isSystem: 1,
},
{
name: "protocol_viewer",
descriptionAr: "مشاهد العلاقات العامة والمراسم",
descriptionEn: "Protocol Viewer",
isSystem: 1,
},
];
await db.insert(rolesTable).values(protocolRoles).onConflictDoNothing();
console.log("Protocol roles created");
// Scoped protocol permissions. `protocol.access` also gates the home-screen
// tile (via app_permissions); the rest guard individual route capabilities.
// Route middleware authorizes by these permissions, never by role name, so
// an admin can delegate any capability by granting the permission.
const protocolPermissions = [
{
name: "protocol.access",
descriptionAr: "الوصول إلى وحدة العلاقات العامة والمراسم",
descriptionEn: "Access the Public Relations & Protocol module",
},
{
name: "protocol.request",
descriptionAr: "تقديم طلبات الحجز وإصدار الدروع",
descriptionEn: "Submit protocol booking and gift-issue requests",
},
{
name: "protocol.mutate",
descriptionAr: "إنشاء وتعديل سجلات العلاقات العامة والمراسم",
descriptionEn: "Create and edit protocol records",
},
{
name: "protocol.approve",
descriptionAr: "اعتماد أو رفض الحجوزات وإصدارات الدروع",
descriptionEn: "Approve or reject protocol bookings and gift issues",
},
{
name: "protocol.rooms.manage",
descriptionAr: "إدارة سجل القاعات",
descriptionEn: "Manage the protocol rooms registry",
},
{
name: "protocol.audit.read",
descriptionAr: "عرض سجل تدقيق العلاقات العامة والمراسم",
descriptionEn: "View the protocol audit log",
},
];
await db
.insert(permissionsTable)
.values(protocolPermissions)
.onConflictDoNothing();
// Map each protocol permission to the roles that should hold it. Capabilities
// are additive: super admin holds everything, viewer only reads.
const protocolRolePermMap: Record<string, string[]> = {
"protocol.access": [
"admin",
"protocol_super_admin",
"protocol_pr_manager",
"protocol_officer",
"protocol_requester",
"protocol_viewer",
],
"protocol.request": [
"admin",
"protocol_super_admin",
"protocol_pr_manager",
"protocol_officer",
"protocol_requester",
],
"protocol.mutate": [
"admin",
"protocol_super_admin",
"protocol_pr_manager",
"protocol_officer",
],
"protocol.approve": [
"admin",
"protocol_super_admin",
"protocol_pr_manager",
],
"protocol.rooms.manage": ["admin", "protocol_super_admin"],
"protocol.audit.read": [
"admin",
"protocol_super_admin",
"protocol_pr_manager",
],
};
const allRolesForProtocol = await db.select().from(rolesTable);
const roleIdByName = new Map(allRolesForProtocol.map((r) => [r.name, r.id]));
const allProtocolPerms = await db
.select()
.from(permissionsTable)
.where(
inArray(
permissionsTable.name,
protocolPermissions.map((p) => p.name),
),
);
const permIdByName = new Map(allProtocolPerms.map((p) => [p.name, p.id]));
const rolePermValues: { roleId: number; permissionId: number }[] = [];
for (const [permName, roleNames] of Object.entries(protocolRolePermMap)) {
const permId = permIdByName.get(permName);
if (permId === undefined) continue;
for (const roleName of roleNames) {
const roleId = roleIdByName.get(roleName);
if (roleId === undefined) continue;
rolePermValues.push({ roleId, permissionId: permId });
}
}
if (rolePermValues.length > 0) {
await db
.insert(rolePermissionsTable)
.values(rolePermValues)
.onConflictDoNothing();
}
console.log("Protocol scoped permissions mapped to roles");
const protocolAccessPermId = permIdByName.get("protocol.access");
if (protocolAccessPermId !== undefined) {
const [protocolApp] = await db
.select()
.from(appsTable)
.where(eq(appsTable.slug, "protocol"));
if (protocolApp) {
await db
.insert(appPermissionsTable)
.values({ appId: protocolApp.id, permissionId: protocolAccessPermId })
.onConflictDoNothing();
console.log(
"App permissions set: protocol app restricted to protocol.access permission",
);
}
}
// Public Relations & Protocol: three default meeting rooms. Only seeded
// when the table is empty so admins can rename/remove them freely without
// re-seeds bringing them back.
const existingRooms = await db
.select({ id: protocolRoomsTable.id })
.from(protocolRoomsTable)
.limit(1);
if (existingRooms.length === 0) {
await db.insert(protocolRoomsTable).values([
{
nameAr: "قاعة الاجتماعات الرئيسية",
nameEn: "Main Meeting Hall",
sortOrder: 1,
},
{
nameAr: "قاعة كبار الزوار",
nameEn: "VIP Guests Hall",
sortOrder: 2,
},
{
nameAr: "قاعة الاجتماعات التنفيذية",
nameEn: "Executive Meetings Hall",
sortOrder: 3,
},
]);
console.log("Protocol default rooms created");
}
// Executive meetings: sample data for today (idempotent per-date).
//
// Opt-in only — a vanilla self-hosted install should start with an
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# One-command helper: merge the Gitea server's changes into this Repl's work and push.
# Run from the Replit Shell with: bash sync-to-gitea.sh
set -uo pipefail
REMOTE="origin"
BRANCH="main"
echo "==> Checking GITEA_TOKEN secret is available..."
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "ERROR: GITEA_TOKEN is not set in this shell. Cannot authenticate to Gitea."
exit 1
fi
echo "==> Preparing credential helper (token never printed)..."
cat > /tmp/ap.sh <<'EOF'
#!/usr/bin/env bash
case "$1" in
*Username*) echo "rafraa" ;;
*) printf '%s' "$GITEA_TOKEN" ;;
esac
EOF
chmod +x /tmp/ap.sh
export GIT_ASKPASS=/tmp/ap.sh GIT_TERMINAL_PROMPT=0
echo "==> Current branch:"
git rev-parse --abbrev-ref HEAD
echo "==> Fetching $REMOTE/$BRANCH ..."
if ! git fetch "$REMOTE" "$BRANCH"; then
echo "ERROR: fetch failed. The Gitea server may be unreachable right now. Try again in a moment."
exit 1
fi
echo "==> Merging $REMOTE/$BRANCH into local $BRANCH (keeping both sides)..."
if ! git merge "$REMOTE/$BRANCH" -m "Merge Gitea server changes with Replit work"; then
echo ""
echo "MERGE CONFLICT: the merge could not finish automatically."
echo "Nothing was pushed. Copy everything above and send it to the assistant to resolve."
exit 1
fi
echo "==> Pushing local $BRANCH to $REMOTE ..."
if ! git push "$REMOTE" "$BRANCH"; then
echo "ERROR: push failed. Copy everything above and send it to the assistant."
exit 1
fi
echo ""
echo "==> SUCCESS. Verifying remote now matches local..."
LOCAL=$(git rev-parse "$BRANCH")
REMOTE_SHA=$(git ls-remote "$REMOTE" -h "refs/heads/$BRANCH" | awk '{print $1}')
echo " local $BRANCH = $LOCAL"
echo " server $BRANCH = $REMOTE_SHA"
if [ "$LOCAL" = "$REMOTE_SHA" ]; then
echo " MATCH — your work is now on Gitea."
else
echo " WARNING: they do not match yet. Send this output to the assistant."
fi