43 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
29 changed files with 7823 additions and 26 deletions
+2
View File
@@ -20,6 +20,7 @@
"@workspace/api-zod": "workspace:*", "@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*", "@workspace/db": "workspace:*",
"arabic-persian-reshaper": "1.0.1", "arabic-persian-reshaper": "1.0.1",
"archiver": "^8.0.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"bidi-js": "^1.0.3", "bidi-js": "^1.0.3",
"connect-pg-simple": "^10.0.0", "connect-pg-simple": "^10.0.0",
@@ -41,6 +42,7 @@
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@types/archiver": "^8.0.0",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/connect-pg-simple": "^7.0.3", "@types/connect-pg-simple": "^7.0.3",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
@@ -222,7 +222,7 @@ export async function broadcastExecutiveMeetingNotifications(
type: "executive_meeting", type: "executive_meeting",
relatedId: meetingId, relatedId: meetingId,
tag: meetingId ? `meeting-${meetingId}-${notificationType}` : `meeting-${notificationType}`, tag: meetingId ? `meeting-${meetingId}-${notificationType}` : `meeting-${notificationType}`,
url: "/meetings", url: meetingId ? `/meetings?meeting=${meetingId}` : "/meetings",
}); });
} }
io.emit("executive_meeting_notifications_changed", { io.emit("executive_meeting_notifications_changed", {
@@ -43,6 +43,16 @@ function stripHtmlToPlainText(input: string | null | undefined): string {
return decoded.replace(/\s+/g, " ").trim(); 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 { function dateKey(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
} }
@@ -161,7 +171,7 @@ async function tickOnce(): Promise<void> {
stripHtmlToPlainText(meeting.titleEn) || stripHtmlToPlainText(meeting.titleEn) ||
stripHtmlToPlainText(meeting.titleAr) || stripHtmlToPlainText(meeting.titleAr) ||
"Meeting"; "Meeting";
const bodyAr = `${subjectAr} يبدأ خلال ${deltaMin} دقيقة`; const bodyAr = `${subjectAr} يبدأ ${minutesAfterAr(deltaMin)}`;
const bodyEn = `${subjectEn} starts in ${deltaMin} min`; const bodyEn = `${subjectEn} starts in ${deltaMin} min`;
for (const { userId } of claimed) { for (const { userId } of claimed) {
@@ -175,7 +185,7 @@ async function tickOnce(): Promise<void> {
bodyAr, bodyAr,
bodyEn, bodyEn,
tag: `em-reminder-${meeting.id}`, tag: `em-reminder-${meeting.id}`,
url: "/meetings", url: `/meetings?meeting=${meeting.id}`,
type: "executive_meeting", type: "executive_meeting",
relatedId: meeting.id, relatedId: meeting.id,
}, },
+14 -1
View File
@@ -5,10 +5,11 @@ import {
servicesTable, servicesTable,
executiveMeetingFontSettingsTable, executiveMeetingFontSettingsTable,
executiveMeetingPdfArchivesTable, executiveMeetingPdfArchivesTable,
protocolPhotosTable,
rolesTable, rolesTable,
} from "@workspace/db"; } from "@workspace/db";
import { eq, inArray, sql } from "drizzle-orm"; import { eq, inArray, sql } from "drizzle-orm";
import { getEffectiveRoleIds } from "../middlewares/auth"; import { getEffectiveRoleIds, userHasPermission } from "../middlewares/auth";
import { getVisibleAppsForUser } from "./appsVisibility"; import { getVisibleAppsForUser } from "./appsVisibility";
// Roles that gate read access to the Executive Meetings module. // 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 ?? []; const rows = (meeting as unknown as { rows?: unknown[] }).rows ?? [];
if (rows.length > 0) return hasExecutiveAccess; 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 // Orphan path — no entity references it. Treat as not-found for
// every role (including admin). This is what stops object // every role (including admin). This is what stops object
// enumeration: even with admin credentials, you cannot probe // 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 // Driver interface
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -253,3 +253,36 @@ export async function requireExecutiveAccess(
next(); 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();
}
+2
View File
@@ -16,6 +16,7 @@ import groupsRouter from "./groups";
import rolesRouter from "./roles"; import rolesRouter from "./roles";
import auditRouter from "./audit"; import auditRouter from "./audit";
import executiveMeetingsRouter from "./executive-meetings"; import executiveMeetingsRouter from "./executive-meetings";
import protocolRouter from "./protocol";
import pushRouter from "./push"; import pushRouter from "./push";
import systemRouter from "./system"; import systemRouter from "./system";
@@ -38,6 +39,7 @@ router.use(groupsRouter);
router.use(rolesRouter); router.use(rolesRouter);
router.use(auditRouter); router.use(auditRouter);
router.use(executiveMeetingsRouter); router.use(executiveMeetingsRouter);
router.use(protocolRouter);
router.use(pushRouter); router.use(pushRouter);
router.use(systemRouter); router.use(systemRouter);
+6 -2
View File
@@ -1130,6 +1130,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
bodyAr: `${senderNameAr} أرسل لك ملاحظة`, bodyAr: `${senderNameAr} أرسل لك ملاحظة`,
bodyEn: `${senderName} sent you a note`, bodyEn: `${senderName} sent you a note`,
type: "note", type: "note",
relatedType: "note",
relatedId: note.id,
}) })
.returning(); .returning();
await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" }); 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", type: "note",
relatedId: note.id, relatedId: note.id,
tag: `note-${note.id}`, tag: `note-${note.id}`,
url: "/notes", url: `/notes?thread=${note.id}`,
}); });
await emitToUser(r.recipientUserId, "note_received", { await emitToUser(r.recipientUserId, "note_received", {
noteId: note.id, noteId: note.id,
@@ -1488,6 +1490,8 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
bodyAr: `${replierNameAr} رد على ملاحظة`, bodyAr: `${replierNameAr} رد على ملاحظة`,
bodyEn: `${replierName} replied on a note`, bodyEn: `${replierName} replied on a note`,
type: "note", type: "note",
relatedType: "note",
relatedId: id.id,
}) })
.returning(); .returning();
await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" }); 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", type: "note",
relatedId: id.id, relatedId: id.id,
tag: `note-reply-${reply.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 // Enrich the realtime payload so the recipient's client can render the
// floating reply card without an extra round-trip. We truncate the body // 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, bodyEn: string,
orderId: number, orderId: number,
actorId?: 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. // Never notify users about actions they themselves initiated.
if (actorId !== undefined && actorId === userId) return; 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 const [notification] = await db
.insert(notificationsTable) .insert(notificationsTable)
.values({ .values({
@@ -130,7 +140,7 @@ async function notifyUser(
bodyEn, bodyEn,
type: "order", type: "order",
relatedId: orderId, relatedId: orderId,
relatedType: "order", relatedType,
}) })
.returning(); .returning();
await emitToUser(userId, "notification_created", notification); await emitToUser(userId, "notification_created", notification);
@@ -154,7 +164,7 @@ async function notifyUser(
type: "order", type: "order",
relatedId: orderId, relatedId: orderId,
tag: `order-${orderId}`, tag: `order-${orderId}`,
url: "/my-orders", url: deepLinkUrl,
}, },
{ ignoreConnected: true }, { ignoreConnected: true },
); );
@@ -209,6 +219,7 @@ router.post("/orders", requireAuth, async (req, res): Promise<void> => {
service.nameEn, service.nameEn,
order.id, order.id,
userId, userId,
"incoming",
); );
} }
await broadcastIncomingChanged(receivers); await broadcastIncomingChanged(receivers);
+33 -1
View File
@@ -4,7 +4,11 @@ import {
RequestUploadUrlBody, RequestUploadUrlBody,
RequestUploadUrlResponse, RequestUploadUrlResponse,
} from "@workspace/api-zod"; } from "@workspace/api-zod";
import { ObjectStorageService, ObjectNotFoundError } from "../lib/objectStorage"; import {
ObjectStorageService,
ObjectNotFoundError,
extensionForContentType,
} from "../lib/objectStorage";
import { canUserReadObjectPath } from "../lib/objectAuthz"; import { canUserReadObjectPath } from "../lib/objectAuthz";
import { requireAuth } from "../middlewares/auth"; import { requireAuth } from "../middlewares/auth";
@@ -109,6 +113,34 @@ router.get("/storage/objects/*path", requireAuth, async (req: Request, res: Resp
res.status(response.status); res.status(response.status);
response.headers.forEach((value, key) => res.setHeader(key, value)); 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) { if (response.body) {
const nodeStream = Readable.fromWeb(response.body as ReadableStream<Uint8Array>); const nodeStream = Readable.fromWeb(response.body as ReadableStream<Uint8Array>);
nodeStream.pipe(res); 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 // Strip a leading slash from `relative` so URL() treats it as a
// path under scope rather than origin-relative. // path under scope rather than origin-relative.
const cleaned = String(relative || "").replace(/^\/+/, ""); 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", () => { self.addEventListener("install", () => {
+14 -1
View File
@@ -27,6 +27,8 @@ import NotesPage from "@/pages/notes";
import MyOrdersPage from "@/pages/my-orders"; import MyOrdersPage from "@/pages/my-orders";
import OrdersIncomingPage from "@/pages/orders-incoming"; import OrdersIncomingPage from "@/pages/orders-incoming";
import ExecutiveMeetingsPage from "@/pages/executive-meetings"; import ExecutiveMeetingsPage from "@/pages/executive-meetings";
import ProtocolPage from "@/pages/protocol";
import ProtocolRequestPage from "@/pages/protocol-request";
import EmbeddedAppPage from "@/pages/embedded-app"; import EmbeddedAppPage from "@/pages/embedded-app";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -73,6 +75,8 @@ function Router() {
<Route path="/my-orders" component={MyOrdersPage} /> <Route path="/my-orders" component={MyOrdersPage} />
<Route path="/orders/incoming" component={OrdersIncomingPage} /> <Route path="/orders/incoming" component={OrdersIncomingPage} />
<Route path="/meetings" component={ExecutiveMeetingsPage} /> <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 {/* Back-compat redirect: the page used to live at
/executive-meetings. Stored PDFs, email links, and bookmarks /executive-meetings. Stored PDFs, email links, and bookmarks
still point there, so catch the old path (and any deep still point there, so catch the old path (and any deep
@@ -94,7 +98,16 @@ function App() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider> <TooltipProvider>
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}> <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> </WouterRouter>
<Toaster /> <Toaster />
</TooltipProvider> </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>
);
}
+162 -1
View File
@@ -971,7 +971,9 @@
"appName": "نظام Tx", "appName": "نظام Tx",
"previous": "السابق", "previous": "السابق",
"next": "التالي", "next": "التالي",
"empty": "(فارغ)" "empty": "(فارغ)",
"back": "رجوع",
"confirmDelete": "تأكيد الحذف"
}, },
"embeddedFrame": { "embeddedFrame": {
"title": "تطبيق مدمج", "title": "تطبيق مدمج",
@@ -1656,5 +1658,164 @@
"saved": "تم الحفظ", "saved": "تم الحفظ",
"reset": "إعادة الافتراضي" "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": "لا يوجد نشاط."
}
} }
} }
+162 -1
View File
@@ -883,7 +883,9 @@
"appName": "Tx OS", "appName": "Tx OS",
"previous": "Previous", "previous": "Previous",
"next": "Next", "next": "Next",
"empty": "(empty)" "empty": "(empty)",
"back": "Back",
"confirmDelete": "Confirm delete"
}, },
"embeddedFrame": { "embeddedFrame": {
"title": "Embedded app", "title": "Embedded app",
@@ -1529,5 +1531,164 @@
"saved": "Saved", "saved": "Saved",
"reset": "Reset to default" "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."
}
} }
} }
@@ -13,7 +13,7 @@ import {
} from "react"; } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "wouter"; import { useLocation, useSearch } from "wouter";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowLeft, ArrowLeft,
@@ -2450,6 +2450,84 @@ function ScheduleSection({
}; };
}, [highlightEditRowId, meetings]); }, [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 // #489: Drag-from-anywhere on the row rotates meeting CONTENT through
// the day's fixed time slots. The (startTime, endTime, dailyNumber) // the day's fixed time slots. The (startTime, endTime, dailyNumber)
// tuple of each chronological slot stays anchored to its physical // tuple of each chronological slot stays anchored to its physical
@@ -4559,7 +4637,7 @@ function MeetingRow({
return ( return (
<td <td
key="meeting" 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)} style={tintedCellStyle(col, true)}
> >
<EditableCell <EditableCell
@@ -4567,14 +4645,20 @@ function MeetingRow({
onSave={onSaveTitle} onSave={onSaveTitle}
ariaLabel={t("executiveMeetings.editMeetingTitle")} ariaLabel={t("executiveMeetings.editMeetingTitle")}
dir={isRtl ? "rtl" : "ltr"} 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} disabled={!canMutate}
testId={`em-edit-title-${meeting.id}`} testId={`em-edit-title-${meeting.id}`}
startInEditMode={autoEditTitle} startInEditMode={autoEditTitle}
onStartedEditing={onAutoEditTitleConsumed} onStartedEditing={onAutoEditTitleConsumed}
/> />
{meeting.location && ( {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} {meeting.location}
</div> </div>
)} )}
@@ -4586,7 +4670,7 @@ function MeetingRow({
return ( return (
<td <td
key="attendees" 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)} style={tintedCellStyle(col, false)}
> >
<AttendeesCell <AttendeesCell
@@ -4671,7 +4755,7 @@ function MeetingRow({
}} }}
ariaLabel={t("executiveMeetings.merge.editLabel")} ariaLabel={t("executiveMeetings.merge.editLabel")}
dir={isRtl ? "rtl" : "ltr"} 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} disabled={!canMutate}
placeholder={t("executiveMeetings.merge.placeholder")} placeholder={t("executiveMeetings.merge.placeholder")}
testId={`em-merge-edit-${meeting.id}`} testId={`em-merge-edit-${meeting.id}`}
@@ -5878,7 +5962,7 @@ function AttendeeFlow({
} }
if (items.length === 0 && !showAdd && !isPending) { 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 // Locate the listIdx where the inline pending ghost should appear, when
@@ -6038,7 +6122,7 @@ function AttendeeFlow({
}; };
return ( 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) => { {items.flatMap(({ a, i }, listIdx) => {
const isSub = (a.kind ?? "person") === "subheading"; const isSub = (a.kind ?? "person") === "subheading";
const nodes: ReactNode[] = []; const nodes: ReactNode[] = [];
+58 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "wouter"; import { useLocation, useSearch } from "wouter";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { import {
useListMyServiceOrders, useListMyServiceOrders,
@@ -159,10 +159,12 @@ function OrderCard({
order, order,
onDelete, onDelete,
onReorder, onReorder,
highlighted = false,
}: { }: {
order: ServiceOrder; order: ServiceOrder;
onDelete: (id: number) => void; onDelete: (id: number) => void;
onReorder: (order: ServiceOrder) => void; onReorder: (order: ServiceOrder) => void;
highlighted?: boolean;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -236,7 +238,13 @@ function OrderCard({
}; };
return ( 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"> <div className="flex items-start gap-3">
<OrderImage src={img} alt={name} /> <OrderImage src={img} alt={name} />
<div className="flex-1 min-w-0"> <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 ( return (
<div className="min-h-screen os-bg flex flex-col"> <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"> <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 <OrderCard
key={o.id} key={o.id}
order={o} order={o}
highlighted={o.id === highlightOrderId}
onDelete={(id) => scheduleDelete([id])} onDelete={(id) => scheduleDelete([id])}
onReorder={(order) => onReorder={(order) =>
setReorderTarget({ setReorderTarget({
@@ -14,6 +14,34 @@ import { AppDock } from "@/components/app-dock";
import { formatDateTime } from "@/lib/i18n-format"; import { formatDateTime } from "@/lib/i18n-format";
import { useAuth } from "@/contexts/AuthContext"; 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() { export default function NotificationsPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
@@ -53,10 +81,15 @@ export default function NotificationsPage() {
const handleNotificationClick = (n: { const handleNotificationClick = (n: {
id: number; id: number;
isRead: boolean; isRead: boolean;
type: string;
relatedType?: string | null;
relatedId?: number | null;
}) => { }) => {
if (!n.isRead) { if (!n.isRead) {
handleMarkOne(n.id); handleMarkOne(n.id);
} }
const dest = notificationDestination(n);
if (dest) setLocation(dest);
}; };
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? 0; 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 { useTranslation } from "react-i18next";
import { useLocation } from "wouter"; import { useLocation, useSearch } from "wouter";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { import {
useListIncomingServiceOrders, useListIncomingServiceOrders,
@@ -105,6 +105,7 @@ function IncomingOrderCard({
selected, selected,
onToggleSelected, onToggleSelected,
onRequestDelete, onRequestDelete,
highlighted = false,
}: { }: {
order: ServiceOrder; order: ServiceOrder;
meId: number; meId: number;
@@ -112,6 +113,7 @@ function IncomingOrderCard({
selected: boolean; selected: boolean;
onToggleSelected: (id: number) => void; onToggleSelected: (id: number) => void;
onRequestDelete: (id: number) => void; onRequestDelete: (id: number) => void;
highlighted?: boolean;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
@@ -279,6 +281,7 @@ function IncomingOrderCard({
className={cn( className={cn(
"glass-panel rounded-xl p-3 flex flex-col gap-3 transition-colors", "glass-panel rounded-xl p-3 flex flex-col gap-3 transition-colors",
selected && "ring-2 ring-amber-400 bg-amber-50/40", 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}`} data-testid={`incoming-order-card-${order.id}`}
> >
@@ -406,6 +409,55 @@ export default function OrdersIncomingPage() {
[mineIds, unclaimedIds], [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()); const [selected, setSelected] = useState<Set<number>>(new Set());
// confirmIds === null → no dialog; otherwise the ids that confirming will delete. // confirmIds === null → no dialog; otherwise the ids that confirming will delete.
const [confirmIds, setConfirmIds] = useState<number[] | null>(null); const [confirmIds, setConfirmIds] = useState<number[] | null>(null);
@@ -610,6 +662,7 @@ export default function OrdersIncomingPage() {
key={o.id} key={o.id}
order={o} order={o}
meId={meId} meId={meId}
highlighted={o.id === highlightOrderId}
onActionDone={refetch} onActionDone={refetch}
selected={validSelected.has(o.id)} selected={validSelected.has(o.id)}
onToggleSelected={toggleOne} onToggleSelected={toggleOne}
@@ -649,6 +702,7 @@ export default function OrdersIncomingPage() {
key={o.id} key={o.id}
order={o} order={o}
meId={meId} meId={meId}
highlighted={o.id === highlightOrderId}
onActionDone={refetch} onActionDone={refetch}
selected={validSelected.has(o.id)} selected={validSelected.has(o.id)}
onToggleSelected={toggleOne} 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
+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"); await client.query("COMMIT");
} catch (err) { } catch (err) {
await client.query("ROLLBACK").catch(() => {}); await client.query("ROLLBACK").catch(() => {});
+1
View File
@@ -27,6 +27,7 @@ export const BUILTIN_APP_SLUGS = [
"executive-meetings", "executive-meetings",
"calendar", "calendar",
"documents", "documents",
"protocol",
] as const; ] as const;
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number]; export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];
+1
View File
@@ -14,4 +14,5 @@ export * from "./audit-logs";
export * from "./role-audit"; export * from "./role-audit";
export * from "./permission-audit"; export * from "./permission-audit";
export * from "./executive-meetings"; export * from "./executive-meetings";
export * from "./protocol";
export * from "./push-subscriptions"; 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: arabic-persian-reshaper:
specifier: 1.0.1 specifier: 1.0.1
version: 1.0.1 version: 1.0.1
archiver:
specifier: ^8.0.0
version: 8.0.0
bcryptjs: bcryptjs:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@@ -148,6 +151,9 @@ importers:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@types/archiver':
specifier: ^8.0.0
version: 8.0.0
'@types/bcryptjs': '@types/bcryptjs':
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
@@ -2434,6 +2440,7 @@ packages:
'@smithy/core@3.24.1': '@smithy/core@3.24.1':
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==} resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
engines: {node: '>=18.0.0'} 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': '@smithy/credential-provider-imds@4.3.1':
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==} resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
@@ -2888,6 +2895,9 @@ packages:
'@transloadit/prettier-bytes@0.3.5': '@transloadit/prettier-bytes@0.3.5':
resolution: {integrity: sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==} 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': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -2996,6 +3006,9 @@ packages:
'@types/react@19.2.14': '@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} 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': '@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
@@ -3101,6 +3114,10 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 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: accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3143,6 +3160,10 @@ packages:
arabic-persian-reshaper@1.0.1: arabic-persian-reshaper@1.0.1:
resolution: {integrity: sha512-VYBjkhz6o4W1Xt4mD2LAReljJpLSw5CUZMqSBDIQRvFgUSlTKEYghapgBWvkeMWF4W+KF3Fm+/z8EywJU4PBeg==} resolution: {integrity: sha512-VYBjkhz6o4W1Xt4mD2LAReljJpLSw5CUZMqSBDIQRvFgUSlTKEYghapgBWvkeMWF4W+KF3Fm+/z8EywJU4PBeg==}
archiver@8.0.0:
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
engines: {node: '>=18'}
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -3153,13 +3174,69 @@ packages:
asn1.js@5.4.1: asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
atomic-sleep@1.0.0: atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'} 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: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} 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: base64-js@0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3196,6 +3273,10 @@ packages:
brace-expansion@2.0.2: brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} 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: braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3211,12 +3292,19 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true 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: buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -3270,6 +3358,10 @@ packages:
compare-versions@6.1.1: compare-versions@6.1.1:
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} 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: connect-pg-simple@10.0.0:
resolution: {integrity: sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==} resolution: {integrity: sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=22.0.0} engines: {node: ^18.18.0 || ^20.9.0 || >=22.0.0}
@@ -3303,10 +3395,22 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cors@2.8.6: cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'} 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: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3662,12 +3766,23 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} 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: eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.4: eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} 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: execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0} engines: {node: ^18.19.0 || >=20.5.0}
@@ -3699,6 +3814,9 @@ packages:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-glob@3.3.3: fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'} engines: {node: '>=8.6.0'}
@@ -3873,6 +3991,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@7.0.5: ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -3937,6 +4058,9 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -3984,6 +4108,10 @@ packages:
jws@4.0.1: jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} 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: leven@4.1.0:
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==} resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -4145,6 +4273,10 @@ packages:
minimalistic-assert@1.0.1: minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} 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: minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
@@ -4201,6 +4333,10 @@ packages:
resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==} resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==}
engines: {node: '>=6.0.0'} 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: npm-run-path@6.0.0:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4417,9 +4553,16 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
process-warning@5.0.0: process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} 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: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -4592,6 +4735,17 @@ packages:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'} 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: readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
@@ -4610,6 +4764,7 @@ packages:
recharts@2.15.4: recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'} 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: peerDependencies:
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0 react-dom: 19.1.0
@@ -4654,6 +4809,9 @@ packages:
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1: safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -4771,10 +4929,19 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
streamx@2.28.0:
resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==}
string-argv@0.3.2: string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'} 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: strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4805,6 +4972,15 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'} 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: thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
@@ -4829,6 +5005,7 @@ packages:
tsconfck@3.1.6: tsconfck@3.1.6:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20} engines: {node: ^18 || >=20}
deprecated: unmaintained
hasBin: true hasBin: true
peerDependencies: peerDependencies:
typescript: ^5.0.0 typescript: ^5.0.0
@@ -5058,6 +5235,10 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'} engines: {node: '>=18'}
zip-stream@7.0.5:
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
engines: {node: '>=18'}
zod@3.25.76: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -7417,6 +7598,11 @@ snapshots:
'@transloadit/prettier-bytes@0.3.5': {} '@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': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.29.0 '@babel/parser': 7.29.0
@@ -7548,6 +7734,10 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 25.3.5
'@types/retry@0.12.2': {} '@types/retry@0.12.2': {}
'@types/sanitize-html@2.16.1': '@types/sanitize-html@2.16.1':
@@ -7673,6 +7863,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@1.3.8: accepts@1.3.8:
dependencies: dependencies:
mime-types: 2.1.35 mime-types: 2.1.35
@@ -7708,6 +7902,22 @@ snapshots:
arabic-persian-reshaper@1.0.1: {} 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: {} argparse@2.0.1: {}
aria-hidden@1.2.6: aria-hidden@1.2.6:
@@ -7721,10 +7931,49 @@ snapshots:
minimalistic-assert: 1.0.1 minimalistic-assert: 1.0.1
safer-buffer: 2.1.2 safer-buffer: 2.1.2
async@3.2.6: {}
atomic-sleep@1.0.0: {} atomic-sleep@1.0.0: {}
b4a@1.8.1: {}
balanced-match@1.0.2: {} 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@0.0.8: {}
base64-js@1.5.1: {} base64-js@1.5.1: {}
@@ -7761,6 +8010,10 @@ snapshots:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
brace-expansion@5.0.7:
dependencies:
balanced-match: 4.0.4
braces@3.0.3: braces@3.0.3:
dependencies: dependencies:
fill-range: 7.1.1 fill-range: 7.1.1
@@ -7781,10 +8034,17 @@ snapshots:
node-releases: 2.0.36 node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1) update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {} buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bytes@3.1.2: {} bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
@@ -7835,6 +8095,14 @@ snapshots:
compare-versions@6.1.1: {} 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: connect-pg-simple@10.0.0:
dependencies: dependencies:
pg: 8.20.0 pg: 8.20.0
@@ -7860,11 +8128,20 @@ snapshots:
cookie@0.7.2: {} cookie@0.7.2: {}
core-util-is@1.0.3: {}
cors@2.8.6: cors@2.8.6:
dependencies: dependencies:
object-assign: 4.1.1 object-assign: 4.1.1
vary: 1.1.2 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: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -8182,10 +8459,20 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {} eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {} 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: execa@9.6.1:
dependencies: dependencies:
'@sindresorhus/merge-streams': 4.0.0 '@sindresorhus/merge-streams': 4.0.0
@@ -8260,6 +8547,8 @@ snapshots:
fast-equals@5.4.0: {} fast-equals@5.4.0: {}
fast-fifo@1.3.2: {}
fast-glob@3.3.3: fast-glob@3.3.3:
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -8452,6 +8741,8 @@ snapshots:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
ieee754@1.2.1: {}
ignore@7.0.5: {} ignore@7.0.5: {}
inherits@2.0.4: {} inherits@2.0.4: {}
@@ -8489,6 +8780,8 @@ snapshots:
is-unicode-supported@2.1.0: {} is-unicode-supported@2.1.0: {}
isarray@1.0.0: {}
isexe@2.0.0: {} isexe@2.0.0: {}
jiti@2.6.1: {} jiti@2.6.1: {}
@@ -8528,6 +8821,10 @@ snapshots:
jwa: 2.0.1 jwa: 2.0.1
safe-buffer: 5.2.1 safe-buffer: 5.2.1
lazystream@1.0.1:
dependencies:
readable-stream: 2.3.8
leven@4.1.0: {} leven@4.1.0: {}
lightningcss-android-arm64@1.31.1: lightningcss-android-arm64@1.31.1:
@@ -8656,6 +8953,10 @@ snapshots:
minimalistic-assert@1.0.1: {} minimalistic-assert@1.0.1: {}
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.7
minimatch@9.0.9: minimatch@9.0.9:
dependencies: dependencies:
brace-expansion: 2.0.2 brace-expansion: 2.0.2
@@ -8693,6 +8994,8 @@ snapshots:
nodemailer@8.0.7: {} nodemailer@8.0.7: {}
normalize-path@3.0.0: {}
npm-run-path@6.0.0: npm-run-path@6.0.0:
dependencies: dependencies:
path-key: 4.0.0 path-key: 4.0.0
@@ -8935,8 +9238,12 @@ snapshots:
dependencies: dependencies:
parse-ms: 4.0.0 parse-ms: 4.0.0
process-nextick-args@2.0.1: {}
process-warning@5.0.0: {} process-warning@5.0.0: {}
process@0.11.10: {}
prop-types@15.8.1: prop-types@15.8.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -9132,6 +9439,28 @@ snapshots:
react@19.1.0: {} 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@4.1.2: {}
readdirp@5.0.0: {} readdirp@5.0.0: {}
@@ -9216,6 +9545,8 @@ snapshots:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {} safe-buffer@5.2.1: {}
safe-stable-stringify@2.5.0: {} safe-stable-stringify@2.5.0: {}
@@ -9369,8 +9700,25 @@ snapshots:
statuses@2.0.2: {} 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-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: strip-ansi@6.0.1:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
@@ -9391,6 +9739,30 @@ snapshots:
tapable@2.3.0: {} 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: thread-stream@3.1.0:
dependencies: dependencies:
real-require: 0.2.0 real-require: 0.2.0
@@ -9590,6 +9962,12 @@ snapshots:
yoctocolors@2.1.2: {} 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@3.25.76: {}
zod@4.3.6: {} zod@4.3.6: {}
+206 -1
View File
@@ -18,8 +18,9 @@ import {
executiveMeetingsTable, executiveMeetingsTable,
executiveMeetingAttendeesTable, executiveMeetingAttendeesTable,
executiveMeetingNotificationsTable, executiveMeetingNotificationsTable,
protocolRoomsTable,
} from "@workspace/db"; } from "@workspace/db";
import { eq, sql } from "drizzle-orm"; import { eq, inArray, sql } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
async function main() { async function main() {
@@ -327,6 +328,19 @@ async function main() {
isSystem: false, isSystem: false,
sortOrder: 7, 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 // Drift guard: every built-in slug (those with hardcoded routes in the
@@ -345,6 +359,7 @@ async function main() {
"executive-meetings": "/meetings", "executive-meetings": "/meetings",
calendar: "/calendar", calendar: "/calendar",
documents: "/documents", documents: "/documents",
protocol: "/protocol",
}; };
for (const a of apps) { for (const a of apps) {
if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) { 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). // Executive meetings: sample data for today (idempotent per-date).
// //
// Opt-in only — a vanilla self-hosted install should start with an // 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