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.
This commit is contained in:
riyadhafraa
2026-05-13 14:08:11 +00:00
parent 9a8e6676ed
commit 143ad9a29d
279 changed files with 2334 additions and 3430 deletions
+58
View File
@@ -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>
+60 -41
View File
@@ -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
*~
+88
View File
@@ -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"]
+147
View File
@@ -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.
+187
View File
@@ -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.
+2 -2
View File
@@ -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",
+28 -37
View File
@@ -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;
+447 -145
View File
@@ -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 };
}
+2
View File
@@ -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;
-2
View File
@@ -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:",
+1 -17
View File
@@ -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"),
-3
View File
@@ -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:",
+4 -21
View File
@@ -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,
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Some files were not shown because too many files have changed in this diff Show More