## Original task
`pnpm --filter @workspace/db run push` was failing with a duplicate-key
error on `app_permissions` (and a missing FK on
`executive_meeting_notifications`) because legacy data in the dev DB
violates constraints the schema now declares. Devs had to drop into psql
to fix it, which made bootstrapping painful and left
`role_permission_audit` reliant on hand-applied SQL.
## Changes
- Added `lib/db/scripts/pre-push-cleanup.ts`, an idempotent cleanup that:
- Collapses duplicate `app_permissions` rows to one per
`(app_id, permission_id)` so the composite PK can be added.
- Deletes orphan `executive_meeting_notifications` rows so the new
`ON DELETE CASCADE` FK can be added.
- Skips both checks when the tables don't exist yet (fresh DB
no-op).
- Wired the script into `lib/db/package.json` so both `push` and
`push-force` run cleanup first (`pnpm run pre-push-cleanup && drizzle-kit push ...`).
- Added `tsx` to `lib/db` devDependencies (catalog version) so the
package can run the cleanup without leaning on another workspace.
- Updated `replit.md` Deployment / Migration Runbook to reflect that
cleanup is now automatic — no manual SQL required in any environment.
## Verification
- `pnpm --filter @workspace/db run push` now collapses 2 duplicate
groups + removes 1978 orphan notifications, then succeeds with
`[✓] Changes applied`.
- Re-running push is idempotent: second run reports no duplicates and
no orphans, then succeeds.
- `pnpm --filter @workspace/db run push-force` (used by
`scripts/post-merge.sh`) was also verified end-to-end.
- Confirmed `app_permissions` now has the composite PK,
`executive_meeting_notifications` has the cascade FK, and
`role_permission_audit` matches the schema.
## Notes
- A pre-existing unrelated typecheck error in
`artifacts/api-server/src/routes/executive-meetings.ts` (font
settings `scope` overload) was confirmed to exist on `main` before
any changes here and is out of scope for this task.
- Proposed follow-up #213 to move from `push`/`push-force` to
versioned `drizzle-kit migrate` so legacy-data backfills are checked
in instead of living in a generic pre-push script.
Replit-Task-Id: 2efc4e22-0c65-48a1-b573-319474698b96
8.7 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. - 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'.