Implement optimistic locking for meeting postponements to prevent concurrent edits. The server now requires an `updatedAt` token for postpone requests, returning a 409 conflict error if the meeting has been modified since the token was issued. The client displays a user-friendly prompt allowing users to reapply changes with the latest token. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 1abdc3d2-c834-4a37-b104-397852e4732a Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/8gwn7Xm Replit-Helium-Checkpoint-Created: true
12 KiB
Tx OS
Overview
Tx OS — a bilingual (Arabic/English, RTL/LTR) full-stack internal web platform styled as an OS-like interface with glassmorphism aesthetics. Built as a pnpm monorepo.
Architecture
/
├── artifacts/
│ ├── api-server/ Express 5 backend (port 8080)
│ └── tx-os/ React + Vite frontend (path: /, port dynamic)
├── lib/
│ ├── db/ Drizzle ORM + PostgreSQL schema
│ ├── api-spec/ OpenAPI spec + Orval codegen
│ ├── api-client-react/ Generated React Query hooks (from Orval)
│ └── api-zod/ Generated Zod schemas (from Orval)
└── scripts/ Seed script (pnpm run seed)
Stack
- Monorepo: pnpm workspaces
- Node.js: 24, TypeScript 5.9
- Backend: Express 5, express-session + connect-pg-simple (PostgreSQL sessions), bcryptjs, Socket.IO
- Database: PostgreSQL + Drizzle ORM, Zod validation
- API codegen: Orval (OpenAPI → React Query hooks + Zod schemas)
- Frontend: React + Vite, Tailwind CSS v4, wouter (routing), i18next (i18n), react-i18next
- Real-time: Socket.IO (path: /api/socket.io)
- Build: esbuild (API), Vite (frontend)
Features
- Arabic default with full RTL layout; English toggle persisted in localStorage + user profile
- Glassmorphism OS UI: animated gradient background, frosted glass panels
- OS Home Screen: live clock status bar (per-user clock style: full / digital / digital-no-seconds / analog / minimal, picker in status bar), app grid, bottom dock
- خدماتي (My Services): service card grid with availability status
- Internal Chat: real-time messages via Socket.IO, conversation list
- Notifications: unread tracking, mark-all-read
- Admin Panel: CRUD for apps, services, users (admin role required). Delete dialogs show a dependency warning on the FIRST click using count fields (
groupCount/restrictionCount/openCounton apps,orderCounton services,noteCount/orderCount/conversationCount/messageCounton users) returned by the list endpoints (GET /api/admin/apps,GET /api/services,GET /api/users); the lazy 409 conflict response fromDELETE /api/{apps,services,users}/:id(with?force=trueto override) remains as a safety net. The Add App dialog includes aNewAppPermissionsPickerthat lets admins pre-setpermissionIds[]soPOST /api/appscreates the app and inserts itsapp_permissionsrows in the same transaction (withonConflictDoNothing), avoiding the brief unrestricted window between create and follow-up gating. - Session-based Auth: RBAC with roles (admin/user)
- Executive Meetings (Phase 2): bilingual full-stack module under
/executive-meetingswith 9 sections — Schedule (centered cells, attendees widest column, RTL-locked column order # / الاجتماع / الحضور / الوقت), Manage Meetings (CRUD with attendee replace, transactional), Change Requests (submit / withdraw, supportsmeetingId=nullfor create-suggestions), Approvals (approve/reject with review notes), Tasks (CRUD with assignee status updates — assignees and mutators can change status, only mutators can delete), Notifications (per-user feed; meeting/request/task events fan out viarecordExecutiveMeetingNotificationsto bothexecutive_meeting_notificationsand the globalnotificationsbell, then broadcast via Socket.IOnotification_createdper-user +executive_meeting_notifications_changedglobally; approvers also receive a best-effort email side-channel viasendExecutiveMeetingEmailthat logs an outbox entry until SMTP is wired up), Audit Log (full action chain, admin role required), PDF (window.print export), Font Settings (per-user + global scope, family/size/weight/alignment with live preview). RBAC enforced via 5 role sets (READ/MUTATE/APPROVE/REQUEST/ADMIN_AUDIT) and amakeRequireRolesmiddleware factory;/api/executive-meetings/mereturns{userId, roles, canRead, canMutate, canApprove, canSubmitRequest, canViewAudit}. Every mutation (meeting/request/task/font CRUD) wraps the DB write and the audit-log insert in the samedb.transaction(...)so audit entries cannot drift from state. Routes userouter.param("id")withnext("route")to handle path-to-regexp 8 (no inline:id(\\d+)support).
Key Commands
pnpm run typecheck— full typecheck across all packagespnpm run build— build all packagespnpm --filter @workspace/api-spec run codegen— regenerate API hooks from OpenAPI specpnpm --filter @workspace/db run push— push DB schema changes (dev)pnpm --filter @workspace/scripts run seed— seed demo data
Demo Accounts
- Admin:
admin/admin123(admin + user roles) - User:
ahmed/user123(user role)
Database Tables
users, roles, permissions, user_roles, role_permissions, role_permission_audit, permission_audit, apps, app_permissions, service_categories, services, conversations, conversation_participants, messages, message_reads, notifications, user_sessions, audit_logs, executive_meetings, executive_meeting_attendees, executive_meeting_requests, executive_meeting_tasks, executive_meeting_notifications, executive_meeting_audit_logs, executive_meeting_pdf_archives, executive_meeting_font_settings
Important Notes
- Session table
user_sessionsis created manually (not auto-created) — needed in DB before first run - Vite dev server proxies
/api→localhost:8080for cookie-based auth to work - All API calls use
credentials: "include"vialib/api-client-react/src/custom-fetch.ts - Socket.IO server path:
/api/socket.io - Frontend connects to Socket.IO via same-origin proxy (no separate URL needed)
- i18n locale files:
artifacts/tx-os/src/locales/ar.jsonanden.json - Default receivers group is named Tx (renamed from legacy "TeaBoy"); a one-time migration in the seed script renames any pre-existing legacy group on next run.
Deployment / Migration Runbook
-
Rich-text columns are PostgreSQL
text(no length cap).executive_meetings.title_ar,executive_meetings.title_en, andexecutive_meeting_attendees.namewere widened fromvarchar(500)/varchar(255)totextto hold sanitized Tiptap HTML. The schema declarations live inlib/db/src/schema/executive-meetings.ts. -
pnpm --filter @workspace/db run push-forcemust run in every environment (dev, staging, production) after deploying schema changes. Dev is covered automatically byscripts/post-merge.sh. Staging and production must run the same command on each deploy so theirtitle_ar/title_en/namecolumns match the code; otherwise long rich-text saves will be rejected by the old varchar limits. -
Pre-push cleanup is automatic. Both
pnpm --filter @workspace/db run pushandpush-forcenow runlib/db/scripts/pre-push-cleanup.tsfirst. That script is idempotent and:- Collapses duplicate rows in
app_permissionsto one per(app_id, permission_id)so the composite primary key declared by the schema can be created on legacy DBs. - Deletes orphan
executive_meeting_notificationsrows whosemeeting_idno longer exists, so the newON DELETE CASCADEforeign key the schema declares can be added on legacy DBs.
Both checks are skipped automatically on a fresh DB (the table-existence guard makes them no-ops). No manual SQL is needed in any environment —
pnpm --filter @workspace/db run pushruns cleanly against both fresh and existing dev DBs, andscripts/post-merge.shcontinues to usepush-forceso the same cleanup runs after every task merge. All schema tables (notablyrole_permission_auditandpermission_audit) are created via the normal push path. - Collapses duplicate rows in
Task #207 — Custom subheadings inside attendee cells (April 2026)
executive_meeting_attendees.kind (varchar(16) NOT NULL DEFAULT 'person') lets meetings interleave free-text section headers ("subheadings") with person rows. Subheadings are excluded from the running attendee number and from the per-meeting attendee count surface, but reorder/delete identically to person rows. The schema lives in lib/db/src/schema/executive-meetings.ts. All four insert paths (POST, PATCH attendees replace, PUT attendees, duplicate) round-trip kind. The PDF renderer (artifacts/api-server/src/lib/pdf-renderer.ts) prints subheadings as — label — and skips them when incrementing personIdx.
Deployment / migration step (run once per environment before the next release): the new kind column has NOT NULL DEFAULT 'person', so existing rows are auto-backfilled by Postgres on add-column. Apply via either pnpm --filter @workspace/db run push-force (recommended; idempotent) or, if push is blocked by other legacy data in that environment, run this one-line SQL: ALTER TABLE executive_meeting_attendees ADD COLUMN IF NOT EXISTS kind varchar(16) NOT NULL DEFAULT 'person';. Verify backfill with SELECT kind, COUNT(*) FROM executive_meeting_attendees GROUP BY kind; — every existing row should report kind = 'person'.
Task #273 — 5-minute pre-meeting alert (May 2026)
Floating, draggable upcoming-meeting alert that appears on every Tx OS page when an Executive Meeting is within five minutes of starting. Mounted globally inside <AuthProvider> in artifacts/tx-os/src/App.tsx as <UpcomingMeetingAlert />; gated by the executive_meetings:read capability returned by /api/me.
- New table
executive_meeting_alert_state (meetingId, userId, dismissed, acknowledged, updatedAt)with unique(meeting_id, user_id)— declared inlib/db/src/schema/executive-meetings.ts. Apply withpnpm --filter @workspace/db run push-forcein every environment. - New routes in
artifacts/api-server/src/routes/executive-meetings.ts:GET /executive-meetings/alert-state?date=YYYY-MM-DDPOST /executive-meetings/:id/alert-state(action: shown | acknowledged | dismissed)POST /executive-meetings/:id/postpone-minutesPOST /executive-meetings/:id/reschedulePOST /executive-meetings/:id/cancel
- All three mutation routes lock the meeting row with
SELECT ... FOR UPDATEinside the transaction, compute oldValue from the locked snapshot, run conflict detection in the same tx, and write the audit row before commit. Cancel is idempotent (no duplicate audit if already cancelled). - i18n keys:
executiveMeetings.alert.*(en + ar), including the cancel-confirm prompt. - Component:
artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx— draggable withlocalStorageposition persistence, polls every 30 s, renders RTL when locale isar, shows the start–end window, postpone-by-minutes chips[5,10,15,30,45,60], full reschedule sub-form, and a Cancel-meeting destructive flow that requires an explicit second-step confirmation before firing. - E2E coverage:
artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs(6 specs: appear+Done, postpone-10 shifts times, cancel-with-confirm, dismiss audit, postpone-chip+conflict-warning, AR/RTL).
Task #283 — Optimistic locking for postpone (May 2026)
Concurrent postpones from two users no longer silently stack. The mutation routes in artifacts/api-server/src/routes/executive-meetings.ts now accept an optional expectedUpdatedAt: string (ISO) in the request body. Inside the existing SELECT … FOR UPDATE transaction, the handler compares expectedUpdatedAt against the locked row's updated_at; on mismatch it returns HTTP 409 with code: "stale_meeting" and a conflict payload ({ currentStartTime, currentEndTime, currentStatus, lastModifiedAt, lastActor: { id, username, displayNameAr, displayNameEn } }). Every successful update also writes updatedBy = userId.
Client (artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx) sends expectedUpdatedAt: meeting.updatedAt from the postpone dialog. A new ApiError class surfaces the response body so the dialog can render a rose-tinted "X just changed this meeting — Add N more minutes anyway?" block (postpone-stale-block) instead of a destructive toast. The user can either retry without the token (force-apply) or back out. New i18n keys: executiveMeetings.alert.staleMeetingTitle / staleMeetingByUser / staleMeetingCurrentTime / staleMeetingApplyAnyway in both en.json and ar.json.
Coverage: artifacts/api-server/tests/executive-meetings-postpone-race.test.mjs exercises the win/lose/refetch+retry/force-apply paths and confirms the loser's stale call does not stack a second shift.