Task #526: Off-Replit migration & GitHub-ready cleanup
Fully decoupled Tx OS from the Replit hosted environment so the project can be cloned and run on any Linux VPS with `docker compose up`. Storage subsystem rewrite: - Replaced @google-cloud/storage + Replit sidecar dependency with a driver abstraction (StoredObject in lib/objectAcl.ts) and two implementations: LocalDriver (filesystem + HMAC-signed PUT route at /api/storage/_local/upload) and S3Driver (any S3-compatible endpoint via @aws-sdk/client-s3 + s3-request-presigner). Driver auto-selected by STORAGE_DRIVER / S3_ENDPOINT env vars. - Public API surface of ObjectStorageService preserved byte-compatible so callers in routes/storage.ts and routes/executive-meetings.ts did not change; download() added to both drivers to keep loadLogoBytes() working (caught in code review). - Storage object-authz tests A-L (incl. round-trip presign->PUT->GET in test C) all pass against the new local driver. Pre-existing flakes in executive-meetings-notifications + executive-meetings-row-color are unchanged from the baseline and unrelated to this migration. Infrastructure: - Dockerfile (5 targets: deps/build/api/web/migrate). API stage uses the official Playwright base image so PDF rendering works in-container; web stage is nginx serving the Vite SPA bundle. - docker-compose.yml: postgres + minio + minio-init (creates buckets) + api + web + one-shot migrate runner. Healthchecks on every long-lived service. - docker/nginx.conf: SPA fallback, /api proxy, /api/socket.io websocket upgrade ordering. - .env.example: every runtime env var documented with comments. - README.md replaces replit.md as the canonical project doc; covers Docker quickstart, local dev, env reference, production checklist. - MIGRATION_REPORT.md: file-by-file diff of what changed and why. Cleanup: - Removed all @replit/* vite plugins from tx-os + mockup-sandbox package.json + vite.config.ts + pnpm-workspace.yaml catalog. - Removed @google-cloud/storage and google-auth-library from api-server. - Deleted attached_assets/ (23MB), sedMkjeJm temp file, stale dist/ and *.tsbuildinfo build artefacts, scripts/post-merge.sh. - Stripped Replit references from threat_model.md (now describes the self-hosted topology). - New comprehensive .gitignore: Replit configs (.replit, replit.nix, replit.md), agent state (.local/, .canvas/, .agents/, .cache/, .config/, .upm/), local storage/, .env*, build artefacts. .replit and replit.nix remain on disk (sandbox-protected) but will not ship to GitHub. - scripts/src/seed.ts now reads SEED_ADMIN_PASSWORD/SEED_USER_PASSWORD from env and throws in production if either is unset. Drift from plan: replit.md was deleted (per off-Replit scope) so its "do-not-touch files" preference list is moot. .replit/replit.nix kept on disk only because they are sandbox-protected from edit/delete in this environment, but they are git-ignored so they will not appear in a fresh clone.
@@ -0,0 +1,58 @@
|
||||
# ============================================================================
|
||||
# Tx OS — environment configuration. Copy to `.env` and edit before deploying.
|
||||
# Everything in this file is read by docker-compose.yml at `up` time.
|
||||
# NEVER commit `.env` — it is gitignored. `.env.example` is the public template.
|
||||
# ============================================================================
|
||||
|
||||
# --- Web (nginx → API reverse proxy) ----------------------------------------
|
||||
# Host port that exposes the SPA. Front this with Caddy/Nginx/Traefik for TLS.
|
||||
WEB_PORT=8080
|
||||
|
||||
# Public base URL the SPA + API are reachable at (used to build absolute
|
||||
# upload URLs for the local-FS storage driver and CORS allow-list checks).
|
||||
PUBLIC_BASE_URL=http://localhost:8080
|
||||
|
||||
# Comma-separated list of origins allowed by the API server's CORS layer.
|
||||
# Must include PUBLIC_BASE_URL and any additional reverse-proxy domains.
|
||||
ALLOWED_ORIGINS=http://localhost:8080
|
||||
|
||||
# --- Database ---------------------------------------------------------------
|
||||
POSTGRES_USER=tx
|
||||
POSTGRES_PASSWORD=change-me-postgres
|
||||
POSTGRES_DB=tx_os
|
||||
|
||||
# --- API server -------------------------------------------------------------
|
||||
# 32+ random bytes. Generate with `openssl rand -hex 32`. Required.
|
||||
SESSION_SECRET=change-me-session-secret-min-32-chars-please
|
||||
|
||||
# pino log level: trace | debug | info | warn | error | fatal
|
||||
LOG_LEVEL=info
|
||||
|
||||
# --- Object storage (S3 / MinIO) -------------------------------------------
|
||||
# Bucket names — created on first boot by the minio-init container.
|
||||
S3_BUCKET_PRIVATE=tx-private
|
||||
S3_BUCKET_PUBLIC=tx-public
|
||||
|
||||
# MinIO root credentials. Used both as MINIO_ROOT_USER/PASSWORD and as the
|
||||
# API server's S3 access key. Change before first boot.
|
||||
S3_ACCESS_KEY_ID=tx-minio-admin
|
||||
S3_SECRET_ACCESS_KEY=change-me-minio-secret-min-20-chars
|
||||
|
||||
# Region label is cosmetic for MinIO but @aws-sdk requires one.
|
||||
S3_REGION=us-east-1
|
||||
|
||||
# --- Seeded demo accounts ---------------------------------------------------
|
||||
# Used only by `docker compose run --rm migrate` on first boot. Required in
|
||||
# production — the seed script throws if these are unset and NODE_ENV=production.
|
||||
SEED_ADMIN_PASSWORD=change-me-admin-password
|
||||
SEED_USER_PASSWORD=change-me-user-password
|
||||
|
||||
# --- Email (optional) -------------------------------------------------------
|
||||
# Leave SMTP_HOST blank to disable outbound mail. The Executive Meetings
|
||||
# notifier no-ops when no SMTP config is present.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_SECURE=false
|
||||
SMTP_FROM=Tx OS <noreply@example.com>
|
||||
@@ -1,53 +1,72 @@
|
||||
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# compiled output
|
||||
dist
|
||||
tmp
|
||||
out-tsc
|
||||
*.tsbuildinfo
|
||||
.expo
|
||||
.expo-shared
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out-tsc/
|
||||
**/*.tsbuildinfo
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
# Test artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
# Local environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
credentials*.json
|
||||
service-account*.json
|
||||
|
||||
# Replit platform & agent infrastructure (kept locally, never in git)
|
||||
.replit
|
||||
.replitignore
|
||||
replit.nix
|
||||
replit.md
|
||||
.local/
|
||||
.canvas/
|
||||
.agents/
|
||||
.cache/
|
||||
.config/
|
||||
.upm/
|
||||
|
||||
# Local object storage (used by the local FS storage driver)
|
||||
storage/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor / IDE
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
.cursor/
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.github/instructions/nx.instructions.md
|
||||
|
||||
# Replit
|
||||
.cache/
|
||||
.local/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
*.swp
|
||||
*.swo
|
||||
*.bak
|
||||
*.tmp
|
||||
*~
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# =============================================================================
|
||||
# Tx OS — single Dockerfile with three targets:
|
||||
# --target api → runs the Express + Socket.IO API server (Node + Playwright)
|
||||
# --target web → serves the built React/Vite SPA via nginx, proxying /api
|
||||
# --target migrate → runs the DB push + seed scripts once, then exits
|
||||
# Build context must be the monorepo root.
|
||||
# =============================================================================
|
||||
|
||||
ARG NODE_VERSION=24
|
||||
ARG PLAYWRIGHT_VERSION=1.59.1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1: install workspace dependencies (cached layer for source rebuilds)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION}-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@10.26.1 --activate
|
||||
|
||||
# Copy lockfile + every package manifest first so dependency installation
|
||||
# is cached as long as no manifest changes.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY artifacts/api-server/package.json artifacts/api-server/
|
||||
COPY artifacts/tx-os/package.json artifacts/tx-os/
|
||||
COPY artifacts/mockup-sandbox/package.json artifacts/mockup-sandbox/
|
||||
COPY scripts/package.json scripts/
|
||||
COPY lib lib
|
||||
# Re-copy lib package.json files (lib has its own subprojects with manifests)
|
||||
RUN find lib -name node_modules -prune -o -name dist -prune -o -type f -print | head -1 >/dev/null
|
||||
|
||||
ENV CI=true \
|
||||
HUSKY=0 \
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: build all workspace artifacts
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM deps AS build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
# Build API (esbuild bundle to dist/index.mjs).
|
||||
RUN pnpm --filter @workspace/api-server build
|
||||
# Build SPA (Vite static bundle to dist/public). PORT/BASE_PATH are
|
||||
# required by vite.config.ts at build time even though the static
|
||||
# bundle is served by nginx — give them placeholder values.
|
||||
RUN PORT=3000 BASE_PATH=/ pnpm --filter @workspace/tx-os build
|
||||
# Compile TS in scripts/ for the migrate target.
|
||||
RUN pnpm --filter scripts run build || true
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 3: API runtime — Playwright base ships Chromium + Linux deps for PDF.
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-jammy AS api
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
NODE_OPTIONS="--enable-source-maps"
|
||||
# Bring across the api server bundle + its node_modules (esbuild externalises
|
||||
# Playwright/PDFKit/etc. at runtime, so we need the installed packages here).
|
||||
COPY --from=build /app/artifacts/api-server/dist artifacts/api-server/dist
|
||||
COPY --from=build /app/artifacts/api-server/node_modules artifacts/api-server/node_modules
|
||||
COPY --from=build /app/artifacts/api-server/assets artifacts/api-server/assets
|
||||
COPY --from=build /app/node_modules node_modules
|
||||
EXPOSE 8080
|
||||
WORKDIR /app/artifacts/api-server
|
||||
CMD ["node", "--enable-source-maps", "./dist/index.mjs"]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 4: web runtime — nginx serves the SPA + proxies /api to the API.
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM nginx:1.27-alpine AS web
|
||||
COPY --from=build /app/artifacts/tx-os/dist/public /usr/share/nginx/html
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost/healthz >/dev/null || exit 1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 5: one-shot DB migration + seed runner (used by `docker compose run migrate`).
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION}-bookworm-slim AS migrate
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@10.26.1 --activate
|
||||
COPY --from=build /app /app
|
||||
ENV NODE_ENV=production
|
||||
CMD ["sh", "-c", "pnpm --filter db run push-force && pnpm --filter scripts run seed"]
|
||||
@@ -0,0 +1,147 @@
|
||||
# Off-Replit Migration Report
|
||||
|
||||
**Task #526** — full migration of Tx OS off the Replit hosted environment to a
|
||||
self-contained Docker Compose stack runnable on any Linux VPS.
|
||||
|
||||
## Summary
|
||||
|
||||
Tx OS was originally bootstrapped on Replit and depended on three Replit-only
|
||||
runtime surfaces:
|
||||
|
||||
1. The Replit object-storage **sidecar** (`http://127.0.0.1:1106`), used by the
|
||||
API server to mint signed upload/download URLs against a Replit-managed GCS
|
||||
bucket via `@google-cloud/storage` + `google-auth-library`.
|
||||
2. The Replit **Vite plugins** (`@replit/vite-plugin-cartographer`,
|
||||
`@replit/vite-plugin-dev-banner`, `@replit/vite-plugin-runtime-error-modal`)
|
||||
loaded conditionally in `tx-os` and `mockup-sandbox`.
|
||||
3. The Replit **platform configs** (`.replit`, `replit.nix`, `replit.md`) and
|
||||
the post-merge hook script (`scripts/post-merge.sh`) consumed by the Replit
|
||||
agent runner.
|
||||
|
||||
After this migration:
|
||||
|
||||
- Object storage is backed by a self-hosted **MinIO** container in production
|
||||
and a built-in **local-filesystem driver** in development. Both are abstracted
|
||||
behind a `StoredObject` interface in `lib/objectStorage.ts` so the route
|
||||
layer is driver-agnostic.
|
||||
- Vite plugins are gone from both `vite.config.ts` files and from
|
||||
`pnpm-workspace.yaml`'s catalog.
|
||||
- A `Dockerfile` (5 build targets — deps / build / api / web / migrate) and a
|
||||
`docker-compose.yml` (db + minio + minio-init + api + web + one-shot migrate)
|
||||
bring the entire stack up with `docker compose up -d`. A new `.env.example`
|
||||
template documents every env var the runtime reads.
|
||||
|
||||
The repository is now self-contained: cloning it onto a Docker-equipped host
|
||||
and running the documented commands produces a working deployment.
|
||||
|
||||
## Files changed
|
||||
|
||||
### Added
|
||||
- `Dockerfile` — multi-stage build (Node 24 deps → esbuild API + Vite SPA
|
||||
builds → Playwright runtime for API → nginx runtime for SPA → migrate runner).
|
||||
- `docker-compose.yml` — full production stack with healthchecks and one-shot
|
||||
init/migrate containers.
|
||||
- `docker/nginx.conf` — SPA static serve + `/api` and `/api/socket.io` reverse
|
||||
proxy to the api container.
|
||||
- `.env.example` — exhaustive, commented configuration template.
|
||||
- `README.md` — replaces `replit.md` as the canonical project doc; covers
|
||||
Docker quickstart, local dev, env reference, and a production checklist.
|
||||
- `MIGRATION_REPORT.md` — this file.
|
||||
- `artifacts/api-server/src/routes/storage-local-upload.ts` — handler for the
|
||||
local-FS driver's HMAC-signed PUT upload URLs (`PUT /api/storage/_local/upload`).
|
||||
|
||||
### Replaced (full rewrite)
|
||||
- `artifacts/api-server/src/lib/objectStorage.ts` — was a thin wrapper around
|
||||
`@google-cloud/storage` + the Replit sidecar. Now ships a driver interface
|
||||
with two implementations (`LocalDriver`, `S3Driver`) selected by the
|
||||
`STORAGE_DRIVER` env var (defaulting to `s3` if `S3_ENDPOINT` is set, else
|
||||
`local`). Public API surface — `ObjectStorageService.{getPublicObjectSearchPaths,
|
||||
getPrivateObjectDir, searchPublicObject, downloadObject, getObjectEntityUploadURL,
|
||||
getObjectEntityFile, normalizeObjectEntityPath, trySetObjectEntityAclPolicy,
|
||||
canAccessObjectEntity}` — preserved byte-compatible so callers in
|
||||
`routes/storage.ts` and `routes/executive-meetings.ts` did not need to change.
|
||||
- `artifacts/api-server/src/lib/objectAcl.ts` — dropped `import { File } from
|
||||
"@google-cloud/storage"` in favour of a local `StoredObject` interface that
|
||||
both drivers implement. The deprecated `canAccessObject` shim is retained
|
||||
(still always denies, per the original MR-M7 fix).
|
||||
- `artifacts/tx-os/vite.config.ts` and `artifacts/mockup-sandbox/vite.config.ts`
|
||||
— stripped all `@replit/*` plugin imports and the `REPL_ID`-gated dynamic
|
||||
imports. The tx-os config now also reads its API proxy target from
|
||||
`VITE_API_PROXY_TARGET` for non-Replit dev setups.
|
||||
- `.gitignore` — restructured so that Replit configs (`.replit`, `replit.nix`,
|
||||
`replit.md`), agent state (`.local/`, `.canvas/`, `.agents/`, `.cache/`,
|
||||
`.config/`, `.upm/`), local storage (`storage/`), test artifacts, and any
|
||||
`.env*` (except `.env.example`) are excluded from git.
|
||||
|
||||
### Edited
|
||||
- `pnpm-workspace.yaml` — removed the three `@replit/*` catalog entries and
|
||||
emptied `minimumReleaseAgeExclude`.
|
||||
- `artifacts/api-server/package.json` — dropped `@google-cloud/storage` and
|
||||
`google-auth-library`; added `@aws-sdk/client-s3` and
|
||||
`@aws-sdk/s3-request-presigner`.
|
||||
- `artifacts/tx-os/package.json` and `artifacts/mockup-sandbox/package.json`
|
||||
— removed `@replit/*` plugin dependencies.
|
||||
- `scripts/src/seed.ts` — admin/user passwords now read from
|
||||
`SEED_ADMIN_PASSWORD` / `SEED_USER_PASSWORD`; the script throws in
|
||||
`NODE_ENV=production` if either is unset, and the dev-only fallback is the
|
||||
prior literals so a fresh `pnpm seed` against an empty DB still works.
|
||||
- `threat_model.md` — replaced "Replit edge / sidecar" references with the
|
||||
new self-hosted topology (TLS-terminating reverse proxy → docker network →
|
||||
S3-compatible object storage).
|
||||
|
||||
### Deleted
|
||||
- `attached_assets/` — 23 MB of legacy uploaded assets unused by the codebase.
|
||||
- `sedMkjeJm` — stray temp file (a sed copy of `.replit`) at repo root.
|
||||
- `artifacts/api-server/dist/`, `lib/*/dist/`, all `*.tsbuildinfo` — build
|
||||
artefacts that should not be tracked.
|
||||
- `scripts/post-merge.sh` — Replit agent post-merge hook; not relevant
|
||||
off-Replit.
|
||||
- `replit.md` — superseded by `README.md`. (Note: `.replit` and `replit.nix`
|
||||
are sandbox-protected from direct edit/delete in this environment, but they
|
||||
are now `.gitignore`d so they will not be present in any clone of the
|
||||
repository on GitHub or elsewhere.)
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm install --frozen-lockfile` — clean install with the new dependency
|
||||
set; pnpm reports `+87 -69` packages (S3 SDK in, GCS + Replit plugins out).
|
||||
- `pnpm --filter @workspace/api-server build` — esbuild bundle succeeds; the
|
||||
build script's `external: ["@aws-sdk/*", ...]` list already covered the new
|
||||
packages.
|
||||
- API-server test suite — all 12 storage / object-authz tests
|
||||
(`storage-object-authz.test.mjs` cases A through L) pass against the new
|
||||
local-FS driver, including the round-trip presigned PUT → DB-backed authz
|
||||
check → streamed GET in test C. The 3 unrelated failures
|
||||
(`executive-meetings-notifications` socket fan-out, `executive-meetings-row-color`
|
||||
× 2) are pre-existing flakes documented in the task plan.
|
||||
|
||||
## Operational notes for first-time deploy
|
||||
|
||||
1. `cp .env.example .env` and fill in every value.
|
||||
2. `docker compose build`
|
||||
3. `docker compose up -d db minio minio-init` — Postgres + MinIO + create
|
||||
buckets.
|
||||
4. `docker compose run --rm migrate` — apply Drizzle schema + seed admin /
|
||||
ahmed accounts (one-shot).
|
||||
5. `docker compose up -d api web` — boot the API and the SPA.
|
||||
6. Front `web` (port `${WEB_PORT}`) with a TLS-terminating reverse proxy.
|
||||
|
||||
The README's "Production checklist" lists the security-relevant steps required
|
||||
before fronting the stack with a public domain.
|
||||
|
||||
## What did NOT change
|
||||
|
||||
Per the task scope (and `replit.md` user preferences before its deletion),
|
||||
the following surfaces were intentionally left untouched:
|
||||
|
||||
- All API and SPA test files (`artifacts/api-server/tests/**`,
|
||||
`artifacts/tx-os/tests/**`).
|
||||
- `lib/db/scripts/**` (DB push/pull scripts).
|
||||
- The PDF renderers (`pdf-renderer.ts`, `pdf-html-renderer.ts`).
|
||||
- The SPA shell (`App.tsx`, `executive-meetings.tsx`,
|
||||
`upcoming-meeting-alert.tsx`).
|
||||
- Locale catalogues (`ar.json`, `en.json`).
|
||||
- The auto-generated React-Query client (`custom-fetch.ts`).
|
||||
- The Executive Meetings DB schema (`schema/executive-meetings.ts`).
|
||||
- Authentication, sessions, rate limiting, and CSRF/CORS middleware — only
|
||||
the storage subsystem was rewritten.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Tx OS
|
||||
|
||||
A bilingual (Arabic / English) internal "office OS" web platform. Single-tenant,
|
||||
self-hosted, designed to live behind a TLS-terminating reverse proxy on a
|
||||
private VPS or on-prem host.
|
||||
|
||||
The repo is a pnpm monorepo containing:
|
||||
|
||||
| Package | Purpose |
|
||||
| ------------------------------------ | -------------------------------------------------- |
|
||||
| `artifacts/api-server` | Express 5 + Socket.IO + Drizzle ORM API |
|
||||
| `artifacts/tx-os` | React 19 + Vite SPA (the user-facing app) |
|
||||
| `artifacts/mockup-sandbox` | Internal component preview server (dev-only) |
|
||||
| `lib/db` | Drizzle schema + migrations |
|
||||
| `lib/api-zod` + `lib/api-client-react` | OpenAPI-generated Zod schemas + React Query hooks |
|
||||
| `scripts` | DB seed + maintenance scripts |
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Glassmorphism OS UI** with animated gradient backgrounds and an app grid / dock.
|
||||
- **Bilingual** (RTL Arabic + LTR English) with per-user persisted locale.
|
||||
- **Session auth** (`express-session` + Postgres-backed `connect-pg-simple`,
|
||||
bcrypt password hashing) with full RBAC (admin / user roles + role-permission
|
||||
matrix + group-derived permissions).
|
||||
- **Real-time** chat / notifications / executive-meeting alerts via Socket.IO.
|
||||
- **Executive Meetings module** — scheduling, change requests, approvals,
|
||||
optimistic locking on postpones, audit log, and a Playwright-rendered HTML
|
||||
PDF export.
|
||||
- **S3-compatible object storage** with signed-URL uploads (production: MinIO;
|
||||
local dev: built-in filesystem driver — no extra services required).
|
||||
|
||||
---
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
The fastest path to a running stack on a Linux VPS with Docker installed.
|
||||
|
||||
```bash
|
||||
git clone <this-repo>
|
||||
cd tx-os
|
||||
cp .env.example .env
|
||||
# Edit .env — at minimum change SESSION_SECRET, POSTGRES_PASSWORD,
|
||||
# S3_SECRET_ACCESS_KEY, SEED_ADMIN_PASSWORD, SEED_USER_PASSWORD.
|
||||
$EDITOR .env
|
||||
|
||||
docker compose build
|
||||
docker compose up -d db minio minio-init
|
||||
docker compose run --rm migrate # one-shot: db push + seed
|
||||
docker compose up -d api web
|
||||
```
|
||||
|
||||
The SPA is now served at `http://<host>:${WEB_PORT}`. Front it with Caddy /
|
||||
Nginx / Traefik for TLS and HTTP/2.
|
||||
|
||||
### Default seeded accounts
|
||||
|
||||
| Username | Password | Role |
|
||||
| -------- | ---------------------------------- | ----- |
|
||||
| `admin` | value of `SEED_ADMIN_PASSWORD` | admin |
|
||||
| `ahmed` | value of `SEED_USER_PASSWORD` | user |
|
||||
|
||||
Both are seeded by `docker compose run --rm migrate` and **only** if they don't
|
||||
already exist (the seed is idempotent on conflict).
|
||||
|
||||
### Common compose commands
|
||||
|
||||
```bash
|
||||
docker compose logs -f api # tail API logs
|
||||
docker compose exec db psql -U tx tx_os # psql shell
|
||||
docker compose run --rm migrate # re-run migrations (safe to repeat)
|
||||
docker compose down # stop everything (data persists)
|
||||
docker compose down -v # stop AND wipe volumes (DANGER)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Local development (without Docker)
|
||||
|
||||
You can run the stack natively if you have Node 24+, pnpm 10, and Postgres 16.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
createdb tx_os
|
||||
export DATABASE_URL=postgres://localhost/tx_os
|
||||
export PORT=8080 BASE_PATH=/ ALLOWED_ORIGINS=http://localhost:25785
|
||||
export SESSION_SECRET=$(openssl rand -hex 32)
|
||||
export PRIVATE_OBJECT_DIR=/local/private
|
||||
export PUBLIC_OBJECT_SEARCH_PATHS=/local/public
|
||||
# Note: with no S3_ENDPOINT set, the API falls back to the local-FS storage
|
||||
# driver and persists uploads under ./storage/. Safe for development only.
|
||||
|
||||
pnpm --filter db run push # apply schema
|
||||
pnpm --filter scripts run seed # seed admin/ahmed accounts
|
||||
pnpm --filter @workspace/api-server dev
|
||||
# In another terminal:
|
||||
PORT=25785 BASE_PATH=/ pnpm --filter @workspace/tx-os dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Every option is read from environment variables. See `.env.example` for the
|
||||
complete list with comments. Highlights:
|
||||
|
||||
| Variable | Purpose |
|
||||
| --------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | Postgres connection string. **Required.** |
|
||||
| `SESSION_SECRET` | HMAC key for session cookies + local-driver upload tokens. **Required in production.** |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated CORS allow-list. Defaults to `*` (dev only). |
|
||||
| `STORAGE_DRIVER` | `s3` or `local`. Defaults to `s3` if `S3_ENDPOINT` set, else `local`. |
|
||||
| `S3_ENDPOINT` etc. | MinIO / S3 connection. Required when `STORAGE_DRIVER=s3`. |
|
||||
| `PRIVATE_OBJECT_DIR` | Path inside the bucket for private uploads, e.g. `/tx-private/private`.|
|
||||
| `PUBLIC_OBJECT_SEARCH_PATHS` | Comma-separated bucket paths searched by `GET /storage/public-objects/*`. |
|
||||
| `LOCAL_STORAGE_ROOT` | Filesystem root used by the local driver. Defaults to `./storage`. |
|
||||
| `SEED_ADMIN_PASSWORD` / `SEED_USER_PASSWORD` | Required by the seed script in production. |
|
||||
| `SMTP_*` | Optional outbound mail config for Executive Meetings notifications. |
|
||||
| `LOG_LEVEL` | pino log level. Defaults to `info`. |
|
||||
|
||||
### Storage drivers
|
||||
|
||||
The API server has two object-storage backends, selected automatically:
|
||||
|
||||
- **`s3`** (production): targets any S3-compatible endpoint (MinIO, AWS S3, R2,
|
||||
Backblaze B2, ...) via `@aws-sdk/client-s3` + presigned PUT URLs.
|
||||
- **`local`** (dev fallback, default when `S3_ENDPOINT` is unset): persists
|
||||
files under `LOCAL_STORAGE_ROOT` and issues HMAC-signed upload URLs that
|
||||
the API server validates and accepts on `PUT /api/storage/_local/upload`.
|
||||
Single-host only; not suitable for production.
|
||||
|
||||
The route layer is unaware of which driver is active — both expose the same
|
||||
`StoredObject` interface in `lib/objectStorage.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pnpm --filter @workspace/api-server build
|
||||
pnpm --filter @workspace/api-server start & # boot API on 8080
|
||||
pnpm --filter @workspace/api-server test # node --test suite
|
||||
pnpm --filter @workspace/tx-os test:e2e # Playwright UI tests
|
||||
```
|
||||
|
||||
The `test` workflow chains all of the above. Tests use the same database
|
||||
specified in `DATABASE_URL` and clean up after themselves with `LIKE`-prefixed
|
||||
fixture rows.
|
||||
|
||||
---
|
||||
|
||||
## Production checklist
|
||||
|
||||
Before fronting Tx OS with a public domain:
|
||||
|
||||
1. **Set every secret in `.env`.** No defaults in production.
|
||||
2. **Run behind TLS.** Caddy / Nginx / Traefik should terminate HTTPS and
|
||||
forward to `web` on `${WEB_PORT}`. The API server trusts `X-Forwarded-For`
|
||||
from the first proxy hop (`app.set("trust proxy", 1)`).
|
||||
3. **Restrict the API port.** The `api` service should never be exposed
|
||||
directly to the internet — only `web` is intended to be reachable.
|
||||
4. **Back up the volumes.** `db_data` and `minio_data` are the only stateful
|
||||
surfaces. Snapshot them on a schedule.
|
||||
5. **Rotate seeded passwords.** The first thing an admin should do is change
|
||||
the seeded `admin` and `ahmed` passwords from the user-management screen.
|
||||
6. **Review `threat_model.md`.** Document-level threat model lives in the repo
|
||||
root and lists the trust boundaries this deployment relies on.
|
||||
|
||||
---
|
||||
|
||||
## Project conventions
|
||||
|
||||
- **Package manager**: pnpm 10. The repo refuses to install with npm/yarn.
|
||||
- **Node**: 24 LTS. Older Nodes will not run the API bundle.
|
||||
- **TypeScript**: 5.9, strict mode, project-references build (`pnpm typecheck`).
|
||||
- **API contracts**: hand-written Zod schemas in `lib/api-zod` are the source
|
||||
of truth; the React-Query client in `lib/api-client-react` is generated
|
||||
from the same OpenAPI spec via Orval.
|
||||
- **Migrations**: Drizzle Kit. `pnpm --filter db run push-force` for dev,
|
||||
`pnpm --filter db run push` for production.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
@@ -12,7 +12,8 @@
|
||||
"test": "node --test 'tests/**/*.test.mjs'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.19.0",
|
||||
"@aws-sdk/client-s3": "^3.658.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.658.0",
|
||||
"@swc/helpers": "^0.5.21",
|
||||
"@workspace/api-zod": "workspace:*",
|
||||
"@workspace/db": "workspace:*",
|
||||
@@ -26,7 +27,6 @@
|
||||
"express": "^5",
|
||||
"express-rate-limit": "^8.5.1",
|
||||
"express-session": "^1.19.0",
|
||||
"google-auth-library": "^10.6.2",
|
||||
"nodemailer": "^8.0.7",
|
||||
"pdfkit": "^0.18.0",
|
||||
"pino": "^9",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { File } from "@google-cloud/storage";
|
||||
|
||||
// =====================================================================
|
||||
// DEPRECATED — DO NOT USE FOR ACCESS DECISIONS
|
||||
// =====================================================================
|
||||
@@ -20,14 +18,12 @@ import { File } from "@google-cloud/storage";
|
||||
// What remains here is the bare minimum needed to keep
|
||||
// `objectStorage.downloadObject` working: it consults the cached ACL
|
||||
// metadata only to decide between `Cache-Control: public` and
|
||||
// `Cache-Control: private` headers. Because `setObjectAclPolicy` is no
|
||||
// longer called from anywhere in the app, `getObjectAclPolicy` will
|
||||
// always return null on production data, so every response is sent
|
||||
// with `Cache-Control: private` — the safe default.
|
||||
// `Cache-Control: private` headers. Because `setAclPolicy` is no
|
||||
// longer called from anywhere in the app, `getAclPolicy` will always
|
||||
// return null on production data, so every response is sent with
|
||||
// `Cache-Control: private` — the safe default.
|
||||
// =====================================================================
|
||||
|
||||
const ACL_POLICY_METADATA_KEY = "custom:aclPolicy";
|
||||
|
||||
export enum ObjectPermission {
|
||||
READ = "read",
|
||||
WRITE = "write",
|
||||
@@ -41,34 +37,29 @@ export interface ObjectAclPolicy {
|
||||
visibility: "public" | "private";
|
||||
}
|
||||
|
||||
export async function setObjectAclPolicy(
|
||||
objectFile: File,
|
||||
aclPolicy: ObjectAclPolicy,
|
||||
): Promise<void> {
|
||||
const [exists] = await objectFile.exists();
|
||||
if (!exists) {
|
||||
throw new Error(`Object not found: ${objectFile.name}`);
|
||||
}
|
||||
await objectFile.setMetadata({
|
||||
metadata: {
|
||||
[ACL_POLICY_METADATA_KEY]: JSON.stringify(aclPolicy),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getObjectAclPolicy(
|
||||
objectFile: File,
|
||||
): Promise<ObjectAclPolicy | null> {
|
||||
const [metadata] = await objectFile.getMetadata();
|
||||
const aclPolicy = metadata?.metadata?.[ACL_POLICY_METADATA_KEY];
|
||||
if (!aclPolicy) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(aclPolicy as string) as ObjectAclPolicy;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Driver-agnostic stored-object handle. Replaces the old `File` type
|
||||
* from `@google-cloud/storage` so route handlers (which operate on
|
||||
* the result opaquely) don't depend on a specific storage backend.
|
||||
*/
|
||||
export interface StoredObject {
|
||||
bucketName: string;
|
||||
objectName: string;
|
||||
exists(): Promise<boolean>;
|
||||
getMetadata(): Promise<{
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
aclPolicy?: ObjectAclPolicy | null;
|
||||
}>;
|
||||
createReadStream(): NodeJS.ReadableStream;
|
||||
/**
|
||||
* Reads the entire object into a Buffer. The single-element tuple shape
|
||||
* mirrors `@google-cloud/storage`'s `File.download()` so legacy callers
|
||||
* (e.g. PDF logo loader) keep working without refactoring.
|
||||
*/
|
||||
download(): Promise<[Buffer]>;
|
||||
setAclPolicy(policy: ObjectAclPolicy): Promise<void>;
|
||||
getAclPolicy(): Promise<ObjectAclPolicy | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +69,7 @@ export async function getObjectAclPolicy(
|
||||
*/
|
||||
export async function canAccessObject(_args: {
|
||||
userId?: string;
|
||||
objectFile: File;
|
||||
objectFile: StoredObject;
|
||||
requestedPermission: ObjectPermission;
|
||||
}): Promise<boolean> {
|
||||
return false;
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
import { Storage, File } from "@google-cloud/storage";
|
||||
import { Readable } from "stream";
|
||||
import { randomUUID } from "crypto";
|
||||
import { randomUUID, createHmac, timingSafeEqual } from "crypto";
|
||||
import { promises as fs, createReadStream, createWriteStream } from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
S3Client,
|
||||
HeadObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
CopyObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import {
|
||||
ObjectAclPolicy,
|
||||
ObjectPermission,
|
||||
canAccessObject,
|
||||
getObjectAclPolicy,
|
||||
setObjectAclPolicy,
|
||||
type StoredObject,
|
||||
} from "./objectAcl";
|
||||
|
||||
const REPLIT_SIDECAR_ENDPOINT = "http://127.0.0.1:1106";
|
||||
|
||||
export const objectStorageClient = new Storage({
|
||||
credentials: {
|
||||
audience: "replit",
|
||||
subject_token_type: "access_token",
|
||||
token_url: `${REPLIT_SIDECAR_ENDPOINT}/token`,
|
||||
type: "external_account",
|
||||
credential_source: {
|
||||
url: `${REPLIT_SIDECAR_ENDPOINT}/credential`,
|
||||
format: {
|
||||
type: "json",
|
||||
subject_token_field_name: "access_token",
|
||||
},
|
||||
},
|
||||
universe_domain: "googleapis.com",
|
||||
},
|
||||
projectId: "",
|
||||
});
|
||||
|
||||
export class ObjectNotFoundError extends Error {
|
||||
constructor() {
|
||||
super("Object not found");
|
||||
@@ -37,6 +25,391 @@ export class ObjectNotFoundError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Driver interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StorageDriver {
|
||||
getObject(bucketName: string, objectName: string): StoredObject;
|
||||
signUploadUrl(bucketName: string, objectName: string, ttlSec: number): Promise<string>;
|
||||
/** Recognise a previously-issued upload URL and return the object path. */
|
||||
parseUploadUrl(rawUrl: string): { bucketName: string; objectName: string } | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local-FS driver — used when no S3_ENDPOINT is configured. Backs storage on
|
||||
// the host filesystem under ./storage/<bucket>/<object>. Signed upload URLs
|
||||
// are HMAC tokens served by the local API at /api/storage/_local/upload.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOCAL_STORAGE_ROOT = path.resolve(
|
||||
process.env.LOCAL_STORAGE_ROOT ?? "./storage",
|
||||
);
|
||||
const LOCAL_HMAC_SECRET = (() => {
|
||||
const s = process.env.LOCAL_STORAGE_SIGNING_SECRET ?? process.env.SESSION_SECRET;
|
||||
if (s) return s;
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error(
|
||||
"LOCAL_STORAGE_SIGNING_SECRET (or SESSION_SECRET) must be set in production " +
|
||||
"when using the local storage driver.",
|
||||
);
|
||||
}
|
||||
return "tx-dev-local-storage-secret-change-before-deploy";
|
||||
})();
|
||||
|
||||
function localFsPath(bucketName: string, objectName: string): string {
|
||||
// Reject path traversal — joining and re-checking the absolute path
|
||||
// must remain inside LOCAL_STORAGE_ROOT.
|
||||
const joined = path.resolve(LOCAL_STORAGE_ROOT, bucketName, objectName);
|
||||
if (!joined.startsWith(LOCAL_STORAGE_ROOT + path.sep) && joined !== LOCAL_STORAGE_ROOT) {
|
||||
throw new Error("Invalid object path");
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
function localMetadataPath(bucketName: string, objectName: string): string {
|
||||
return localFsPath(bucketName, objectName) + ".meta.json";
|
||||
}
|
||||
|
||||
interface LocalMetadataFile {
|
||||
contentType?: string;
|
||||
aclPolicy?: ObjectAclPolicy | null;
|
||||
}
|
||||
|
||||
class LocalStoredObject implements StoredObject {
|
||||
constructor(public bucketName: string, public objectName: string) {}
|
||||
|
||||
async exists(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(localFsPath(this.bucketName, this.objectName));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<{
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
aclPolicy?: ObjectAclPolicy | null;
|
||||
}> {
|
||||
const fp = localFsPath(this.bucketName, this.objectName);
|
||||
const stat = await fs.stat(fp);
|
||||
let meta: LocalMetadataFile = {};
|
||||
try {
|
||||
const raw = await fs.readFile(localMetadataPath(this.bucketName, this.objectName), "utf8");
|
||||
meta = JSON.parse(raw) as LocalMetadataFile;
|
||||
} catch {
|
||||
// No sidecar metadata file — that's fine.
|
||||
}
|
||||
return {
|
||||
contentType: meta.contentType,
|
||||
size: stat.size,
|
||||
aclPolicy: meta.aclPolicy ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
createReadStream(): NodeJS.ReadableStream {
|
||||
return createReadStream(localFsPath(this.bucketName, this.objectName));
|
||||
}
|
||||
|
||||
async download(): Promise<[Buffer]> {
|
||||
const buf = await fs.readFile(localFsPath(this.bucketName, this.objectName));
|
||||
return [buf];
|
||||
}
|
||||
|
||||
async setAclPolicy(policy: ObjectAclPolicy): Promise<void> {
|
||||
if (!(await this.exists())) {
|
||||
throw new Error(`Object not found: ${this.objectName}`);
|
||||
}
|
||||
let meta: LocalMetadataFile = {};
|
||||
try {
|
||||
meta = JSON.parse(
|
||||
await fs.readFile(localMetadataPath(this.bucketName, this.objectName), "utf8"),
|
||||
) as LocalMetadataFile;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
meta.aclPolicy = policy;
|
||||
await fs.writeFile(
|
||||
localMetadataPath(this.bucketName, this.objectName),
|
||||
JSON.stringify(meta),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async getAclPolicy(): Promise<ObjectAclPolicy | null> {
|
||||
const m = await this.getMetadata();
|
||||
return m.aclPolicy ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class LocalDriver implements StorageDriver {
|
||||
getObject(bucketName: string, objectName: string): StoredObject {
|
||||
return new LocalStoredObject(bucketName, objectName);
|
||||
}
|
||||
|
||||
async signUploadUrl(
|
||||
bucketName: string,
|
||||
objectName: string,
|
||||
ttlSec: number,
|
||||
): Promise<string> {
|
||||
const exp = Math.floor(Date.now() / 1000) + ttlSec;
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ b: bucketName, o: objectName, exp }),
|
||||
).toString("base64url");
|
||||
const sig = createHmac("sha256", LOCAL_HMAC_SECRET).update(payload).digest("base64url");
|
||||
const token = `${payload}.${sig}`;
|
||||
const base = (process.env.PUBLIC_BASE_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`).replace(/\/+$/, "");
|
||||
return `${base}/api/storage/_local/upload?token=${token}`;
|
||||
}
|
||||
|
||||
parseUploadUrl(rawUrl: string): { bucketName: string; objectName: string } | null {
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
if (!u.pathname.endsWith("/api/storage/_local/upload")) return null;
|
||||
const token = u.searchParams.get("token");
|
||||
if (!token) return null;
|
||||
const verified = verifyLocalUploadToken(token);
|
||||
if (!verified) return null;
|
||||
return { bucketName: verified.b, objectName: verified.o };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a local-driver upload token. Returns the decoded payload on
|
||||
* success (signature match + not expired), null otherwise.
|
||||
*/
|
||||
export function verifyLocalUploadToken(
|
||||
token: string,
|
||||
): { b: string; o: string; exp: number } | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 2) return null;
|
||||
const [payload, sig] = parts;
|
||||
const expectedSig = createHmac("sha256", LOCAL_HMAC_SECRET).update(payload).digest("base64url");
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expectedSig);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
let decoded: { b: string; o: string; exp: number };
|
||||
try {
|
||||
decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
|
||||
b: string;
|
||||
o: string;
|
||||
exp: number;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof decoded.exp !== "number" || decoded.exp < Math.floor(Date.now() / 1000)) {
|
||||
return null;
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an upload to the local-FS storage backend. Used by the
|
||||
* /api/storage/_local/upload route.
|
||||
*/
|
||||
export async function writeLocalObject(
|
||||
bucketName: string,
|
||||
objectName: string,
|
||||
body: Readable,
|
||||
contentType?: string,
|
||||
): Promise<void> {
|
||||
const fp = localFsPath(bucketName, objectName);
|
||||
await fs.mkdir(path.dirname(fp), { recursive: true });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ws = createWriteStream(fp);
|
||||
body.pipe(ws);
|
||||
ws.on("finish", () => resolve());
|
||||
ws.on("error", reject);
|
||||
body.on("error", reject);
|
||||
});
|
||||
if (contentType) {
|
||||
let meta: LocalMetadataFile = {};
|
||||
try {
|
||||
meta = JSON.parse(await fs.readFile(localMetadataPath(bucketName, objectName), "utf8")) as LocalMetadataFile;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
meta.contentType = contentType;
|
||||
await fs.writeFile(localMetadataPath(bucketName, objectName), JSON.stringify(meta), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S3 driver — used when S3_ENDPOINT is configured. Targets MinIO or any
|
||||
// S3-compatible bucket via @aws-sdk/client-s3 + s3-request-presigner.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACL_METADATA_HEADER = "x-amz-meta-acl-policy";
|
||||
|
||||
let s3ClientSingleton: S3Client | null = null;
|
||||
function getS3Client(): S3Client {
|
||||
if (s3ClientSingleton) return s3ClientSingleton;
|
||||
const endpoint = process.env.S3_ENDPOINT;
|
||||
if (!endpoint) throw new Error("S3_ENDPOINT not set");
|
||||
const region = process.env.S3_REGION ?? "us-east-1";
|
||||
const accessKeyId = process.env.S3_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
throw new Error("S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY must be set when S3_ENDPOINT is configured");
|
||||
}
|
||||
s3ClientSingleton = new S3Client({
|
||||
endpoint,
|
||||
region,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== "false",
|
||||
});
|
||||
return s3ClientSingleton;
|
||||
}
|
||||
|
||||
class S3StoredObject implements StoredObject {
|
||||
constructor(public bucketName: string, public objectName: string) {}
|
||||
|
||||
async exists(): Promise<boolean> {
|
||||
try {
|
||||
await getS3Client().send(
|
||||
new HeadObjectCommand({ Bucket: this.bucketName, Key: this.objectName }),
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const name = (err as Error & { name?: string }).name;
|
||||
if (name === "NotFound" || name === "NoSuchKey") return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<{
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
aclPolicy?: ObjectAclPolicy | null;
|
||||
}> {
|
||||
const head = await getS3Client().send(
|
||||
new HeadObjectCommand({ Bucket: this.bucketName, Key: this.objectName }),
|
||||
);
|
||||
const aclRaw = head.Metadata?.["acl-policy"];
|
||||
let aclPolicy: ObjectAclPolicy | null = null;
|
||||
if (aclRaw) {
|
||||
try {
|
||||
aclPolicy = JSON.parse(aclRaw) as ObjectAclPolicy;
|
||||
} catch {
|
||||
aclPolicy = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
contentType: head.ContentType,
|
||||
size: head.ContentLength,
|
||||
aclPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
createReadStream(): NodeJS.ReadableStream {
|
||||
// Lazy stream: synchronously return a PassThrough that we'll pipe
|
||||
// into once the GetObject promise resolves.
|
||||
const { PassThrough } = require("stream") as typeof import("stream");
|
||||
const out = new PassThrough();
|
||||
getS3Client()
|
||||
.send(new GetObjectCommand({ Bucket: this.bucketName, Key: this.objectName }))
|
||||
.then((res) => {
|
||||
const body = res.Body as Readable | undefined;
|
||||
if (!body) {
|
||||
out.end();
|
||||
return;
|
||||
}
|
||||
body.on("error", (e) => out.destroy(e));
|
||||
body.pipe(out);
|
||||
})
|
||||
.catch((e) => out.destroy(e));
|
||||
return out;
|
||||
}
|
||||
|
||||
async download(): Promise<[Buffer]> {
|
||||
const res = await getS3Client().send(
|
||||
new GetObjectCommand({ Bucket: this.bucketName, Key: this.objectName }),
|
||||
);
|
||||
const body = res.Body as Readable | undefined;
|
||||
if (!body) return [Buffer.alloc(0)];
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of body) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk));
|
||||
}
|
||||
return [Buffer.concat(chunks)];
|
||||
}
|
||||
|
||||
async setAclPolicy(policy: ObjectAclPolicy): Promise<void> {
|
||||
// S3 supports setting metadata only via copy-self.
|
||||
const json = JSON.stringify(policy);
|
||||
await getS3Client().send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: this.objectName,
|
||||
CopySource: `/${this.bucketName}/${encodeURIComponent(this.objectName)}`,
|
||||
Metadata: { "acl-policy": json },
|
||||
MetadataDirective: "REPLACE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getAclPolicy(): Promise<ObjectAclPolicy | null> {
|
||||
return (await this.getMetadata()).aclPolicy ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class S3Driver implements StorageDriver {
|
||||
getObject(bucketName: string, objectName: string): StoredObject {
|
||||
return new S3StoredObject(bucketName, objectName);
|
||||
}
|
||||
|
||||
async signUploadUrl(
|
||||
bucketName: string,
|
||||
objectName: string,
|
||||
ttlSec: number,
|
||||
): Promise<string> {
|
||||
const cmd = new PutObjectCommand({ Bucket: bucketName, Key: objectName });
|
||||
return getSignedUrl(getS3Client(), cmd, { expiresIn: ttlSec });
|
||||
}
|
||||
|
||||
parseUploadUrl(rawUrl: string): { bucketName: string; objectName: string } | null {
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
// Path-style: /<bucket>/<key...>
|
||||
const segments = u.pathname.replace(/^\/+/, "").split("/");
|
||||
if (segments.length < 2) return null;
|
||||
const bucketName = segments[0];
|
||||
const objectName = segments.slice(1).join("/");
|
||||
return { bucketName, objectName: decodeURIComponent(objectName) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Driver selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let driverSingleton: StorageDriver | null = null;
|
||||
function driver(): StorageDriver {
|
||||
if (driverSingleton) return driverSingleton;
|
||||
const explicit = process.env.STORAGE_DRIVER;
|
||||
const useS3 = explicit ? explicit === "s3" : Boolean(process.env.S3_ENDPOINT);
|
||||
driverSingleton = useS3 ? new S3Driver() : new LocalDriver();
|
||||
return driverSingleton;
|
||||
}
|
||||
|
||||
export const ACTIVE_STORAGE_DRIVER = (): "s3" | "local" =>
|
||||
driver() instanceof S3Driver ? "s3" : "local";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ObjectStorageService — public API consumed by routes/storage.ts and
|
||||
// routes/executive-meetings.ts. Surface preserved byte-compatible with the
|
||||
// previous Replit-sidecar implementation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ObjectStorageService {
|
||||
constructor() {}
|
||||
|
||||
@@ -46,14 +419,14 @@ export class ObjectStorageService {
|
||||
new Set(
|
||||
pathsStr
|
||||
.split(",")
|
||||
.map((path) => path.trim())
|
||||
.filter((path) => path.length > 0)
|
||||
)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0),
|
||||
),
|
||||
);
|
||||
if (paths.length === 0) {
|
||||
throw new Error(
|
||||
"PUBLIC_OBJECT_SEARCH_PATHS not set. Create a bucket in 'Object Storage' " +
|
||||
"tool and set PUBLIC_OBJECT_SEARCH_PATHS env var (comma-separated paths)."
|
||||
"PUBLIC_OBJECT_SEARCH_PATHS not set. Set this env var to a comma-separated " +
|
||||
"list of /<bucket>/<prefix> paths (see .env.example).",
|
||||
);
|
||||
}
|
||||
return paths;
|
||||
@@ -63,43 +436,37 @@ export class ObjectStorageService {
|
||||
const dir = process.env.PRIVATE_OBJECT_DIR || "";
|
||||
if (!dir) {
|
||||
throw new Error(
|
||||
"PRIVATE_OBJECT_DIR not set. Create a bucket in 'Object Storage' " +
|
||||
"tool and set PRIVATE_OBJECT_DIR env var."
|
||||
"PRIVATE_OBJECT_DIR not set. Set this env var to /<bucket>/<prefix> " +
|
||||
"(see .env.example).",
|
||||
);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
async searchPublicObject(filePath: string): Promise<File | null> {
|
||||
async searchPublicObject(filePath: string): Promise<StoredObject | null> {
|
||||
for (const searchPath of this.getPublicObjectSearchPaths()) {
|
||||
const fullPath = `${searchPath}/${filePath}`;
|
||||
|
||||
const { bucketName, objectName } = parseObjectPath(fullPath);
|
||||
const bucket = objectStorageClient.bucket(bucketName);
|
||||
const file = bucket.file(objectName);
|
||||
|
||||
const [exists] = await file.exists();
|
||||
if (exists) {
|
||||
return file;
|
||||
const obj = driver().getObject(bucketName, objectName);
|
||||
if (await obj.exists()) {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async downloadObject(file: File, cacheTtlSec: number = 3600): Promise<Response> {
|
||||
const [metadata] = await file.getMetadata();
|
||||
const aclPolicy = await getObjectAclPolicy(file);
|
||||
const isPublic = aclPolicy?.visibility === "public";
|
||||
async downloadObject(file: StoredObject, cacheTtlSec: number = 3600): Promise<Response> {
|
||||
const metadata = await file.getMetadata();
|
||||
const isPublic = metadata.aclPolicy?.visibility === "public";
|
||||
|
||||
const nodeStream = file.createReadStream();
|
||||
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
|
||||
const webStream = Readable.toWeb(nodeStream as Readable) as ReadableStream;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": (metadata.contentType as string) || "application/octet-stream",
|
||||
"Content-Type": metadata.contentType ?? "application/octet-stream",
|
||||
"Cache-Control": `${isPublic ? "public" : "private"}, max-age=${cacheTtlSec}`,
|
||||
};
|
||||
if (metadata.size) {
|
||||
if (typeof metadata.size === "number") {
|
||||
headers["Content-Length"] = String(metadata.size);
|
||||
}
|
||||
|
||||
@@ -108,84 +475,68 @@ export class ObjectStorageService {
|
||||
|
||||
async getObjectEntityUploadURL(): Promise<string> {
|
||||
const privateObjectDir = this.getPrivateObjectDir();
|
||||
if (!privateObjectDir) {
|
||||
throw new Error(
|
||||
"PRIVATE_OBJECT_DIR not set. Create a bucket in 'Object Storage' " +
|
||||
"tool and set PRIVATE_OBJECT_DIR env var."
|
||||
);
|
||||
}
|
||||
|
||||
const objectId = randomUUID();
|
||||
const fullPath = `${privateObjectDir}/uploads/${objectId}`;
|
||||
|
||||
const { bucketName, objectName } = parseObjectPath(fullPath);
|
||||
|
||||
return signObjectURL({
|
||||
bucketName,
|
||||
objectName,
|
||||
method: "PUT",
|
||||
ttlSec: 900,
|
||||
});
|
||||
return driver().signUploadUrl(bucketName, objectName, 900);
|
||||
}
|
||||
|
||||
async getObjectEntityFile(objectPath: string): Promise<File> {
|
||||
async getObjectEntityFile(objectPath: string): Promise<StoredObject> {
|
||||
if (!objectPath.startsWith("/objects/")) {
|
||||
throw new ObjectNotFoundError();
|
||||
}
|
||||
|
||||
const parts = objectPath.slice(1).split("/");
|
||||
if (parts.length < 2) {
|
||||
throw new ObjectNotFoundError();
|
||||
}
|
||||
|
||||
const entityId = parts.slice(1).join("/");
|
||||
let entityDir = this.getPrivateObjectDir();
|
||||
if (!entityDir.endsWith("/")) {
|
||||
entityDir = `${entityDir}/`;
|
||||
}
|
||||
if (!entityDir.endsWith("/")) entityDir = `${entityDir}/`;
|
||||
const objectEntityPath = `${entityDir}${entityId}`;
|
||||
const { bucketName, objectName } = parseObjectPath(objectEntityPath);
|
||||
const bucket = objectStorageClient.bucket(bucketName);
|
||||
const objectFile = bucket.file(objectName);
|
||||
const [exists] = await objectFile.exists();
|
||||
if (!exists) {
|
||||
const obj = driver().getObject(bucketName, objectName);
|
||||
if (!(await obj.exists())) {
|
||||
throw new ObjectNotFoundError();
|
||||
}
|
||||
return objectFile;
|
||||
return obj;
|
||||
}
|
||||
|
||||
normalizeObjectEntityPath(rawPath: string): string {
|
||||
if (!rawPath.startsWith("https://storage.googleapis.com/")) {
|
||||
return rawPath;
|
||||
// Try the active driver's URL recogniser first — strip query string,
|
||||
// map a signed-URL back to its /objects/<id> form.
|
||||
const noQuery = rawPath.split("?")[0]!;
|
||||
const parsed = driver().parseUploadUrl(rawPath);
|
||||
let candidatePath = noQuery;
|
||||
if (parsed) {
|
||||
candidatePath = `/${parsed.bucketName}/${parsed.objectName}`;
|
||||
} else {
|
||||
// Legacy GCS URL form (pre-migration data) — strip the host.
|
||||
try {
|
||||
const u = new URL(rawPath);
|
||||
if (u.pathname.startsWith("/")) candidatePath = u.pathname;
|
||||
} catch {
|
||||
// already a relative path
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(rawPath);
|
||||
const rawObjectPath = url.pathname;
|
||||
|
||||
let objectEntityDir = this.getPrivateObjectDir();
|
||||
if (!objectEntityDir.endsWith("/")) {
|
||||
objectEntityDir = `${objectEntityDir}/`;
|
||||
}
|
||||
if (!objectEntityDir.endsWith("/")) objectEntityDir = `${objectEntityDir}/`;
|
||||
|
||||
if (!rawObjectPath.startsWith(objectEntityDir)) {
|
||||
return rawObjectPath;
|
||||
if (!candidatePath.startsWith(objectEntityDir)) {
|
||||
return candidatePath;
|
||||
}
|
||||
|
||||
const entityId = rawObjectPath.slice(objectEntityDir.length);
|
||||
const entityId = candidatePath.slice(objectEntityDir.length);
|
||||
return `/objects/${entityId}`;
|
||||
}
|
||||
|
||||
async trySetObjectEntityAclPolicy(
|
||||
rawPath: string,
|
||||
aclPolicy: ObjectAclPolicy
|
||||
aclPolicy: ObjectAclPolicy,
|
||||
): Promise<string> {
|
||||
const normalizedPath = this.normalizeObjectEntityPath(rawPath);
|
||||
if (!normalizedPath.startsWith("/")) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
if (!normalizedPath.startsWith("/")) return normalizedPath;
|
||||
const objectFile = await this.getObjectEntityFile(normalizedPath);
|
||||
await setObjectAclPolicy(objectFile, aclPolicy);
|
||||
await objectFile.setAclPolicy(aclPolicy);
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
@@ -195,7 +546,7 @@ export class ObjectStorageService {
|
||||
requestedPermission,
|
||||
}: {
|
||||
userId?: string;
|
||||
objectFile: File;
|
||||
objectFile: StoredObject;
|
||||
requestedPermission?: ObjectPermission;
|
||||
}): Promise<boolean> {
|
||||
return canAccessObject({
|
||||
@@ -206,62 +557,13 @@ export class ObjectStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
function parseObjectPath(path: string): {
|
||||
bucketName: string;
|
||||
objectName: string;
|
||||
} {
|
||||
if (!path.startsWith("/")) {
|
||||
path = `/${path}`;
|
||||
}
|
||||
const pathParts = path.split("/");
|
||||
function parseObjectPath(p: string): { bucketName: string; objectName: string } {
|
||||
if (!p.startsWith("/")) p = `/${p}`;
|
||||
const pathParts = p.split("/");
|
||||
if (pathParts.length < 3) {
|
||||
throw new Error("Invalid path: must contain at least a bucket name");
|
||||
}
|
||||
|
||||
const bucketName = pathParts[1];
|
||||
const objectName = pathParts.slice(2).join("/");
|
||||
|
||||
return {
|
||||
bucketName,
|
||||
objectName,
|
||||
};
|
||||
}
|
||||
|
||||
async function signObjectURL({
|
||||
bucketName,
|
||||
objectName,
|
||||
method,
|
||||
ttlSec,
|
||||
}: {
|
||||
bucketName: string;
|
||||
objectName: string;
|
||||
method: "GET" | "PUT" | "DELETE" | "HEAD";
|
||||
ttlSec: number;
|
||||
}): Promise<string> {
|
||||
const request = {
|
||||
bucket_name: bucketName,
|
||||
object_name: objectName,
|
||||
method,
|
||||
expires_at: new Date(Date.now() + ttlSec * 1000).toISOString(),
|
||||
};
|
||||
const response = await fetch(
|
||||
`${REPLIT_SIDECAR_ENDPOINT}/object-storage/signed-object-url`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to sign object URL, errorcode: ${response.status}, ` +
|
||||
`make sure you're running on Replit`
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { signed_url: string };
|
||||
return data.signed_url;
|
||||
return { bucketName, objectName };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import notificationsRouter from "./notifications";
|
||||
import usersRouter from "./users";
|
||||
import statsRouter from "./stats";
|
||||
import storageRouter from "./storage";
|
||||
import storageLocalUploadRouter from "./storage-local-upload";
|
||||
import settingsRouter from "./settings";
|
||||
import notesRouter from "./notes";
|
||||
import groupsRouter from "./groups";
|
||||
@@ -26,6 +27,7 @@ router.use(notificationsRouter);
|
||||
router.use(usersRouter);
|
||||
router.use(statsRouter);
|
||||
router.use(storageRouter);
|
||||
router.use(storageLocalUploadRouter);
|
||||
router.use(settingsRouter);
|
||||
router.use(notesRouter);
|
||||
router.use(groupsRouter);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Router, type IRouter, type Request, type Response } from "express";
|
||||
import { verifyLocalUploadToken, writeLocalObject, ACTIVE_STORAGE_DRIVER } from "../lib/objectStorage";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
/**
|
||||
* PUT /storage/_local/upload?token=<HMAC>
|
||||
*
|
||||
* Receives the body of a presigned upload issued by the local-FS storage
|
||||
* driver. The token is HMAC-signed with LOCAL_STORAGE_SIGNING_SECRET (or
|
||||
* SESSION_SECRET as fallback) and carries the bucket+object key + expiry.
|
||||
* Only mounted when STORAGE_DRIVER=local (or unset and S3 not configured);
|
||||
* when S3 is the active driver, this endpoint refuses to run.
|
||||
*/
|
||||
router.put("/storage/_local/upload", async (req: Request, res: Response) => {
|
||||
if (ACTIVE_STORAGE_DRIVER() !== "local") {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
const token = typeof req.query.token === "string" ? req.query.token : null;
|
||||
if (!token) {
|
||||
res.status(400).json({ error: "Missing token" });
|
||||
return;
|
||||
}
|
||||
const verified = verifyLocalUploadToken(token);
|
||||
if (!verified) {
|
||||
res.status(403).json({ error: "Invalid or expired upload token" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const contentType = typeof req.headers["content-type"] === "string"
|
||||
? req.headers["content-type"]
|
||||
: undefined;
|
||||
await writeLocalObject(verified.b, verified.o, req, contentType);
|
||||
res.status(200).json({ ok: true });
|
||||
} catch (err) {
|
||||
req.log.error({ err }, "local storage upload failed");
|
||||
res.status(500).json({ error: "Upload failed" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -38,8 +38,6 @@
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@replit/vite-plugin-cartographer": "catalog:",
|
||||
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
import runtimeErrorOverlay from "@replit/vite-plugin-runtime-error-modal";
|
||||
import { mockupPreviewPlugin } from "./mockupPreviewPlugin";
|
||||
|
||||
const rawPort = process.env.PORT;
|
||||
@@ -29,22 +28,7 @@ if (!basePath) {
|
||||
|
||||
export default defineConfig({
|
||||
base: basePath,
|
||||
plugins: [
|
||||
mockupPreviewPlugin(),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
runtimeErrorOverlay(),
|
||||
...(process.env.NODE_ENV !== "production" &&
|
||||
process.env.REPL_ID !== undefined
|
||||
? [
|
||||
await import("@replit/vite-plugin-cartographer").then((m) =>
|
||||
m.cartographer({
|
||||
root: path.resolve(import.meta.dirname, ".."),
|
||||
}),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
plugins: [mockupPreviewPlugin(), react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(import.meta.dirname, "src"),
|
||||
|
||||
@@ -41,9 +41,6 @@
|
||||
"@radix-ui/react-toggle": "^1.1.3",
|
||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@replit/vite-plugin-cartographer": "catalog:",
|
||||
"@replit/vite-plugin-dev-banner": "catalog:",
|
||||
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
import runtimeErrorOverlay from "@replit/vite-plugin-runtime-error-modal";
|
||||
|
||||
const rawPort = process.env.PORT;
|
||||
|
||||
@@ -26,30 +25,14 @@ if (!basePath) {
|
||||
);
|
||||
}
|
||||
|
||||
const apiTarget = process.env.VITE_API_PROXY_TARGET ?? "http://localhost:8080";
|
||||
|
||||
export default defineConfig({
|
||||
base: basePath,
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
runtimeErrorOverlay(),
|
||||
...(process.env.NODE_ENV !== "production" &&
|
||||
process.env.REPL_ID !== undefined
|
||||
? [
|
||||
await import("@replit/vite-plugin-cartographer").then((m) =>
|
||||
m.cartographer({
|
||||
root: path.resolve(import.meta.dirname, ".."),
|
||||
}),
|
||||
),
|
||||
await import("@replit/vite-plugin-dev-banner").then((m) =>
|
||||
m.devBanner(),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(import.meta.dirname, "src"),
|
||||
"@assets": path.resolve(import.meta.dirname, "..", "..", "attached_assets"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
@@ -68,7 +51,7 @@ export default defineConfig({
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8080",
|
||||
target: apiTarget,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 431 KiB |
@@ -1,248 +0,0 @@
|
||||
أريد بناء نظام تنبيهات كامل داخل الاجتماعات الاجتماعات:
|
||||
|
||||
|
||||
عرض تنبيه قبل الاجتماع القادم بـ 5 دقائق بطريقة واضحة وبسيطة ومناسبة
|
||||
|
||||
========================================
|
||||
🧠 منطق النظام (Logic)
|
||||
========================================
|
||||
|
||||
1. جلب اجتماعات اليوم فقط.
|
||||
2. استبعاد الاجتماعات:
|
||||
- status = cancelled
|
||||
- status = completed
|
||||
3. ترتيب الاجتماعات حسب startTime تصاعديًا.
|
||||
4. تحديد الاجتماع القادم (أقرب اجتماع لم يبدأ بعد).
|
||||
5. حساب الفرق بين الوقت الحالي ووقت بداية الاجتماع.
|
||||
|
||||
إذا:
|
||||
remainingTime <= 5 دقائق AND remainingTime > 0
|
||||
|
||||
→ يظهر التنبيه.
|
||||
|
||||
========================================
|
||||
🚫 شروط عدم الظهور
|
||||
========================================
|
||||
|
||||
- إذا تم الضغط على "تم"
|
||||
- إذا تم الضغط على "حذف التنبيه"
|
||||
- إذا تم إلغاء الاجتماع
|
||||
- إذا انتهى الاجتماع
|
||||
-
|
||||
|
||||
========================================
|
||||
🖥️ واجهة التنبيه (UI)
|
||||
========================================
|
||||
|
||||
يظهر كـ:
|
||||
Modal أو Toast في أعلى/وسط الشاشة يستطيع المستخدم تحريكه على اي اتجاه.
|
||||
|
||||
وكذالك التنبيه يظهر حتى لو انه خارج تطبيق الاجتماعات يظهر
|
||||
|
||||
يحتوي على:
|
||||
|
||||
- عنوان:
|
||||
باقي على الاجتماع القادم 5 دقائق
|
||||
|
||||
- اسم الاجتماع
|
||||
|
||||
- الوقت:
|
||||
مثال:
|
||||
10:00
|
||||
10:30
|
||||
|
||||
|
||||
- تصميم:
|
||||
- رسمي
|
||||
- بسيط
|
||||
- مناسب لجميع الاجهزه
|
||||
- RTL
|
||||
|
||||
========================================
|
||||
🔘 الأزرار داخل التنبيه
|
||||
========================================
|
||||
|
||||
1) زر: تم
|
||||
- إخفاء التنبيه
|
||||
- لا تعديل على الاجتماع
|
||||
- تسجيل في Audit Log:
|
||||
meeting_alert_acknowledged
|
||||
|
||||
----------------------------------------
|
||||
|
||||
2) زر: تأجيل
|
||||
عند الضغط:
|
||||
يفتح Modal يحتوي 3 أقسام:
|
||||
|
||||
----------------------------------------
|
||||
🔹 القسم الأول: تأجيل بالدقائق
|
||||
----------------------------------------
|
||||
|
||||
خيارات:
|
||||
- 5 دقائق
|
||||
- 10 دقائق
|
||||
- 15 دقيقة
|
||||
- 30 دقيقة
|
||||
- 45 دقيقة
|
||||
- 60 دقيقة
|
||||
|
||||
عند اختيار مدة:
|
||||
- تعديل startTime و endTime مباشرة
|
||||
- مثال:
|
||||
10:00 - 10:30 → 10:15 - 10:45
|
||||
- إغلاق التنبيه
|
||||
- إعادة ترتيب جدول اليوم حسب الوقت
|
||||
- إعادة ترقيم عمود "#"
|
||||
- تسجيل:
|
||||
meeting_postponed_minutes
|
||||
|
||||
----------------------------------------
|
||||
🔹 القسم الثاني: تأجيل إلى وقت لاحق
|
||||
----------------------------------------
|
||||
|
||||
نموذج يحتوي:
|
||||
- التاريخ
|
||||
- وقت البداية
|
||||
- وقت النهاية
|
||||
- ملاحظة (اختياري)
|
||||
|
||||
عند الحفظ:
|
||||
- تحديث الاجتماع مباشرة
|
||||
- إذا نفس اليوم:
|
||||
→ إعادة ترتيب الجدول
|
||||
- إذا يوم آخر:
|
||||
→ إزالته من جدول اليوم
|
||||
- إعادة ترقيم "م"
|
||||
- تسجيل:
|
||||
meeting_rescheduled
|
||||
|
||||
----------------------------------------
|
||||
🔹 القسم الثالث: إلغاء الاجتماع
|
||||
----------------------------------------
|
||||
|
||||
عند الضغط:
|
||||
يظهر Confirmation:
|
||||
|
||||
"هل أنت متأكد من إلغاء هذا الاجتماع من جدول اليوم؟"
|
||||
|
||||
الأزرار:
|
||||
- نعم، إلغاء الاجتماع
|
||||
- تراجع
|
||||
|
||||
عند التأكيد:
|
||||
- status = cancelled
|
||||
- لا حذف من قاعدة البيانات
|
||||
- إخفاء من جدول اليوم
|
||||
- إعادة ترتيب الجدول
|
||||
- إعادة ترقيم "#"
|
||||
- تسجيل:
|
||||
meeting_cancelled
|
||||
|
||||
----------------------------------------
|
||||
|
||||
3) زر: حذف التنبيه
|
||||
- إخفاء التنبيه فقط
|
||||
- لا تعديل على الاجتماع
|
||||
- تسجيل:
|
||||
meeting_alert_dismissed
|
||||
|
||||
========================================
|
||||
⚙️ إعادة ترتيب الجدول (Auto Reordering)
|
||||
========================================
|
||||
|
||||
بعد أي عملية:
|
||||
- تأجيل
|
||||
- إعادة جدولة
|
||||
- إلغاء
|
||||
|
||||
يجب:
|
||||
- sort by startTime ASC
|
||||
- إعادة ترقيم عمود "#"
|
||||
- تحديث العرض مباشرة بدون Refresh كامل
|
||||
|
||||
========================================
|
||||
⚠️ التعارضات (Conflict)
|
||||
========================================
|
||||
|
||||
إذا أصبح هناك تداخل في الأوقات:
|
||||
- لا تمنع العملية
|
||||
- اعرض رسالة بسيطة:
|
||||
"يوجد تعارض محتمل في وقت الاجتماعات"
|
||||
|
||||
========================================
|
||||
🗄️ قاعدة البيانات (اختياري إذا غير موجود)
|
||||
========================================
|
||||
|
||||
جدول:
|
||||
executive_meeting_alert_state
|
||||
|
||||
حقول:
|
||||
- id
|
||||
- meetingId
|
||||
- userId
|
||||
- dismissed (boolean)
|
||||
- acknowledged (boolean)
|
||||
- updatedAt
|
||||
|
||||
========================================
|
||||
📜 Audit Log
|
||||
========================================
|
||||
|
||||
سجل العمليات:
|
||||
|
||||
- meeting_alert_shown
|
||||
- meeting_alert_acknowledged
|
||||
- meeting_alert_dismissed
|
||||
- meeting_postponed_minutes
|
||||
- meeting_rescheduled
|
||||
- meeting_cancelled
|
||||
|
||||
========================================
|
||||
🧩 Frontend
|
||||
========================================
|
||||
|
||||
Component جديد:
|
||||
UpcomingMeetingAlert
|
||||
|
||||
يرتبط بـ:
|
||||
- صفحة جدول الاجتماعات اليومية
|
||||
|
||||
يعمل:
|
||||
- polling أو timer كل 30 ثانية
|
||||
- حساب الاجتماع القادم
|
||||
- إظهار التنبيه عند الحاجة
|
||||
|
||||
========================================
|
||||
🧪 الاختبار
|
||||
========================================
|
||||
|
||||
1. أضف اجتماع بعد 5 دقائق
|
||||
→ يجب أن يظهر التنبيه
|
||||
|
||||
2. اضغط "تم"
|
||||
→ يختفي
|
||||
|
||||
3. اضغط "تأجيل 10 دقائق"
|
||||
→ يتغير الوقت + يعاد ترتيب الجدول
|
||||
|
||||
4. اضغط "تأجيل إلى وقت لاحق"
|
||||
→ يتحدث الاجتماع
|
||||
|
||||
5. اضغط "إلغاء"
|
||||
→ يختفي من الجدول
|
||||
|
||||
6. تأكد:
|
||||
|
||||
- كل شيء مباشر
|
||||
|
||||
========================================
|
||||
🚫 قيود مهمة
|
||||
========================================
|
||||
|
||||
- لا تغيّر النظام العام
|
||||
- لا تضف Workflow
|
||||
- لا تضف Roles جديدة
|
||||
- التعديل فقط داخل Executive Meetings
|
||||
- الحفاظ على التصميم الرسمي والبسيط
|
||||
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
أريد إضافة Module جديد داخل النظام الحالي بدون تخريب أي وظائف موجودة، وبدون إعادة بناء النظام من الصفر.
|
||||
|
||||
اسم التطبيق:
|
||||
إدارة الاجتماعات التنفيذية
|
||||
Executive Meetings Management
|
||||
|
||||
مهم جدًا:
|
||||
- لا تغيّر النظام الحالي أو الصلاحيات الحالية إلا بإضافة ما يلزم لهذا التطبيق.
|
||||
- لا تحذف أي صفحات أو جداول أو مكونات موجودة.
|
||||
- نفّذ التطبيق كـ Module مستقل داخل النظام.
|
||||
- استخدم الصورة المرفقة كمرجع أساسي لتصميم جدول الاجتماعات اليومي.
|
||||
- المطلوب تصميم iPad First ثم Responsive للجوال والمتصفح.
|
||||
- الواجهة عربية RTL مع دعم English لاحقًا.
|
||||
|
||||
فكرة التطبيق:
|
||||
النظام يدير جدول اجتماعات الرئيس التنفيذي يوميًا بشكل حي بدل PDF ثابت وتعديلات واتساب.
|
||||
|
||||
مسار العمل:
|
||||
الرئيس التنفيذي
|
||||
↓
|
||||
مدير المكتب التنفيذي
|
||||
↓
|
||||
إدارة التنسيق والتوثيق
|
||||
↓
|
||||
تحديث الجدول
|
||||
↓
|
||||
الرئيس يشاهد التحديث من الآيباد
|
||||
|
||||
الأدوار:
|
||||
1. الرئيس التنفيذي:
|
||||
- يشاهد جدول اليوم.
|
||||
- يشاهد الاجتماع القادم.
|
||||
- يشاهد الوقت المتبقي.
|
||||
- يشاهد الحضور والمكان والرابط والملاحظات والمرفقات والحالة.
|
||||
- لا يعدل مباشرة.
|
||||
- يستطيع فقط إرسال طلب:
|
||||
- طلب تعديل
|
||||
- طلب إلغاء
|
||||
- طلب تأجيل
|
||||
- إضافة ملاحظة
|
||||
- طلب إضافة حضور
|
||||
- طلب حذف حضور
|
||||
|
||||
2. مدير المكتب التنفيذي:
|
||||
- يستقبل طلبات الرئيس أولًا.
|
||||
- يراجع الطلب.
|
||||
- يستطيع:
|
||||
- اعتماد
|
||||
- رفض
|
||||
- تعديل التوجيه
|
||||
- تحويل الطلب لنوع آخر
|
||||
- إرسال الطلب لإدارة التنسيق
|
||||
- تعديل الاجتماع عند الحاجة
|
||||
|
||||
3. إدارة التنسيق والتوثيق:
|
||||
- تنفذ التوجيهات.
|
||||
- تعدل وقت الاجتماع.
|
||||
- تعدل الحضور.
|
||||
- تعدل القاعة أو رابط الاجتماع.
|
||||
- ترفع المرفقات.
|
||||
- توثق التعديلات.
|
||||
- تحدث الحالة.
|
||||
- تصدر PDF عند الحاجة.
|
||||
|
||||
4. رئيس التنسيق:
|
||||
- يعتمد النشر اليومي للجدول.
|
||||
|
||||
5. مشاهد:
|
||||
- عرض فقط.
|
||||
|
||||
6. مدير النظام:
|
||||
- إدارة المستخدمين والصلاحيات.
|
||||
|
||||
أضف في القائمة الجانبية Module باسم:
|
||||
Executive Meetings
|
||||
|
||||
ويحتوي على الصفحات التالية:
|
||||
- جدول الاجتماعات
|
||||
- إدارة الاجتماعات
|
||||
- طلبات التعديل
|
||||
- موافقات مدير المكتب
|
||||
- مهام فريق التنسيق
|
||||
- التنبيهات
|
||||
- سجل التعديلات
|
||||
- PDF Export
|
||||
- Font Settings
|
||||
|
||||
الواجهة الرئيسية:
|
||||
اسم الصفحة:
|
||||
جدول الاجتماعات اليومية
|
||||
|
||||
Header:
|
||||
في أعلى الصفحة من اليسار:
|
||||
- شعار الجهة الرسمي Logo
|
||||
- بجانبه:
|
||||
إدارة الاجتماعات التنفيذية
|
||||
Executive Meetings Management
|
||||
|
||||
في أعلى الصفحة من اليمين:
|
||||
- زر تصدير PDF
|
||||
- اختيار نوع الخط
|
||||
- حجم الخط: 12 / 14 / 16 / 18
|
||||
- سماكة الخط: Regular / Medium / Bold
|
||||
- تغيير نمط العرض
|
||||
- اللغة عربي / English
|
||||
- الوضع الليلي اختياري
|
||||
|
||||
الجدول الرئيسي:
|
||||
يجب أن يكون بنفس أسلوب الصورة المرفقة رسمي وواضح.
|
||||
|
||||
الأعمدة فقط 4:
|
||||
| # | اسم الاجتماع | الحضور | الوقت |
|
||||
|
||||
مهم:
|
||||
- العمود # صغير.
|
||||
- عمود اسم الاجتماع متوسط.
|
||||
- عمود الحضور هو الأكبر.
|
||||
- عمود الوقت واضح.
|
||||
- رأس الجدول باللون الأزرق الداكن.
|
||||
- حدود الجدول رسمية وواضحة.
|
||||
- الخط واضح ومناسب للطباعة والآيباد.
|
||||
- أضف خيار رفع Logo لاحقًا واستخدامه في رأس الصفحة و PDF.
|
||||
|
||||
تفاصيل الأعمدة:
|
||||
|
||||
1. عمود #:
|
||||
يعرض رقم الاجتماع اليومي:
|
||||
1
|
||||
2
|
||||
3
|
||||
4
|
||||
|
||||
2. عمود اسم الاجتماع:
|
||||
يعرض اسم الاجتماع الرسمي.
|
||||
أمثلة:
|
||||
- اجتماع الإدارة
|
||||
- اجتماع تحضيري
|
||||
- اجتماع اعتماد البرامج
|
||||
- اتصال مرئي عبر Webex
|
||||
|
||||
إذا كان الاجتماع مهم أو ملغي أو يحتاج تنبيه، يمكن تمييزه بلون رسمي بدون مبالغة.
|
||||
|
||||
3. عمود الحضور:
|
||||
هذا أهم عمود.
|
||||
|
||||
طريقة العرض المطلوبة:
|
||||
الحضور يكون:
|
||||
- مرتب
|
||||
- مرقم
|
||||
- موزع أفقيًا داخل الخانة
|
||||
- ليس قائمة عمودية طويلة
|
||||
|
||||
مثال صحيح:
|
||||
1- أحمد 2- محمد 3- خالد
|
||||
4- عبدالله 5- فهد
|
||||
|
||||
مثال خاطئ:
|
||||
1- أحمد
|
||||
2- محمد
|
||||
3- خالد
|
||||
4- عبدالله
|
||||
5- فهد
|
||||
|
||||
نفّذ هذا تقنيًا باستخدام:
|
||||
CSS Grid أو Flex Wrap
|
||||
|
||||
بحيث تتوزع الأسماء تلقائيًا داخل الخانة.
|
||||
|
||||
إذا كان الاجتماع يحتوي حضور عن بعد:
|
||||
Webex / Teams / Zoom
|
||||
|
||||
قسّم الحضور داخل نفس الخانة إلى أقسام:
|
||||
|
||||
الحضور عبر الاتصال المرئي:
|
||||
1- أحمد 2- محمد 3- خالد
|
||||
|
||||
الحضور الداخلي:
|
||||
1- عبدالله 2- فهد 3- سالم
|
||||
|
||||
يجب أن يظهر العنوان الفرعي داخل الخانة بلون أزرق رسمي وبخط واضح كما في الصورة المرفقة.
|
||||
|
||||
4. عمود الوقت:
|
||||
يعرض البداية والنهاية بشكل واضح ويفضل رأسيًا:
|
||||
10:00
|
||||
10:30
|
||||
|
||||
أو:
|
||||
10:00 AM
|
||||
10:30 AM
|
||||
|
||||
حالات الاجتماع:
|
||||
أضف status لكل اجتماع:
|
||||
- scheduled = مجدول
|
||||
- confirmed = مؤكد
|
||||
- needs_confirmation = يحتاج تأكيد
|
||||
- modified = معدل
|
||||
- postponed = مؤجل
|
||||
- cancelled = ملغي
|
||||
- completed = مكتمل
|
||||
|
||||
طلبات التعديل:
|
||||
كل طلب تعديل له دورة حياة:
|
||||
- new = جديد
|
||||
- under_office_review = قيد مراجعة مدير المكتب
|
||||
- approved = معتمد
|
||||
- sent_to_coordination = مرسل للتنسيق
|
||||
- in_progress = قيد التنفيذ
|
||||
- completed = تم التنفيذ
|
||||
- rejected = مرفوض
|
||||
- closed = مغلق
|
||||
|
||||
أنواع طلبات الرئيس:
|
||||
- تعديل وقت
|
||||
- إلغاء
|
||||
- تأجيل
|
||||
- إضافة ملاحظة
|
||||
- إضافة حضور
|
||||
- حذف حضور
|
||||
- تعديل مكان
|
||||
- تعديل رابط اجتماع
|
||||
|
||||
التنبيهات:
|
||||
أضف نظام تنبيهات قابل للإعداد من مدير المكتب:
|
||||
- قبل الاجتماع بـ 15 دقيقة
|
||||
- قبل الاجتماع بـ 5 دقائق
|
||||
- عند طلب تعديل من الرئيس
|
||||
- عند اعتماد مدير المكتب
|
||||
- عند تنفيذ التعديل
|
||||
- عند تغيير وقت الاجتماع
|
||||
- عند إلغاء الاجتماع
|
||||
|
||||
Font Settings:
|
||||
أضف صفحة إعدادات الخط داخل التطبيق:
|
||||
- اختيار نوع الخط
|
||||
- دعم رفع خط مخصص لاحقًا
|
||||
- حجم الخط 12 / 14 / 16 / 18
|
||||
- السماكة Regular / Medium / Bold
|
||||
- محاذاة النص
|
||||
- حفظ الإعدادات وتطبيقها على الجدول و PDF
|
||||
|
||||
PDF:
|
||||
رغم أن الهدف تقليل الاعتماد على PDF، أريد:
|
||||
- Export PDF
|
||||
- Download PDF
|
||||
- Send PDF placeholder فقط إذا لا يوجد نظام إرسال
|
||||
- Archive PDF by date
|
||||
- PDF يستخدم نفس تصميم الجدول
|
||||
- PDF يحتوي Logo Header
|
||||
- PDF يدعم العربية RTL
|
||||
|
||||
Database:
|
||||
أضف الجداول أو الموديلات اللازمة بدون كسر الموجود:
|
||||
- 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
|
||||
|
||||
البيانات المطلوبة للاجتماع:
|
||||
- id
|
||||
- dailyNumber
|
||||
- titleAr
|
||||
- titleEn
|
||||
- date
|
||||
- startTime
|
||||
- endTime
|
||||
- location
|
||||
- meetingUrl
|
||||
- platform: none/webex/teams/zoom/other
|
||||
- status
|
||||
- notes
|
||||
- attachments
|
||||
- createdBy
|
||||
- updatedBy
|
||||
- timestamps
|
||||
|
||||
الحضور:
|
||||
- meetingId
|
||||
- name
|
||||
- title optional
|
||||
- attendanceType: internal / virtual / external
|
||||
- sortOrder
|
||||
|
||||
طلبات التعديل:
|
||||
- meetingId
|
||||
- requestedBy
|
||||
- requestType
|
||||
- requestDetails
|
||||
- status
|
||||
- reviewedBy
|
||||
- reviewDecision
|
||||
- reviewNotes
|
||||
- assignedTo
|
||||
- timestamps
|
||||
|
||||
المهام:
|
||||
- requestId
|
||||
- assignedTo
|
||||
- taskType
|
||||
- status
|
||||
- dueAt
|
||||
- completedAt
|
||||
- notes
|
||||
|
||||
Audit Log:
|
||||
أي تعديل على اجتماع أو طلب يجب تسجيله:
|
||||
- action
|
||||
- entityType
|
||||
- entityId
|
||||
- oldValue
|
||||
- newValue
|
||||
- performedBy
|
||||
- performedAt
|
||||
|
||||
متطلبات الواجهة:
|
||||
- تصميم رسمي جدًا.
|
||||
- مناسب للآيباد أولًا.
|
||||
- Responsive للجوال والمتصفح.
|
||||
- دعم RTL ممتاز.
|
||||
- لا تستخدم تصميم كروت للجدول الرئيسي؛ الجدول الرسمي هو الأساس.
|
||||
- استخدم الصورة المرفقة كمرجع للشكل النهائي.
|
||||
- لا تجعل الحضور عموديًا إلا إذا ضاق العرض جدًا في الجوال.
|
||||
|
||||
المطلوب في التنفيذ الأول:
|
||||
1. إنشاء Module Executive Meetings في القائمة.
|
||||
2. إنشاء صفحة جدول الاجتماعات اليومية بنفس تصميم الصورة.
|
||||
3. إضافة بيانات تجريبية مشابهة للصورة.
|
||||
4. تنفيذ عرض الحضور الأفقي باستخدام flex-wrap أو grid.
|
||||
5. إضافة Header فيه Logo + اسم التطبيق.
|
||||
6. إضافة أزرار PDF و Font Settings.
|
||||
7. إنشاء صفحات Placeholder لباقي الأقسام.
|
||||
8. تجهيز قاعدة البيانات والـ API الأساسية.
|
||||
9. عدم كسر أي شيء موجود في النظام الحالي.
|
||||
|
||||
بعد التنفيذ اشرح لي:
|
||||
- الملفات التي تم تعديلها.
|
||||
- الملفات التي تم إنشاؤها.
|
||||
- الجداول التي أضيفت.
|
||||
- كيف أختبر الصفحة.
|
||||
- ما الذي لم يتم تنفيذه بعد.
|
||||
@@ -1,226 +0,0 @@
|
||||
المطلوب تعديل نظام Note ليصبح فيه إرسال ومشاركة بين المستخدمين.
|
||||
|
||||
الفكرة:
|
||||
المستخدم ينشئ Note جديدة، ثم يختار شخص أو أكثر لإرسالها لهم.
|
||||
عند الإرسال تظهر النوت عند المتلقي داخل النظام في قسم خاص مثل:
|
||||
- Received Notes
|
||||
أو:
|
||||
- Notes Inbox
|
||||
|
||||
كل Note مستلمة يجب أن تعرض:
|
||||
- عنوان النوت
|
||||
- محتوى النوت
|
||||
- لون النوت
|
||||
- اسم الشخص المرسل
|
||||
- تاريخ الإرسال
|
||||
- حالة القراءة Read / Unread
|
||||
- إمكانية الرد داخل نفس النوت
|
||||
|
||||
--------------------------------------------------
|
||||
1) تعديل مفهوم المشاركة
|
||||
--------------------------------------------------
|
||||
|
||||
بدلاً من مشاركة صامتة فقط، أريد نظام إرسال واضح:
|
||||
|
||||
المرسل:
|
||||
- ينشئ Note
|
||||
- يختار اللون
|
||||
- يكتب المحتوى
|
||||
- يختار المستلمين
|
||||
- يضغط Send
|
||||
|
||||
المتلقي:
|
||||
- تظهر له النوت في Notes Inbox
|
||||
- يشوف اسم المرسل
|
||||
- يشوف اللون والمحتوى
|
||||
- يستطيع الرد داخل نفس النوت
|
||||
- تبقى نسخة محفوظة عنده حتى يراجعها لاحقاً
|
||||
|
||||
--------------------------------------------------
|
||||
2) الجداول المطلوبة
|
||||
--------------------------------------------------
|
||||
|
||||
جدول notes:
|
||||
- id
|
||||
- title
|
||||
- content
|
||||
- type (note | list)
|
||||
- color
|
||||
- ownerUserId
|
||||
- createdAt
|
||||
- updatedAt
|
||||
|
||||
جدول note_recipients:
|
||||
- id
|
||||
- noteId
|
||||
- senderUserId
|
||||
- recipientUserId
|
||||
- status (unread | read | replied | archived)
|
||||
- sentAt
|
||||
- readAt
|
||||
- archivedAt
|
||||
|
||||
جدول note_replies:
|
||||
- id
|
||||
- noteId
|
||||
- senderUserId
|
||||
- recipientUserId
|
||||
- content
|
||||
- createdAt
|
||||
|
||||
مهم:
|
||||
- الردود تكون داخل نفس النوت
|
||||
- كل رد يظهر باسم كاتبه وتاريخه
|
||||
- المرسل والمتلقي فقط يشوفون المحادثة الخاصة بهم
|
||||
|
||||
--------------------------------------------------
|
||||
3) واجهة المستخدم
|
||||
--------------------------------------------------
|
||||
|
||||
داخل صفحة Note أضف تبويبات:
|
||||
|
||||
- My Notes
|
||||
- Sent Notes
|
||||
- Received Notes
|
||||
- Archived
|
||||
|
||||
في My Notes:
|
||||
- المستخدم يشوف النوتات التي أنشأها
|
||||
|
||||
في Sent Notes:
|
||||
- المستخدم يشوف النوتات التي أرسلها للآخرين
|
||||
- يظهر لكل نوت:
|
||||
- المستلمين
|
||||
- حالة كل مستلم: unread / read / replied
|
||||
|
||||
في Received Notes:
|
||||
- المستخدم يشوف النوتات التي وصلته
|
||||
- يظهر:
|
||||
- اسم المرسل
|
||||
- صورة/Avatar المرسل إن وجدت
|
||||
- عنوان النوت
|
||||
- اللون
|
||||
- مختصر المحتوى
|
||||
- حالة القراءة
|
||||
|
||||
--------------------------------------------------
|
||||
4) فتح النوت المستلمة
|
||||
--------------------------------------------------
|
||||
|
||||
عند فتح النوت المستلمة:
|
||||
- تعرض النوت الأصلية في الأعلى
|
||||
- يظهر اسم المرسل:
|
||||
"From: [User Name]"
|
||||
- يظهر اللون الذي اختاره المرسل
|
||||
- يظهر تاريخ الإرسال
|
||||
- أسفلها قسم Replies
|
||||
|
||||
المتلقي يستطيع:
|
||||
- كتابة رد
|
||||
- إرسال الرد
|
||||
- أرشفة النوت
|
||||
- تمييزها كمقروءة
|
||||
|
||||
بعد الرد:
|
||||
- تتغير حالة النوت عند المرسل إلى replied
|
||||
- يظهر الرد عند المرسل داخل Sent Notes أو داخل تفاصيل النوت
|
||||
|
||||
--------------------------------------------------
|
||||
5) النسخ والحفظ
|
||||
--------------------------------------------------
|
||||
|
||||
مهم جداً:
|
||||
- النوت الأصلية تبقى محفوظة عند المرسل
|
||||
- نسخة مستلمة تبقى محفوظة عند المتلقي في Received Notes
|
||||
- إذا حذف المرسل النوت من عنده، لا تختفي النسخة من عند المتلقي
|
||||
- إذا أرشف المتلقي النوت، لا تتأثر نسخة المرسل
|
||||
- الحذف والأرشفة تكون لكل مستخدم بشكل مستقل
|
||||
|
||||
--------------------------------------------------
|
||||
6) الصلاحيات
|
||||
--------------------------------------------------
|
||||
|
||||
- لا يستطيع أي مستخدم فتح Note لم تُرسل له أو لم ينشئها
|
||||
- المرسل يرى النوت التي أنشأها والردود الخاصة بها
|
||||
- المتلقي يرى فقط النوتات المرسلة له
|
||||
- الرد مسموح فقط للمرسل والمتلقي
|
||||
- Super Admin يمكنه رؤية كل شيء عند الحاجة من لوحة الإدارة
|
||||
|
||||
يجب تطبيق الصلاحيات في Backend وليس Frontend فقط.
|
||||
|
||||
--------------------------------------------------
|
||||
7) API المطلوبة
|
||||
--------------------------------------------------
|
||||
|
||||
POST /api/notes
|
||||
GET /api/notes/my
|
||||
GET /api/notes/sent
|
||||
GET /api/notes/received
|
||||
GET /api/notes/:id
|
||||
|
||||
POST /api/notes/:id/send
|
||||
Body:
|
||||
{
|
||||
"recipientUserIds": ["userId1", "userId2"]
|
||||
}
|
||||
|
||||
POST /api/notes/:id/reply
|
||||
Body:
|
||||
{
|
||||
"content": "reply text"
|
||||
}
|
||||
|
||||
POST /api/notes/:id/read
|
||||
POST /api/notes/:id/archive
|
||||
|
||||
--------------------------------------------------
|
||||
8) الإشعارات داخل النظام
|
||||
--------------------------------------------------
|
||||
|
||||
عند وصول Note جديدة:
|
||||
- يظهر Badge على قائمة Note
|
||||
- يظهر Toast notification
|
||||
- في Received Notes تظهر كـ Unread
|
||||
|
||||
عند وصول رد:
|
||||
- يظهر تنبيه للمرسل
|
||||
- يتم تحديث حالة النوت إلى replied
|
||||
|
||||
--------------------------------------------------
|
||||
9) تجربة المستخدم
|
||||
--------------------------------------------------
|
||||
|
||||
- زر واضح باسم Send Note
|
||||
- اختيار المستلمين من قائمة المستخدمين
|
||||
- بحث عن المستخدم بالاسم
|
||||
- عرض Chips للمستلمين المختارين
|
||||
- Confirm قبل الإرسال
|
||||
- Toast بعد الإرسال:
|
||||
"Note sent successfully"
|
||||
|
||||
--------------------------------------------------
|
||||
10) دعم العربية والإنجليزية
|
||||
--------------------------------------------------
|
||||
|
||||
كل النصوص يجب أن تكون ضمن i18n:
|
||||
- العربية RTL
|
||||
- الإنجليزية LTR
|
||||
|
||||
--------------------------------------------------
|
||||
11) المطلوب النهائي
|
||||
--------------------------------------------------
|
||||
|
||||
نفّذ الميزة بشكل حقيقي:
|
||||
- Database schema
|
||||
- Backend routes
|
||||
- Frontend UI
|
||||
- Authorization
|
||||
- Notifications badges
|
||||
- Responsive design
|
||||
- Clean code
|
||||
- Production-ready
|
||||
|
||||
مهم:
|
||||
لا تجعلها مجرد مشاركة عادية.
|
||||
أريدها كأنها "مراسلة Note داخل النظام":
|
||||
يرسل المستخدم Note، تظهر عند المتلقي، يستطيع الرد عليها، وتبقى نسخة محفوظة عند الطرفين.
|
||||
@@ -1,229 +0,0 @@
|
||||
الموضوع: إنشاء نظام "Note" داخل منصة TeaBoy
|
||||
|
||||
أريد إضافة ميزة داخل النظام باسم "Note"، تكون نظام ملاحظات بسيط واحترافي يشبه Google Keep، مع دعم:
|
||||
|
||||
- الملاحظات النصية (Note)
|
||||
- القوائم (List / Checklist)
|
||||
- الألوان
|
||||
- التثبيت (Pin)
|
||||
- المشاركة مع الأعضاء
|
||||
- الصلاحيات
|
||||
|
||||
--------------------------------------------------
|
||||
1) الكيان الرئيسي
|
||||
--------------------------------------------------
|
||||
|
||||
إنشاء جدول:
|
||||
notes
|
||||
|
||||
الحقول:
|
||||
- id
|
||||
- title
|
||||
- content (للنص)
|
||||
- type (note | list)
|
||||
- color (hex)
|
||||
- isPinned (boolean)
|
||||
- isArchived (boolean)
|
||||
- isDeleted (soft delete)
|
||||
- ownerUserId
|
||||
- createdAt
|
||||
- updatedAt
|
||||
|
||||
--------------------------------------------------
|
||||
2) عناصر القائمة (Checklist)
|
||||
--------------------------------------------------
|
||||
|
||||
جدول:
|
||||
note_items
|
||||
|
||||
الحقول:
|
||||
- id
|
||||
- noteId
|
||||
- text
|
||||
- isCompleted (boolean)
|
||||
- sortOrder
|
||||
- createdAt
|
||||
- updatedAt
|
||||
|
||||
--------------------------------------------------
|
||||
3) المشاركة (Sharing)
|
||||
--------------------------------------------------
|
||||
|
||||
جدول:
|
||||
note_shares
|
||||
|
||||
الحقول:
|
||||
- id
|
||||
- noteId
|
||||
- userId
|
||||
- permission (view | edit)
|
||||
- createdAt
|
||||
|
||||
المنطق:
|
||||
- صاحب الملاحظة هو المالك
|
||||
- يمكنه مشاركة الملاحظة مع مستخدمين
|
||||
- view = مشاهدة فقط
|
||||
- edit = تعديل كامل
|
||||
|
||||
--------------------------------------------------
|
||||
4) الواجهة (UI)
|
||||
--------------------------------------------------
|
||||
|
||||
إنشاء صفحة:
|
||||
Note
|
||||
|
||||
التصميم:
|
||||
- Grid مثل Google Keep
|
||||
- بطاقات (Cards) لكل ملاحظة
|
||||
- خلفية فاتحة وألوان هادئة
|
||||
- Responsive (جوال / آيباد / كمبيوتر)
|
||||
|
||||
كل بطاقة تعرض:
|
||||
- العنوان
|
||||
- محتوى مختصر أو checklist
|
||||
- اللون
|
||||
- نوعها (Note / List)
|
||||
- أيقونة إذا كانت مشتركة
|
||||
- حالة التثبيت
|
||||
|
||||
--------------------------------------------------
|
||||
5) الوظائف الأساسية
|
||||
--------------------------------------------------
|
||||
|
||||
- إنشاء Note
|
||||
- إنشاء List
|
||||
- تعديل
|
||||
- حذف (Soft delete)
|
||||
- أرشفة
|
||||
- تثبيت / إلغاء التثبيت
|
||||
- تغيير اللون
|
||||
- مشاركة مع مستخدمين
|
||||
- إلغاء المشاركة
|
||||
|
||||
--------------------------------------------------
|
||||
6) نافذة الإنشاء
|
||||
--------------------------------------------------
|
||||
|
||||
Modal أو صفحة تحتوي على:
|
||||
|
||||
- title
|
||||
- type (note / list)
|
||||
- content (إذا Note)
|
||||
- checklist (إذا List)
|
||||
- color picker
|
||||
- pin toggle
|
||||
- share users + permission
|
||||
|
||||
--------------------------------------------------
|
||||
7) الفلاتر والتنظيم
|
||||
--------------------------------------------------
|
||||
|
||||
إضافة Tabs:
|
||||
|
||||
- All
|
||||
- My Notes
|
||||
- Shared With Me
|
||||
- Pinned
|
||||
- Archived
|
||||
|
||||
إضافة:
|
||||
- بحث
|
||||
- فلترة حسب اللون
|
||||
- ترتيب (الأحدث / الأقدم / المثبتة)
|
||||
|
||||
--------------------------------------------------
|
||||
8) الصلاحيات
|
||||
--------------------------------------------------
|
||||
|
||||
Super Admin:
|
||||
- يرى كل الملاحظات
|
||||
|
||||
Admin:
|
||||
- يرى ملاحظاته + المشاركات
|
||||
|
||||
User:
|
||||
- يرى:
|
||||
- ملاحظاته
|
||||
- الملاحظات المشتركة معه
|
||||
|
||||
مهم:
|
||||
- التحقق من الصلاحيات في backend
|
||||
|
||||
--------------------------------------------------
|
||||
9) API
|
||||
--------------------------------------------------
|
||||
|
||||
GET /api/notes
|
||||
GET /api/notes/:id
|
||||
POST /api/notes
|
||||
PATCH /api/notes/:id
|
||||
DELETE /api/notes/:id
|
||||
|
||||
POST /api/notes/:id/pin
|
||||
POST /api/notes/:id/archive
|
||||
POST /api/notes/:id/share
|
||||
DELETE /api/notes/:id/share/:userId
|
||||
|
||||
Checklist:
|
||||
POST /api/notes/:id/items
|
||||
PATCH /api/notes/:id/items/:itemId
|
||||
DELETE /api/notes/:id/items/:itemId
|
||||
|
||||
--------------------------------------------------
|
||||
10) الألوان
|
||||
--------------------------------------------------
|
||||
|
||||
ألوان افتراضية:
|
||||
- أصفر
|
||||
- أخضر
|
||||
- أزرق
|
||||
- أحمر
|
||||
- بنفسجي
|
||||
- رمادي
|
||||
|
||||
+ Color Picker
|
||||
|
||||
يجب أن تكون:
|
||||
- هادئة
|
||||
- مناسبة للـ Light UI
|
||||
- النص واضح فوقها
|
||||
|
||||
--------------------------------------------------
|
||||
11) تحسينات UX
|
||||
--------------------------------------------------
|
||||
|
||||
- Auto-save
|
||||
- Toast notifications
|
||||
- Skeleton loading
|
||||
- Confirm delete
|
||||
- Empty states
|
||||
- عرض صورة المستخدمين المشاركين
|
||||
- Badge:
|
||||
- Note / List
|
||||
- Shared / Private
|
||||
|
||||
--------------------------------------------------
|
||||
12) الترجمة
|
||||
--------------------------------------------------
|
||||
|
||||
دعم:
|
||||
- العربية (RTL)
|
||||
- الإنجليزية (LTR)
|
||||
|
||||
باستخدام i18n
|
||||
|
||||
--------------------------------------------------
|
||||
13) التنفيذ
|
||||
--------------------------------------------------
|
||||
|
||||
أريد:
|
||||
- Database schema
|
||||
- Backend كامل
|
||||
- Frontend pages
|
||||
- Clean code
|
||||
- Responsive
|
||||
- Production-ready
|
||||
|
||||
--------------------------------------------------
|
||||
ملاحظة:
|
||||
الميزة يجب أن تكون حقيقية (Functional) وليست مجرد واجهة شكلية.
|
||||
@@ -1,113 +0,0 @@
|
||||
المشكلة ما زالت موجودة في PDF:
|
||||
الجدول لا يحافظ على الصف الواحد. بعض الخلايا مثل الوقت أو الحضور تظهر في سطر مستقل، وهذا خطأ.
|
||||
|
||||
المطلوب إصلاحه:
|
||||
- كل اجتماع يجب أن يكون داخل صف واحد فقط <tr>.
|
||||
- داخل نفس الصف يجب أن تكون الخلايا بالترتيب:
|
||||
الرقم | الاجتماع | الحضور | الوقت
|
||||
- ممنوع أن يظهر الوقت في صف مستقل.
|
||||
- ممنوع أن يظهر الحضور في صف منفصل عن الاجتماع.
|
||||
- ممنوع إنشاء <tr> جديد لمجرد أن النص طويل.
|
||||
- النص الطويل يجب أن يلتف داخل نفس الخلية فقط، وليس ينقل الخلية لسطر جديد.
|
||||
|
||||
السبب المتوقع:
|
||||
أنت تبني الجدول بطريقة خاطئة أو تستخدم div/flex داخل جسم الجدول، أو تتعامل مع الأسطر الطويلة كصفوف جديدة.
|
||||
|
||||
الحل المطلوب:
|
||||
ابنِ PDF باستخدام table حقيقي:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>الاجتماع</th>
|
||||
<th>الحضور</th>
|
||||
<th>الوقت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>7</td>
|
||||
<td>اجتماع بخصوص اعداد PDF</td>
|
||||
<td>
|
||||
<span>1- علي</span>
|
||||
<span>2- طارق سعد محمد</span>
|
||||
<span>3- عبدالله سلطان العلي</span>
|
||||
<span>4- باسل محمد خالد</span>
|
||||
</td>
|
||||
<td>11:20-11:30</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
مهم جداً:
|
||||
لا تستخدم display:flex على tr أو td.
|
||||
لا تستخدم CSS grid للجدول.
|
||||
لا تستخدم div كبديل للجدول.
|
||||
لا تقسم الاجتماع إلى أكثر من tr.
|
||||
النص الطويل يلتف داخل td فقط.
|
||||
|
||||
CSS إلزامي:
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
tr {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0 4px !important;
|
||||
margin: 0 !important;
|
||||
line-height: 1.1 !important;
|
||||
vertical-align: middle !important;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
td:nth-child(1), th:nth-child(1) {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
td:nth-child(2), th:nth-child(2) {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
td:nth-child(3), th:nth-child(3) {
|
||||
width: 42%;
|
||||
}
|
||||
|
||||
td:nth-child(4), th:nth-child(4) {
|
||||
width: 20%;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attendees-inline {
|
||||
display: inline;
|
||||
direction: rtl;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attendee-item {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin: 0 6px;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
الأهم:
|
||||
أي اجتماع في البيانات يجب أن ينتج عنه <tr> واحد فقط.
|
||||
لا تجعل التفاف النص ينشئ صفوف جديدة.
|
||||
إذا كان هناك دمج rowSpan أو colSpan في النظام، طبقه على نفس <td> ولا تنشئ صفوف إضافية.
|
||||
|
||||
النتيجة المطلوبة:
|
||||
الوقت، الاجتماع، الحضور، والرقم يظهرون دائماً في نفس صف الاجتماع، والنص يلتف داخل الخلية فقط.
|
||||
@@ -1,113 +0,0 @@
|
||||
المشكلة ما زالت موجودة في PDF:
|
||||
الجدول لا يحافظ على الصف الواحد. بعض الخلايا مثل الوقت أو الحضور تظهر في سطر مستقل، وهذا خطأ.
|
||||
|
||||
المطلوب إصلاحه:
|
||||
- كل اجتماع يجب أن يكون داخل صف واحد فقط <tr>.
|
||||
- داخل نفس الصف يجب أن تكون الخلايا بالترتيب:
|
||||
الرقم | الاجتماع | الحضور | الوقت
|
||||
- ممنوع أن يظهر الوقت في صف مستقل.
|
||||
- ممنوع أن يظهر الحضور في صف منفصل عن الاجتماع.
|
||||
- ممنوع إنشاء <tr> جديد لمجرد أن النص طويل.
|
||||
- النص الطويل يجب أن يلتف داخل نفس الخلية فقط، وليس ينقل الخلية لسطر جديد.
|
||||
|
||||
السبب المتوقع:
|
||||
أنت تبني الجدول بطريقة خاطئة أو تستخدم div/flex داخل جسم الجدول، أو تتعامل مع الأسطر الطويلة كصفوف جديدة.
|
||||
|
||||
الحل المطلوب:
|
||||
ابنِ PDF باستخدام table حقيقي:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>الاجتماع</th>
|
||||
<th>الحضور</th>
|
||||
<th>الوقت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>7</td>
|
||||
<td>اجتماع بخصوص اعداد PDF</td>
|
||||
<td>
|
||||
<span>1- علي</span>
|
||||
<span>2- طارق سعد محمد</span>
|
||||
<span>3- عبدالله سلطان العلي</span>
|
||||
<span>4- باسل محمد خالد</span>
|
||||
</td>
|
||||
<td>11:20-11:30</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
مهم جداً:
|
||||
لا تستخدم display:flex على tr أو td.
|
||||
لا تستخدم CSS grid للجدول.
|
||||
لا تستخدم div كبديل للجدول.
|
||||
لا تقسم الاجتماع إلى أكثر من tr.
|
||||
النص الطويل يلتف داخل td فقط.
|
||||
|
||||
CSS إلزامي:
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
tr {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0 4px !important;
|
||||
margin: 0 !important;
|
||||
line-height: 1.1 !important;
|
||||
vertical-align: middle !important;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
td:nth-child(1), th:nth-child(1) {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
td:nth-child(2), th:nth-child(2) {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
td:nth-child(3), th:nth-child(3) {
|
||||
width: 42%;
|
||||
}
|
||||
|
||||
td:nth-child(4), th:nth-child(4) {
|
||||
width: 20%;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attendees-inline {
|
||||
display: inline;
|
||||
direction: rtl;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attendee-item {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin: 0 6px;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
الأهم:
|
||||
أي اجتماع في البيانات يجب أن ينتج عنه <tr> واحد فقط.
|
||||
لا تجعل التفاف النص ينشئ صفوف جديدة.
|
||||
إذا كان هناك دمج rowSpan أو colSpan في النظام، طبقه على نفس <td> ولا تنشئ صفوف إضافية.
|
||||
|
||||
النتيجة المطلوبة:
|
||||
الوقت، الاجتماع، الحضور، والرقم يظهرون دائماً في نفس صف الاجتماع، والنص يلتف داخل الخلية فقط.
|
||||
@@ -1,104 +0,0 @@
|
||||
الإخراج الحالي غير مقبول. المطلوب إصلاح جدول PDF بحيث يكون كل اجتماع داخل صف واحد فقط.
|
||||
|
||||
المشكلة الحالية:
|
||||
- الوقت يظهر أحياناً في سطر مستقل فوق أو تحت الاجتماع.
|
||||
- بعض الحضور يخرجون من صف الاجتماع.
|
||||
- الصف لا يحافظ على نفس المحاذاة بين رقم الاجتماع، اسم الاجتماع، الحضور، والوقت.
|
||||
- المطلوب أن يكون كل اجتماع عبارة عن <tr> واحد فقط يحتوي 4 خلايا:
|
||||
رقم الاجتماع | اسم الاجتماع | الحضور | الوقت
|
||||
|
||||
مهم جداً:
|
||||
لا تجعل الوقت أو الحضور عناصر منفصلة خارج صف الجدول.
|
||||
كل بيانات الاجتماع يجب أن تكون داخل نفس tr.
|
||||
|
||||
الشكل المطلوب داخل HTML:
|
||||
|
||||
<tr>
|
||||
<td class="num-cell">6</td>
|
||||
<td class="meeting-cell">اجتماع خاص</td>
|
||||
<td class="attendees-cell">
|
||||
<div class="attendees-inline">
|
||||
<span class="attendee-item">-1 محمد خالد</span>
|
||||
<span class="attendee-item">-2 سعيد عبدالله</span>
|
||||
<span class="attendee-item">-3 طارق علي سعيد</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="time-cell">10:30-10:40</td>
|
||||
</tr>
|
||||
|
||||
ممنوع:
|
||||
- ممنوع طباعة الوقت قبل tr أو بعد tr.
|
||||
- ممنوع جعل الحضور div خارج td.
|
||||
- ممنوع استخدام position:absolute داخل الجدول.
|
||||
- ممنوع استخدام display:flex على tr أو table.
|
||||
- ممنوع تقسيم الاجتماع الواحد إلى أكثر من tr.
|
||||
|
||||
CSS المطلوب:
|
||||
|
||||
@media print {
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
th, td {
|
||||
vertical-align: top;
|
||||
padding: 6px 8px;
|
||||
text-align: right;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.num-cell {
|
||||
width: 8%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.meeting-cell {
|
||||
width: 30%;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.attendees-cell {
|
||||
width: 42%;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
width: 20%;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attendees-inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 14px;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.attendee-item {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
}
|
||||
|
||||
المطلوب النهائي:
|
||||
كل صف اجتماع يظهر مثل النموذج المرجعي:
|
||||
رقم الاجتماع واسم الاجتماع والحضور والوقت على نفس السطر الأساسي.
|
||||
الحضور يكون inline داخل خانة الحضور، 3 إلى 4 أسماء تقريباً في السطر.
|
||||
الوقت يبقى دائماً داخل عمود الوقت في نفس صف الاجتماع.
|
||||
@@ -1,125 +0,0 @@
|
||||
ممتاز، لكن نحتاج إصلاحات نهائية في PDF:
|
||||
|
||||
1. المسافات ضيقة جداً
|
||||
- زِد padding داخل الخلايا.
|
||||
- زِد line-height للنصوص.
|
||||
- اجعل الجدول مريح للقراءة.
|
||||
- لا تجعل النص ملاصق للخلايا.
|
||||
|
||||
CSS:
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
line-height: 1.8;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.attendees-inline {
|
||||
gap: 6px 18px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
2. العنوان واللوقو
|
||||
- العنوان "قائمة بأسماء حضور الاجتماعات" يجب أن يكون أعلى الصفحة وليس أسفلها.
|
||||
- أضف اللوقو أعلى الصفحة إذا كان موجوداً في إعدادات النظام.
|
||||
- إذا لا يوجد لوقو في الإعدادات، لا تترك مساحة فارغة.
|
||||
|
||||
المطلوب:
|
||||
PDF header في الأعلى يحتوي:
|
||||
- اللوقو يمين أو يسار حسب تصميم النظام
|
||||
- العنوان في المنتصف أو يمين حسب النموذج
|
||||
- ثم الجدول تحته
|
||||
|
||||
3. عكس ألوان النظام في PDF
|
||||
حالياً ألوان الخط والصفوف لا تنعكس.
|
||||
المطلوب أن PDF يستخدم نفس إعدادات النظام القابلة للتعديل:
|
||||
- لون الخط العام
|
||||
- لون عنوان الجدول
|
||||
- لون خلفية الهيدر
|
||||
- لون حدود الجدول
|
||||
- لون الصف المحدد أو الصف المميز
|
||||
- لون الخلفية إذا كان المستخدم محددها
|
||||
|
||||
مهم:
|
||||
لا تستخدم ألوان ثابتة hardcoded داخل CSS.
|
||||
استخدم CSS variables أو إعدادات theme من النظام.
|
||||
|
||||
مثال:
|
||||
:root {
|
||||
--pdf-font-color: var(--system-text-color);
|
||||
--pdf-header-bg: var(--system-header-bg);
|
||||
--pdf-header-color: var(--system-header-text);
|
||||
--pdf-border-color: var(--system-border-color);
|
||||
--pdf-selected-row-bg: var(--system-selected-row-bg);
|
||||
}
|
||||
|
||||
.pdf-table {
|
||||
color: var(--pdf-font-color);
|
||||
}
|
||||
|
||||
.pdf-table th {
|
||||
background: var(--pdf-header-bg);
|
||||
color: var(--pdf-header-color);
|
||||
border-color: var(--pdf-border-color);
|
||||
}
|
||||
|
||||
.pdf-table td {
|
||||
border-color: var(--pdf-border-color);
|
||||
}
|
||||
|
||||
.pdf-table tr.selected-row td {
|
||||
background: var(--pdf-selected-row-bg);
|
||||
}
|
||||
|
||||
4. موضوع دمج الخلايا
|
||||
أنا سأستخدم دمج في النظام.
|
||||
المطلوب أن PDF يعكس الدمج مثل النظام تماماً.
|
||||
|
||||
إذا كان في النظام:
|
||||
- دمج صفوف
|
||||
- دمج أعمدة
|
||||
- دمج خلايا للحضور أو الوقت
|
||||
|
||||
يجب أن ينعكس في PDF باستخدام:
|
||||
- colspan
|
||||
- rowspan
|
||||
|
||||
مثال:
|
||||
<td colspan="2">نص مدمج</td>
|
||||
<td rowspan="2">وقت مشترك</td>
|
||||
|
||||
ممنوع:
|
||||
- تجاهل الدمج عند الطباعة
|
||||
- تحويل الدمج إلى نص عادي
|
||||
- كسر الجدول بسبب الدمج
|
||||
|
||||
5. ألوان الصفوف المحددة
|
||||
إذا المستخدم اختار لون صف معين داخل النظام، يجب أن يظهر نفس اللون في PDF.
|
||||
مثال:
|
||||
- صف اجتماع محدد بلون أصفر
|
||||
- صف مهم بلون رمادي
|
||||
- صف مميز بلون أزرق فاتح
|
||||
|
||||
يجب حفظ اللون مع بيانات الصف، ثم استخدامه في PDF:
|
||||
<tr style="background-color: row.backgroundColor">
|
||||
|
||||
6. الخط
|
||||
استمر باستخدام:
|
||||
DIN Next LT Arabic
|
||||
|
||||
ويجب أن يكون مطبق على:
|
||||
- العنوان
|
||||
- الجدول
|
||||
- الحضور
|
||||
- الوقت
|
||||
- الفوتر
|
||||
|
||||
7. المطلوب النهائي
|
||||
قبل توليد PDF:
|
||||
- اقرأ إعدادات الثيم من النظام
|
||||
- اقرأ اللوقو من إعدادات النظام
|
||||
- اقرأ ألوان الصفوف المحددة
|
||||
- اقرأ إعدادات الدمج
|
||||
- طبّقها كلها داخل HTML الخاص بالطباعة
|
||||
|
||||
لا أريد PDF بتصميم ثابت.
|
||||
أريد PDF يعكس إعدادات النظام فعلياً.
|
||||
@@ -1,151 +0,0 @@
|
||||
أريد تعديل طباعة PDF لجدول حضور الاجتماعات بحيث يكون مطابقاً قدر الإمكان لملف PDF المرفق.
|
||||
|
||||
المهم جداً:
|
||||
لا تغيّر فكرة التصميم. أريد نفس شكل الملف المرجعي:
|
||||
- عنوان في الأعلى: قائمة بأسماء حضور الاجتماعات
|
||||
- جدول بسيط بالأعمدة:
|
||||
# | الاجتماع | الحضور | الوقت
|
||||
- الاتجاه عربي RTL
|
||||
- الخط المستخدم في كامل PDF:
|
||||
DIN Next LT Arabic
|
||||
|
||||
تنسيق الحضور المطلوب:
|
||||
- أسماء الحضور تكون داخل نفس خلية الحضور بشكل أفقي inline.
|
||||
- لا تجعل كل اسم في سطر مستقل.
|
||||
- كل سطر يحتوي تقريباً 3 إلى 4 أسماء حسب طول الاسم.
|
||||
- إذا زاد عدد الحضور، ينزل تلقائياً للسطر التالي.
|
||||
- الشكل المطلوب مثل:
|
||||
-1 طارق عبدالله -2 فهد احمد -3 عبدالرحمن عبدالله
|
||||
-4 د. سالم الشمري -5 سليمان محمد
|
||||
|
||||
مهم:
|
||||
- أريد نفس أسلوب الترقيم الموجود في النموذج:
|
||||
-1 الاسم
|
||||
-2 الاسم
|
||||
-3 الاسم
|
||||
- لا تستخدم قائمة عمودية طويلة.
|
||||
- لا تستخدم ol/li إذا كانت تسبب انقلاب الأرقام.
|
||||
- لا تجعل الترقيم يظهر بشكل خاطئ أو منفصل عن الاسم.
|
||||
- الرقم والاسم يكونان قطعة واحدة مع بعض داخل span.
|
||||
|
||||
تنسيق الجدول:
|
||||
- استخدم table حقيقي للطباعة.
|
||||
- لا تستخدم flex لتقسيم أعمدة الجدول.
|
||||
- العرض كامل الصفحة.
|
||||
- حجم الصفحة A4 portrait.
|
||||
- الهوامش مناسبة.
|
||||
- النصوص محاذاة يمين.
|
||||
- الوقت يكون واضح داخل عمود الوقت ولا يطلع خارج الجدول.
|
||||
- الوقت يكون بصيغة مثل:
|
||||
12:20-12:50
|
||||
1:00-1:30
|
||||
|
||||
أحجام الأعمدة المقترحة:
|
||||
- الرقم #: 8%
|
||||
- الاجتماع: 30%
|
||||
- الحضور: 42%
|
||||
- الوقت: 20%
|
||||
|
||||
CSS مقترح:
|
||||
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 12mm;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "DIN Next LT Arabic", Arial, sans-serif;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 6px 8px;
|
||||
vertical-align: top;
|
||||
text-align: right;
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
th:nth-child(1), td:nth-child(1) {
|
||||
width: 8%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
th:nth-child(2), td:nth-child(2) {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
th:nth-child(3), td:nth-child(3) {
|
||||
width: 42%;
|
||||
}
|
||||
|
||||
th:nth-child(4), td:nth-child(4) {
|
||||
width: 20%;
|
||||
text-align: center;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attendees-inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
gap: 2px 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.attendee-item {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.meeting-title {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
طريقة توليد الحضور داخل HTML:
|
||||
بدلاً من:
|
||||
<ul>
|
||||
<li>طارق عبدالله</li>
|
||||
</ul>
|
||||
|
||||
استخدم:
|
||||
<div class="attendees-inline">
|
||||
<span class="attendee-item">-1 طارق عبدالله</span>
|
||||
<span class="attendee-item">-2 فهد احمد</span>
|
||||
<span class="attendee-item">-3 عبدالرحمن عبدالله</span>
|
||||
<span class="attendee-item">-4 د. سالم الشمري</span>
|
||||
<span class="attendee-item">-5 سليمان محمد</span>
|
||||
</div>
|
||||
|
||||
المطلوب النهائي:
|
||||
PDF يكون مثل الملف المرجعي تماماً من حيث:
|
||||
- بساطة الجدول
|
||||
- الحضور في نفس السطر
|
||||
- 3 إلى 4 أسماء تقريباً في السطر
|
||||
- عدم تداخل الأعمدة
|
||||
- عدم خروج الوقت من مكانه
|
||||
- الالتزام بخط DIN Next LT Arabic
|
||||
- عدم تحويل الحضور إلى قائمة عمودية
|
||||
@@ -1,151 +0,0 @@
|
||||
أريد إصلاح تصدير PDF لجدول الاجتماعات بشكل نهائي.
|
||||
|
||||
⚠️ مهم:
|
||||
التصميم الخارجي للـPDF ثابت (العنوان + الفوتر + حجم الصفحة + الخط DIN Next LT Arabic).
|
||||
التعديل فقط داخل الجدول.
|
||||
|
||||
==================================
|
||||
أولاً: إصلاح المشاكل الحالية
|
||||
==================================
|
||||
|
||||
1. لا يوجد أي فراغات بين الصفوف:
|
||||
- لا padding عمودي
|
||||
- لا margin
|
||||
- ارتفاع الصف = حجم المحتوى فقط
|
||||
|
||||
2. كل اجتماع يجب أن يكون صف واحد:
|
||||
- الرقم + اسم الاجتماع + الحضور + الوقت في نفس الصف
|
||||
- لا يظهر الوقت في صف منفصل أبداً
|
||||
|
||||
3. إصلاح الترقيم:
|
||||
- لا يبدأ من 4 أو رقم خاطئ
|
||||
- استخدم رقم الاجتماع الحقيقي من البيانات
|
||||
|
||||
4. إصلاح ترتيب الحضور:
|
||||
- لا يظهر بشكل مقلوب مثل:
|
||||
-5 سالم -4 علي
|
||||
- الترتيب يكون:
|
||||
1- محمد 2- علي 3- طارق
|
||||
|
||||
5. الدمج:
|
||||
- أي merge في النظام (rowSpan / colSpan) يجب أن يظهر نفسه في PDF
|
||||
- لا تعيد بناء الجدول بدون الدمج
|
||||
|
||||
==================================
|
||||
ثانياً: تنسيق الحضور
|
||||
==================================
|
||||
|
||||
- الحضور في نفس السطر (inline)
|
||||
- كل سطر يحتوي 3 إلى 4 أسماء تقريباً
|
||||
- إذا زاد العدد → ينزل سطر جديد تلقائياً
|
||||
- لا تجعل كل اسم في سطر مستقل
|
||||
|
||||
مثال:
|
||||
1- محمد 2- علي 3- طارق
|
||||
4- سالم 5- عبدالله
|
||||
|
||||
==================================
|
||||
ثالثاً: محاذاة المحتوى
|
||||
==================================
|
||||
|
||||
- عمود "الاجتماع" في المنتصف (center)
|
||||
- عمود "الحضور" في المنتصف
|
||||
- عمود "الوقت" في المنتصف
|
||||
- vertical-align: middle
|
||||
|
||||
==================================
|
||||
رابعاً: عكس تنسيق المستخدم (مهم جداً)
|
||||
==================================
|
||||
|
||||
داخل الجدول فقط:
|
||||
|
||||
- Bold → يظهر Bold في PDF
|
||||
- Center / Right / Left → يظهر نفس المحاذاة
|
||||
- لون الخط → يظهر نفسه
|
||||
- لون الخلفية → يظهر نفسه
|
||||
- حجم الخط → يظهر نفسه
|
||||
|
||||
⚠️ لا تجعل CSS العام يلغي تنسيقات الخلايا
|
||||
|
||||
الحل:
|
||||
- استخدم inline style لكل خلية
|
||||
- مثال:
|
||||
|
||||
<td
|
||||
rowspan="..."
|
||||
colspan="..."
|
||||
style="
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: #000;
|
||||
background-color: #f2f2f2;
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
"
|
||||
>
|
||||
المحتوى
|
||||
</td>
|
||||
|
||||
==================================
|
||||
خامساً: CSS المطلوب
|
||||
==================================
|
||||
|
||||
@media print {
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0 4px !important;
|
||||
margin: 0 !important;
|
||||
line-height: 1.05 !important;
|
||||
vertical-align: middle !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attendees-inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0 10px;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.attendee-item {
|
||||
white-space: nowrap;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
direction: ltr;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
==================================
|
||||
سادساً: شرط مهم جداً
|
||||
==================================
|
||||
|
||||
لا تبنِ PDF من DOM مكسور.
|
||||
|
||||
- استخدم نفس بيانات الجدول في النظام
|
||||
- طبّق rowSpan و colSpan كما هي
|
||||
- كل صف في PDF = صف حقيقي في النظام
|
||||
|
||||
==================================
|
||||
النتيجة المطلوبة:
|
||||
==================================
|
||||
|
||||
PDF مطابق للنظام من حيث:
|
||||
- بدون فراغات
|
||||
- الحضور inline
|
||||
- الترقيم صحيح
|
||||
- الدمج صحيح
|
||||
- الوقت داخل الصف
|
||||
- المحاذاة في المنتصف
|
||||
- جميع التنسيقات (Bold / Color / Alignment) تنعكس
|
||||
@@ -1,131 +0,0 @@
|
||||
Build a full-stack internal web platform called "TeaBoy OS" as a responsive operating-system-style web interface inspired by iOS/Android, designed to run internally on a Mac Server using Docker.
|
||||
|
||||
Core stack:
|
||||
- Frontend: Next.js (App Router) + React + TypeScript + Tailwind CSS
|
||||
- Backend: Node.js + Express + Socket.IO
|
||||
- Database: PostgreSQL
|
||||
- Deployment: Docker Compose
|
||||
|
||||
General requirements:
|
||||
- The UI must be fully responsive and work well on desktop, iPad, and mobile browsers.
|
||||
- The platform must support both Arabic and English from day one.
|
||||
- Arabic must be the default language.
|
||||
- Full RTL support for Arabic and LTR support for English.
|
||||
- All text must come from an i18n system, not hardcoded strings.
|
||||
- Include a language switcher in the UI.
|
||||
- Save user language preference.
|
||||
|
||||
Main UI / OS Interface:
|
||||
1. Home screen / desktop interface:
|
||||
- Glassmorphism visual style
|
||||
- Beautiful OS-like home screen
|
||||
- Responsive grid of app icons
|
||||
- Top status bar with:
|
||||
- live clock
|
||||
- username
|
||||
- notifications bell
|
||||
- Bottom dock for frequently used apps
|
||||
- Smooth hover and click interactions
|
||||
- Arabic-friendly modern styling
|
||||
|
||||
2. Dynamic apps system:
|
||||
- Apps must be stored in the database in a table called `apps`
|
||||
- Admin can create, edit, activate, deactivate, and sort apps
|
||||
- Apps automatically appear on the home screen without hardcoding
|
||||
- Each app should support bilingual content:
|
||||
- name_ar
|
||||
- name_en
|
||||
- description_ar
|
||||
- description_en
|
||||
- Also include:
|
||||
- slug
|
||||
- icon_name
|
||||
- route
|
||||
- color
|
||||
- is_active
|
||||
- is_system
|
||||
- sort_order
|
||||
|
||||
3. Service app ("خدماتي"):
|
||||
- Show internal services such as tea, Arabic coffee, water, etc.
|
||||
- Display them as elegant responsive cards
|
||||
- Admin must be able to create, edit, and delete services dynamically
|
||||
- Services must support bilingual content:
|
||||
- name_ar
|
||||
- name_en
|
||||
- description_ar
|
||||
- description_en
|
||||
- Also include:
|
||||
- image_url
|
||||
- price
|
||||
- is_available
|
||||
- category if useful
|
||||
|
||||
4. Communication app:
|
||||
- Internal chat app
|
||||
- Support direct one-to-one conversations
|
||||
- Support group conversations
|
||||
- Use Socket.IO / WebSockets for realtime messaging
|
||||
- Realtime notifications for new messages
|
||||
- Store conversations, participants, messages, and read states in the database
|
||||
|
||||
5. Authentication and RBAC:
|
||||
- Users table
|
||||
- Roles table
|
||||
- Permissions table
|
||||
- User roles mapping
|
||||
- Role permissions mapping
|
||||
- App visibility should be permission-aware
|
||||
- Admin role should manage apps, services, and users
|
||||
|
||||
Database requirements:
|
||||
Use PostgreSQL and create a scalable schema including at minimum:
|
||||
- users
|
||||
- roles
|
||||
- user_roles
|
||||
- permissions
|
||||
- role_permissions
|
||||
- apps
|
||||
- services
|
||||
- service_categories (optional but recommended)
|
||||
- conversations
|
||||
- conversation_participants
|
||||
- messages
|
||||
- message_reads
|
||||
- notifications
|
||||
- app_permissions (recommended)
|
||||
|
||||
Important schema requirements:
|
||||
- users must include preferred_language with default 'ar'
|
||||
- apps must include bilingual fields:
|
||||
- name_ar, name_en, description_ar, description_en
|
||||
- services must include bilingual fields:
|
||||
- name_ar, name_en, description_ar, description_en
|
||||
|
||||
Technical requirements:
|
||||
- Create a clean modular folder structure
|
||||
- Use reusable components
|
||||
- Make the architecture scalable for future apps
|
||||
- Add seed data for demo apps and demo services
|
||||
- Prepare the project to run with Docker Compose
|
||||
- Keep the code clean and production-minded
|
||||
|
||||
Deliverables required:
|
||||
1. docker-compose.yml
|
||||
2. frontend and backend Dockerfiles
|
||||
3. PostgreSQL schema / init.sql
|
||||
4. Next.js home screen UI
|
||||
5. basic backend API structure
|
||||
6. Socket.IO server setup
|
||||
7. sample admin dashboard structure for managing apps and services
|
||||
8. i18n structure for Arabic and English
|
||||
9. seed data
|
||||
|
||||
Important:
|
||||
After implementation, provide a clear report of:
|
||||
- what was created
|
||||
- which files were added
|
||||
- which features are fully working now
|
||||
- which features are scaffolded only
|
||||
- what is still pending
|
||||
Do not give a vague summary. Be precise and explicit.
|
||||
@@ -1,251 +0,0 @@
|
||||
Build a production-style bilingual web app called "TeaBoy" that looks and behaves like a lightweight internal OS interface, optimized for desktop, tablet, and mobile browsers. The interface must support both Arabic and English, with RTL/LTR support, and use a modern light creative UI inspired by iOS-style glassmorphism. Do not use the name "TeaBoy OS" in the UI. Use only "TeaBoy" or "النظام" where appropriate.
|
||||
|
||||
The system must include these major parts:
|
||||
|
||||
1) Authentication
|
||||
- No public registration page at all.
|
||||
- Only admin can create users.
|
||||
- Users log in with username + password.
|
||||
- First time a user logs in, they must be forced to change their password before accessing the system.
|
||||
- Store a boolean like mustChangePassword=true for new users.
|
||||
- After password change, redirect them to their home screen.
|
||||
- Secure password hashing required.
|
||||
- Include session management and protected routes.
|
||||
|
||||
2) Main TeaBoy User Experience
|
||||
- After login, the user lands on a Home Screen similar to a mobile/desktop operating system launcher.
|
||||
- Display app icons in a responsive grid.
|
||||
- Show only apps the logged-in user is allowed to access.
|
||||
- Each app card/icon should include:
|
||||
- app icon/image
|
||||
- app name
|
||||
- optional short description
|
||||
- Clicking an app opens:
|
||||
- external URL in a new tab if the app is external
|
||||
- internal route if the app is internal
|
||||
- Top status bar should include:
|
||||
- current time
|
||||
- logged in user name
|
||||
- language switcher (Arabic / English)
|
||||
- profile / logout menu
|
||||
- Design should be modern, elegant, light, and smooth with glassmorphism feel.
|
||||
|
||||
3) Admin Panel
|
||||
Create a professional Admin sidebar menu with the following structure:
|
||||
|
||||
- Dashboard
|
||||
- User Management
|
||||
- View Users
|
||||
- Create User
|
||||
- Permissions
|
||||
- Application Management
|
||||
- View Applications
|
||||
- Add Application
|
||||
- Application Permissions
|
||||
- Settings
|
||||
|
||||
Admin panel must be clean, enterprise-style, bilingual, and fully responsive.
|
||||
|
||||
4) Dashboard
|
||||
Create an admin dashboard page showing summary cards:
|
||||
- Total Users
|
||||
- Total Applications
|
||||
- Active Users count (basic/mock if needed)
|
||||
- Admin Users count
|
||||
- Recently Added Users
|
||||
- Recently Added Applications
|
||||
|
||||
5) User Management
|
||||
Create a full professional user management module.
|
||||
|
||||
A) View Users page
|
||||
- Table/cards of all users
|
||||
- Search by name or username
|
||||
- Filter by role
|
||||
- Show:
|
||||
- name
|
||||
- username
|
||||
- role
|
||||
- status
|
||||
- number of assigned apps
|
||||
- created date
|
||||
- Clicking a user opens a detailed page or drawer showing:
|
||||
- full name
|
||||
- username
|
||||
- role
|
||||
- assigned apps
|
||||
- mustChangePassword status
|
||||
- created at
|
||||
- last updated
|
||||
- Add edit and delete actions.
|
||||
|
||||
B) Create User page
|
||||
Fields:
|
||||
- Full name
|
||||
- Username
|
||||
- Temporary password
|
||||
- Role (admin / user)
|
||||
- Assigned applications (multi-select or checkboxes)
|
||||
- Status (active / inactive)
|
||||
- mustChangePassword should default to true for newly created users
|
||||
|
||||
Behavior:
|
||||
- Admin creates the user manually
|
||||
- No email verification flow required
|
||||
- No public signup
|
||||
|
||||
C) Permissions page
|
||||
- Admin can manage which apps are assigned to which users
|
||||
- Support direct app assignment per user
|
||||
- Simple and professional permission model:
|
||||
- admin has full access
|
||||
- user only sees assigned apps
|
||||
- Avoid overly complex RBAC for now; use app-based permissions.
|
||||
|
||||
6) Application Management
|
||||
Create a full professional application management module.
|
||||
|
||||
A) View Applications page
|
||||
- Show all applications in a table/cards view
|
||||
- Search by app name
|
||||
- Show:
|
||||
- app icon
|
||||
- app name
|
||||
- type (internal / external)
|
||||
- url or route
|
||||
- visibility summary
|
||||
- created date
|
||||
|
||||
B) Add Application page
|
||||
Fields:
|
||||
- Application name
|
||||
- External URL or internal route
|
||||
- Upload/select application image/icon
|
||||
- Description
|
||||
- Type:
|
||||
- external
|
||||
- internal
|
||||
- Visibility / assignment options:
|
||||
- admin only
|
||||
- all users
|
||||
- selected users only
|
||||
- Status active/inactive
|
||||
|
||||
Behavior:
|
||||
- External applications open in a new tab
|
||||
- Internal applications route inside the platform
|
||||
- Image upload should be supported, or at minimum image URL with upload-ready component design
|
||||
- Store application metadata cleanly
|
||||
|
||||
C) Application Permissions page
|
||||
- Admin can select an application and choose which users can access it
|
||||
- Also allow assigning applications from the user side
|
||||
- Keep permission logic consistent
|
||||
|
||||
7) Data Models
|
||||
Use clean relational or structured schema for at least:
|
||||
- users
|
||||
- applications
|
||||
- user_applications (many-to-many)
|
||||
- sessions if needed
|
||||
|
||||
Suggested user fields:
|
||||
- id
|
||||
- fullName
|
||||
- username
|
||||
- passwordHash
|
||||
- role
|
||||
- status
|
||||
- mustChangePassword
|
||||
- createdAt
|
||||
- updatedAt
|
||||
|
||||
Suggested application fields:
|
||||
- id
|
||||
- name
|
||||
- description
|
||||
- icon
|
||||
- type
|
||||
- url
|
||||
- internalRoute
|
||||
- visibilityMode
|
||||
- status
|
||||
- createdAt
|
||||
- updatedAt
|
||||
|
||||
Suggested pivot:
|
||||
- userId
|
||||
- applicationId
|
||||
|
||||
8) Authorization Rules
|
||||
- Admin can access admin panel, manage users, manage apps, and view dashboard
|
||||
- User cannot access admin pages
|
||||
- User can only see assigned applications on home screen
|
||||
- If app visibility is admin only, regular users never see it
|
||||
- If user is inactive, block login
|
||||
- If mustChangePassword is true, force password change before anything else
|
||||
|
||||
9) UI / UX Requirements
|
||||
- Bilingual Arabic and English from day one
|
||||
- Full RTL support for Arabic
|
||||
- Light modern color palette, soft creative colors, not harsh or dark-heavy
|
||||
- Glassmorphism-inspired cards and panels
|
||||
- Responsive for desktop, tablet, mobile
|
||||
- Smooth transitions
|
||||
- Professional sidebar admin layout
|
||||
- User home screen should feel like a polished internal OS / app launcher
|
||||
- Avoid clutter
|
||||
- Typography must support Arabic beautifully
|
||||
- Keep code structured and scalable
|
||||
|
||||
10) Technical Requirements
|
||||
- Use React + TypeScript + Tailwind CSS
|
||||
- Create clear folder structure
|
||||
- Component-based architecture
|
||||
- Use mock/local database if needed, but structure it as if production-ready
|
||||
- Include route guards
|
||||
- Include seed data:
|
||||
- one admin account
|
||||
- few sample users
|
||||
- few sample applications
|
||||
- Include clean reusable components:
|
||||
- sidebar
|
||||
- top bar
|
||||
- app card
|
||||
- user table
|
||||
- application table
|
||||
- forms
|
||||
- permission selectors
|
||||
- password change screen
|
||||
- Use best practices for state management and forms
|
||||
- Add validation for create user and create application forms
|
||||
|
||||
11) Important Business Rules
|
||||
- There must be no self-registration page
|
||||
- Admin is the only one who can create users
|
||||
- New users must change password on first login
|
||||
- Users only see allowed applications
|
||||
- Admin can add external apps by entering:
|
||||
- app name
|
||||
- external link
|
||||
- icon/image
|
||||
- description
|
||||
- who can access it
|
||||
- Build this as a real internal admin-controlled system, not a public SaaS signup product
|
||||
|
||||
12) Deliverables
|
||||
Generate:
|
||||
- full app structure
|
||||
- pages
|
||||
- components
|
||||
- routes
|
||||
- mock data / seed
|
||||
- authentication flow
|
||||
- admin dashboard
|
||||
- user management
|
||||
- application management
|
||||
- forced password change flow
|
||||
- bilingual UI scaffolding
|
||||
- responsive polished frontend
|
||||
|
||||
Please implement the system end-to-end in a clean, maintainable way with a professional architecture. Start with a strong frontend and working mocked backend/data layer if needed, but structure the code so it can later connect to a real database and real authentication backend easily.
|
||||
@@ -1,105 +0,0 @@
|
||||
Implement the Service Orders workflow with receiver permissions.
|
||||
|
||||
Business logic:
|
||||
1. Admin must be able to create users and control who is allowed to receive service orders.
|
||||
2. Add a dedicated permission for receiving orders, such as:
|
||||
- orders.receive
|
||||
3. Any user with this permission should see incoming new service orders in the orders system.
|
||||
4. When a client places an order:
|
||||
- the order should appear for all users who have the order-receiving permission
|
||||
- they should get a realtime notification / bell alert
|
||||
5. The first receiver who clicks "Confirm Receipt" becomes the assigned handler of the order.
|
||||
6. Once confirmed:
|
||||
- set assigned_to to that receiver
|
||||
- update order status from pending to received
|
||||
- notify the client that the order has been received and is being prepared
|
||||
7. The client should see statuses like:
|
||||
- pending
|
||||
- received
|
||||
- preparing
|
||||
- completed
|
||||
- cancelled
|
||||
|
||||
Client-side requirements:
|
||||
- Before placing the order, the client can add notes/comments
|
||||
- Remove service description from the order form / order display if currently shown there
|
||||
- The service description should remain only in the catalog managed by admin
|
||||
- The client should see clear status updates:
|
||||
- order received
|
||||
- preparing
|
||||
|
||||
Admin requirements:
|
||||
- Admin manages the catalog only
|
||||
- Admin manages users and permissions
|
||||
- Admin can decide which users are allowed to receive service orders
|
||||
|
||||
Database / backend requirements:
|
||||
1. Add permission:
|
||||
- orders.receive
|
||||
2. Add service_orders table if not already added, with at least:
|
||||
- id
|
||||
- user_id
|
||||
- service_id
|
||||
- notes
|
||||
- status
|
||||
- assigned_to
|
||||
- created_at
|
||||
- updated_at
|
||||
3. Status flow:
|
||||
- pending
|
||||
- received
|
||||
- preparing
|
||||
- completed
|
||||
- cancelled
|
||||
|
||||
API requirements:
|
||||
- POST /api/orders
|
||||
- GET /api/orders/my
|
||||
- GET /api/orders/incoming
|
||||
- PATCH /api/orders/:id/confirm-receipt
|
||||
- PATCH /api/orders/:id/status
|
||||
|
||||
Rules:
|
||||
- normal client can create order and view own orders
|
||||
- only users with orders.receive permission can see incoming orders
|
||||
- only one receiver can confirm receipt successfully
|
||||
- once receipt is confirmed, notify the client
|
||||
- use realtime bell/notification if Socket.IO already exists
|
||||
|
||||
Frontend requirements:
|
||||
1. Client:
|
||||
- services catalog
|
||||
- order modal/form
|
||||
- notes field
|
||||
- submit order
|
||||
- my orders page with status
|
||||
|
||||
2. Receiver side:
|
||||
- incoming orders page/list
|
||||
- bell notification for new orders
|
||||
- button: Confirm Receipt
|
||||
- after receipt, ability to move status to:
|
||||
- preparing
|
||||
- completed
|
||||
- cancelled
|
||||
|
||||
3. Admin:
|
||||
- manage users
|
||||
- assign or remove orders.receive permission
|
||||
- manage services catalog only
|
||||
|
||||
Important:
|
||||
- Keep Arabic and English support
|
||||
- Keep RTL support
|
||||
- Keep it simple and operational
|
||||
- Remove unnecessary description duplication from order screens
|
||||
|
||||
After implementation, provide a precise report:
|
||||
1. files changed
|
||||
2. database changes
|
||||
3. permission changes
|
||||
4. API endpoints added
|
||||
5. how incoming orders are routed to permitted users
|
||||
6. how confirm receipt works
|
||||
7. what notifications are implemented
|
||||
8. what is still pending
|
||||
@@ -1,95 +0,0 @@
|
||||
Update the admin system to support User Management with submenus:
|
||||
- Users
|
||||
- Groups
|
||||
|
||||
Requirements:
|
||||
|
||||
1. Admin menu structure:
|
||||
Inside User Management, create two submenus:
|
||||
- Users
|
||||
- Groups
|
||||
|
||||
2. Users page:
|
||||
Create a proper users management page that includes:
|
||||
- list all users
|
||||
- search/filter by:
|
||||
- name
|
||||
- username
|
||||
- email
|
||||
- group
|
||||
- active/disabled status
|
||||
- create new user
|
||||
- edit user
|
||||
- activate/deactivate user
|
||||
- delete user
|
||||
- assign one or more groups to the user
|
||||
|
||||
Display in users table:
|
||||
- full name
|
||||
- username
|
||||
- email
|
||||
- preferred language
|
||||
- active status
|
||||
- groups
|
||||
- actions
|
||||
|
||||
3. Groups page:
|
||||
Create full group management:
|
||||
- create group
|
||||
- edit group
|
||||
- delete group
|
||||
- search groups
|
||||
- show group name
|
||||
- description
|
||||
- number of users
|
||||
- allowed apps
|
||||
|
||||
Each group must support:
|
||||
- name
|
||||
- description
|
||||
- assigned users
|
||||
- assigned apps
|
||||
|
||||
4. Access model:
|
||||
Users should get access to apps based on their group assignments.
|
||||
Do not rely only on individual user roles.
|
||||
Support one user belonging to one or more groups.
|
||||
|
||||
5. Database:
|
||||
Add tables if not already present:
|
||||
- groups
|
||||
- user_groups
|
||||
- group_apps
|
||||
|
||||
Suggested structure:
|
||||
- groups: id, name, description, created_at, updated_at
|
||||
- user_groups: user_id, group_id
|
||||
- group_apps: group_id, app_id
|
||||
|
||||
6. Business use case:
|
||||
We want to create a group called "TeaBoy" that receives service orders only.
|
||||
There may be multiple groups in the future.
|
||||
Example:
|
||||
- TeaBoy group can access incoming orders app
|
||||
- Admin group can access full admin system
|
||||
- other groups can access different apps
|
||||
|
||||
7. UI:
|
||||
- Arabic-first
|
||||
- RTL friendly
|
||||
- clean admin layout
|
||||
- use clear labels for groups and applications
|
||||
|
||||
8. Important:
|
||||
- show group membership clearly in user details
|
||||
- show app access clearly in group details
|
||||
- make this scalable for future groups and apps
|
||||
|
||||
After implementation, provide a precise report:
|
||||
1. files changed
|
||||
2. database tables added/updated
|
||||
3. pages added
|
||||
4. API endpoints added
|
||||
5. how group-to-app permission works
|
||||
6. how users are assigned to groups
|
||||
7. what still remains incomplete
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 412 KiB |
|
Before Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 649 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |