🚀 Initialize Musadaq SaaS: Full Backend + AI + React Dashboard + Docker Setup
This commit is contained in:
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# ──────────────────────────────────────────────────────────
|
||||||
|
# مُصادَق (Musadaq) — Git Exclusions
|
||||||
|
# ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# OS Files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
bower_components/
|
||||||
|
.npm/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.npm_cache/
|
||||||
|
|
||||||
|
# Flutter specific
|
||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
build/
|
||||||
|
ios/Flutter/Generated.xcconfig
|
||||||
|
ios/Flutter/flutter_export_environment.sh
|
||||||
|
macos/Flutter/ephemeral/
|
||||||
|
android/app/local.properties
|
||||||
|
android/app/GeneratedPluginRegistrant.java
|
||||||
|
linux/flutter/ephemeral/
|
||||||
|
windows/flutter/ephemeral/
|
||||||
|
*.lock
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
40
README.md
Normal file
40
README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# مُصادَق (Musadaq) — Financial Automation SaaS
|
||||||
|
|
||||||
|
منصة برمجية متكاملة (SaaS) لأتمتة دورة حياة الفواتير الضريبية في الأردن، مدمجة بذكاء اصطناعي لاستخراج البيانات وربطها مباشرة ببوابة "جو فوترة" (JoFotara).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏛️ المجلدات الرئيسية
|
||||||
|
|
||||||
|
| المجلد | الوظيفة | التقنيات |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| `backend/` | محرك المعالجة، التحقق الضريبي، AI، والربط الحكومي | NestJS, TypeORM, PostgreSQL, Bull, Gemini AI |
|
||||||
|
| `frontend/` | لوحة تحكم مكاتب المحاسبة والشركات | React, Vite, Tailwind CSS, React Query |
|
||||||
|
| `musadaq/` | تطبيق الموبايل للمسح الضوئي (Scanner) | Flutter, GetX |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 التشغيل السريع (Docker)
|
||||||
|
|
||||||
|
لتشغيل البيئة بالكامل:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 المزامنة والنشر (Sync to Server)
|
||||||
|
|
||||||
|
تم إعداد ملف `sync-to-server.sh` لأتمتة عملية الرفع والبناء على خادم الإنتاج (Contabo) بضغطة واحدة:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh sync-to-server.sh "وصف التعديلات هنا"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 الميزات الأمنية والقانونية
|
||||||
|
- **التحقق الضريبي**: تطبيق قواعد المحاسبة الأردنية (Rules 001-004).
|
||||||
|
- **جو فوترة**: توليد ملفات XML بمعيار **UBL 2.1**.
|
||||||
|
- **حماية البيانات**: تشفير AES-256 للمفاتيح الحساسة.
|
||||||
12
backend/.dockerignore
Normal file
12
backend/.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
uploads
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
|
*.md
|
||||||
|
test
|
||||||
|
.DS_Store
|
||||||
|
docker-compose*.yml
|
||||||
51
backend/.env.example
Normal file
51
backend/.env.example
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# مُصادَق (Musadaq) — Environment Variables Template
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# Copy this file to .env and fill in real values.
|
||||||
|
# NEVER commit .env to version control.
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# ── Application ────────────────────────────────────────────
|
||||||
|
NODE_ENV=
|
||||||
|
PORT=
|
||||||
|
APP_URL=
|
||||||
|
ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# ── Database (PostgreSQL) ─────────────────────────────────
|
||||||
|
DB_HOST=
|
||||||
|
DB_PORT=
|
||||||
|
DB_USER=
|
||||||
|
DB_PASS=
|
||||||
|
DB_NAME=
|
||||||
|
|
||||||
|
# ── Redis ──────────────────────────────────────────────────
|
||||||
|
REDIS_HOST=
|
||||||
|
REDIS_PORT=
|
||||||
|
|
||||||
|
# ── JWT Authentication ─────────────────────────────────────
|
||||||
|
JWT_SECRET=
|
||||||
|
JWT_EXPIRY=
|
||||||
|
JWT_REFRESH_SECRET=
|
||||||
|
JWT_REFRESH_EXPIRY=
|
||||||
|
|
||||||
|
# ── Encryption (AES-256-GCM) — 32 bytes = 64 hex chars ───
|
||||||
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# ── JoFotara ───────────────────────────────────────────────
|
||||||
|
JOFOTARA_SANDBOX_URL=
|
||||||
|
JOFOTARA_PROD_URL=
|
||||||
|
JOFOTARA_ENV=
|
||||||
|
|
||||||
|
# ── Gemini AI ──────────────────────────────────────────────
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
GEMINI_MODEL=
|
||||||
|
|
||||||
|
# ── File Storage ───────────────────────────────────────────
|
||||||
|
STORAGE_PATH=
|
||||||
|
|
||||||
|
# ── Email ──────────────────────────────────────────────────
|
||||||
|
RESEND_API_KEY=
|
||||||
|
EMAIL_FROM=
|
||||||
|
|
||||||
|
# ── Domain ─────────────────────────────────────────────────
|
||||||
|
DOMAIN=
|
||||||
7
backend/.gitignore
vendored
Normal file
7
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
uploads
|
||||||
|
*.log
|
||||||
|
coverage
|
||||||
|
.DS_Store
|
||||||
39
backend/Dockerfile
Normal file
39
backend/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# مُصادَق — Multi-stage Docker Build
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
# ── Stage 1: Builder ──────────────────────────
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 2: Production ──────────────────────
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
|
# Security: non-root user
|
||||||
|
RUN addgroup -g 1001 -S musadaq && \
|
||||||
|
adduser -S musadaq -u 1001 -G musadaq
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy only production deps
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Create uploads directory
|
||||||
|
RUN mkdir -p /app/uploads && chown -R musadaq:musadaq /app
|
||||||
|
|
||||||
|
USER musadaq
|
||||||
|
|
||||||
|
EXPOSE 3300
|
||||||
|
|
||||||
|
CMD ["node", "dist/main"]
|
||||||
24
backend/data-source.ts
Normal file
24
backend/data-source.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق — TypeORM CLI Data Source
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const AppDataSource = new DataSource({
|
||||||
|
type: 'postgres',
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: parseInt(process.env.DB_PORT || '5300'),
|
||||||
|
username: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
synchronize: false,
|
||||||
|
logging: true,
|
||||||
|
entities: ['src/**/entities/*.entity.ts'],
|
||||||
|
migrations: ['src/migrations/*.ts'],
|
||||||
|
subscribers: [],
|
||||||
|
});
|
||||||
111
backend/docker-compose.yml
Normal file
111
backend/docker-compose.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# مُصادَق (Musadaq) — Docker Compose
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# Services: NestJS API (3300) + PostgreSQL (5300) + Redis (6400)
|
||||||
|
# Usage: docker compose up -d
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ── NestJS API ─────────────────────────────────────────
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: musadaq-api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3300}:${PORT:-3300}"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: ${NODE_ENV:-development}
|
||||||
|
PORT: ${PORT:-3300}
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASS: ${DB_PASS}
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_EXPIRY: ${JWT_EXPIRY}
|
||||||
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
|
||||||
|
JWT_REFRESH_EXPIRY: ${JWT_REFRESH_EXPIRY}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
|
JOFOTARA_SANDBOX_URL: ${JOFOTARA_SANDBOX_URL}
|
||||||
|
JOFOTARA_PROD_URL: ${JOFOTARA_PROD_URL}
|
||||||
|
JOFOTARA_ENV: ${JOFOTARA_ENV}
|
||||||
|
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
||||||
|
GEMINI_MODEL: ${GEMINI_MODEL}
|
||||||
|
STORAGE_PATH: /app/uploads
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||||
|
EMAIL_FROM: ${EMAIL_FROM}
|
||||||
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}
|
||||||
|
DOMAIN: ${DOMAIN}
|
||||||
|
volumes:
|
||||||
|
- invoice_uploads:/app/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- musadaq-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:${PORT:-3300}/api/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# ── PostgreSQL 16 ──────────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: musadaq-db
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5300}:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-musadaq_user}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASS:-dev_password_min_32_chars_long_here!}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-musadaq_db}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- musadaq-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-musadaq_user} -d ${DB_NAME:-musadaq_db}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# ── Redis 7 ────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: musadaq-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6400}:6379"
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- musadaq-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── Volumes ────────────────────────────────────────────────
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
invoice_uploads:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
# ── Network ────────────────────────────────────────────────
|
||||||
|
networks:
|
||||||
|
musadaq-network:
|
||||||
|
driver: bridge
|
||||||
8
backend/nest-cli.json
Normal file
8
backend/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
90
backend/package.json
Normal file
90
backend/package.json
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"name": "musadaq-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "مُصادَق — منصة أتمتة الفوترة الضريبية الأردنية",
|
||||||
|
"author": "Musadaq Team",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
|
||||||
|
"migration:run": "npm run typeorm -- migration:run -d data-source.ts",
|
||||||
|
"migration:revert": "npm run typeorm -- migration:revert -d data-source.ts",
|
||||||
|
"migration:generate": "npm run typeorm -- migration:generate -d data-source.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/generative-ai": "^0.11.1",
|
||||||
|
"@nestjs/bull": "^10.1.1",
|
||||||
|
"@nestjs/common": "^10.3.8",
|
||||||
|
"@nestjs/config": "^3.2.2",
|
||||||
|
"@nestjs/core": "^10.3.8",
|
||||||
|
"@nestjs/jwt": "^10.2.0",
|
||||||
|
"@nestjs/passport": "^10.0.3",
|
||||||
|
"@nestjs/platform-express": "^10.3.8",
|
||||||
|
"@nestjs/platform-socket.io": "^10.3.8",
|
||||||
|
"@nestjs/throttler": "^5.1.2",
|
||||||
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
|
"@nestjs/websockets": "^10.3.8",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"bull": "^4.12.2",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"joi": "^17.12.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.11.5",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "^0.3.20",
|
||||||
|
"xmlbuilder2": "^3.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.3.2",
|
||||||
|
"@nestjs/schematics": "^10.1.1",
|
||||||
|
"@nestjs/testing": "^10.3.8",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/bull": "^4.10.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/multer": "^1.4.11",
|
||||||
|
"@types/node": "^20.12.7",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.7.1",
|
||||||
|
"@typescript-eslint/parser": "^7.7.1",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"ts-jest": "^29.1.2",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||||
|
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
backend/src/app.module.ts
Normal file
83
backend/src/app.module.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Root Application Module
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
|
||||||
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { envValidationSchema } from './config/env.validation';
|
||||||
|
import { databaseConfig } from './config/database.config';
|
||||||
|
|
||||||
|
import { HealthController } from './modules/health/health.controller';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { TenantsModule } from './modules/tenants/tenant.module';
|
||||||
|
import { UsersModule } from './modules/users/user.module';
|
||||||
|
import { CompaniesModule } from './modules/companies/company.module';
|
||||||
|
import { SubscriptionsModule } from './modules/subscriptions/subscription.module';
|
||||||
|
import { InvoicesModule } from './modules/invoices/invoice.module';
|
||||||
|
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// ── Configuration ─────────────────────────────────────────
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
validationSchema: envValidationSchema,
|
||||||
|
cache: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ── Database (TypeORM) ────────────────────────────────────
|
||||||
|
TypeOrmModule.forRootAsync(databaseConfig),
|
||||||
|
|
||||||
|
// ── Queue (Bull/Redis) ────────────────────────────────────
|
||||||
|
BullModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
redis: {
|
||||||
|
host: configService.getOrThrow<string>('REDIS_HOST'),
|
||||||
|
port: configService.getOrThrow<number>('REDIS_PORT'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ── Rate Limiting (Throttler) ─────────────────────────────
|
||||||
|
ThrottlerModule.forRoot([{
|
||||||
|
name: 'short',
|
||||||
|
ttl: 1000, // 1 second
|
||||||
|
limit: 3, // 3 requests
|
||||||
|
}, {
|
||||||
|
name: 'long',
|
||||||
|
ttl: 60000, // 1 minute
|
||||||
|
limit: 100, // 100 requests
|
||||||
|
}]),
|
||||||
|
|
||||||
|
// ── Functional Modules ────────────────────────────────────
|
||||||
|
AuthModule,
|
||||||
|
TenantsModule,
|
||||||
|
UsersModule,
|
||||||
|
CompaniesModule,
|
||||||
|
SubscriptionsModule,
|
||||||
|
InvoicesModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
// Global Rate Limiting Guard
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
// Global Audit Log Interceptor
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: AuditLogInterceptor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
14
backend/src/common/decorators/current-user.decorator.ts
Normal file
14
backend/src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — CurrentUser Decorator
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: unknown, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return request.user;
|
||||||
|
},
|
||||||
|
);
|
||||||
11
backend/src/common/decorators/roles.decorator.ts
Normal file
11
backend/src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Roles Decorator
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { UserRole } from '../../modules/users/enums/role.enum';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
54
backend/src/common/filters/global-exception.filter.ts
Normal file
54
backend/src/common/filters/global-exception.filter.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Global Exception Filter
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
|
const status =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.getStatus()
|
||||||
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
const message =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.getResponse()
|
||||||
|
: 'Internal Server Error';
|
||||||
|
|
||||||
|
// Log the error
|
||||||
|
if (status >= 500) {
|
||||||
|
this.logger.error(
|
||||||
|
`${request.method} ${request.url} - ${status} - ${JSON.stringify(message)}`,
|
||||||
|
exception instanceof Error ? exception.stack : '',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`${request.method} ${request.url} - ${status} - ${JSON.stringify(message)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(status).json({
|
||||||
|
statusCode: status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
message: typeof message === 'string' ? message : (message as any).message || message,
|
||||||
|
error: typeof message === 'string' ? null : (message as any).error || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/src/common/guards/jwt-auth.guard.ts
Normal file
30
backend/src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — JWT Auth Guard
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
canActivate(
|
||||||
|
context: ExecutionContext,
|
||||||
|
): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
|
// Add custom authentication logic here if needed
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRequest(err: any, user: any, info: any) {
|
||||||
|
if (err || !user) {
|
||||||
|
throw err || new UnauthorizedException('Authentication Required');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/src/common/guards/roles.guard.ts
Normal file
45
backend/src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Roles Guard
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { UserRole } from '../../modules/users/enums/role.enum';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRole = requiredRoles.includes(user.role);
|
||||||
|
|
||||||
|
if (!hasRole) {
|
||||||
|
throw new ForbiddenException('Required Role Missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/src/common/guards/tenant.guard.ts
Normal file
32
backend/src/common/guards/tenant.guard.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Tenant Guard
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* يضمن أن المستخدم الحالي يحمل نفس tenant_id الكيان المطلوب.
|
||||||
|
* يمنع الوصول العشوائي للبيانات عبر المستأجرين.
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const { user, params } = request;
|
||||||
|
|
||||||
|
if (!user || !user.tenantId) {
|
||||||
|
throw new ForbiddenException('Invalid User Context');
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a stub: real implementation will check the target entity's tenant_id
|
||||||
|
// against the user's tenant_id if applicable.
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/src/common/interceptors/audit-log.interceptor.ts
Normal file
37
backend/src/common/interceptors/audit-log.interceptor.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Audit Log Interceptor
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditLogInterceptor implements NestInterceptor {
|
||||||
|
private readonly logger = new Logger(AuditLogInterceptor.name);
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const { user, method, url } = request;
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap(() => {
|
||||||
|
// Only log non-GET requests (mutative operations)
|
||||||
|
if (method !== 'GET') {
|
||||||
|
this.logger.log(
|
||||||
|
`Audit: User ${user?.id || 'Anonymous'} - ${method} ${url}`,
|
||||||
|
);
|
||||||
|
// Phase 2: Save to Database
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/src/common/middleware/tenant.middleware.ts
Normal file
32
backend/src/common/middleware/tenant.middleware.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Tenant Middleware
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* يستخرج tenantId من JWT ويضعه في request.
|
||||||
|
* يستخدم في تصفية البيانات (Query filtering).
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestMiddleware,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantMiddleware implements NestMiddleware {
|
||||||
|
use(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const { user } = req as any;
|
||||||
|
|
||||||
|
if (!user || !user.tenantId) {
|
||||||
|
// Phase 1: Not always required (registration, login)
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set tenant context for the request
|
||||||
|
(req as any).tenantId = user.tenantId;
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
47
backend/src/config/database.config.ts
Normal file
47
backend/src/config/database.config.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق — TypeORM Database Configuration
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* تهيئة اتصال PostgreSQL من متغيرات البيئة.
|
||||||
|
* synchronize: false دائماً — نستخدم migrations فقط.
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TypeOrmModuleAsyncOptions } from '@nestjs/typeorm';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
export const databaseConfig: TypeOrmModuleAsyncOptions = {
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
type: 'postgres' as const,
|
||||||
|
host: configService.getOrThrow<string>('DB_HOST'),
|
||||||
|
port: configService.getOrThrow<number>('DB_PORT'),
|
||||||
|
username: configService.getOrThrow<string>('DB_USER'),
|
||||||
|
password: configService.getOrThrow<string>('DB_PASS'),
|
||||||
|
database: configService.getOrThrow<string>('DB_NAME'),
|
||||||
|
|
||||||
|
// Entity auto-discovery
|
||||||
|
autoLoadEntities: true,
|
||||||
|
|
||||||
|
// NEVER synchronize — use migrations only
|
||||||
|
synchronize: false,
|
||||||
|
|
||||||
|
// SSL in production
|
||||||
|
ssl: configService.get<string>('NODE_ENV') === 'production'
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: false,
|
||||||
|
|
||||||
|
// Logging based on environment
|
||||||
|
logging: configService.get<string>('NODE_ENV') === 'development'
|
||||||
|
? ['query', 'error', 'warn']
|
||||||
|
: ['error'],
|
||||||
|
|
||||||
|
// Connection pool
|
||||||
|
extra: {
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
136
backend/src/config/env.validation.ts
Normal file
136
backend/src/config/env.validation.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق — Environment Validation Schema (Joi)
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* يتحقق من وجود وصحة جميع متغيرات البيئة عند بدء التطبيق.
|
||||||
|
* أي متغير مفقود أو غير صالح = فشل فوري في التشغيل.
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
|
||||||
|
export const envValidationSchema = Joi.object({
|
||||||
|
// ── Application ────────────────────────────────────────
|
||||||
|
NODE_ENV: Joi.string()
|
||||||
|
.valid('development', 'production', 'test')
|
||||||
|
.required()
|
||||||
|
.description('بيئة التشغيل'),
|
||||||
|
|
||||||
|
PORT: Joi.number()
|
||||||
|
.port()
|
||||||
|
.default(3300)
|
||||||
|
.description('منفذ الخادم'),
|
||||||
|
|
||||||
|
APP_URL: Joi.string()
|
||||||
|
.uri()
|
||||||
|
.required()
|
||||||
|
.description('رابط التطبيق الكامل'),
|
||||||
|
|
||||||
|
ALLOWED_ORIGINS: Joi.string()
|
||||||
|
.required()
|
||||||
|
.description('النطاقات المسموح بها للـ CORS (مفصولة بفاصلة)'),
|
||||||
|
|
||||||
|
// ── Database ───────────────────────────────────────────
|
||||||
|
DB_HOST: Joi.string()
|
||||||
|
.required()
|
||||||
|
.description('مضيف قاعدة البيانات'),
|
||||||
|
|
||||||
|
DB_PORT: Joi.number()
|
||||||
|
.port()
|
||||||
|
.default(5300)
|
||||||
|
.description('منفذ قاعدة البيانات'),
|
||||||
|
|
||||||
|
DB_USER: Joi.string()
|
||||||
|
.required()
|
||||||
|
.description('مستخدم قاعدة البيانات'),
|
||||||
|
|
||||||
|
DB_PASS: Joi.string()
|
||||||
|
.min(32)
|
||||||
|
.required()
|
||||||
|
.description('كلمة مرور قاعدة البيانات (32 حرف كحد أدنى)'),
|
||||||
|
|
||||||
|
DB_NAME: Joi.string()
|
||||||
|
.required()
|
||||||
|
.description('اسم قاعدة البيانات'),
|
||||||
|
|
||||||
|
// ── Redis ──────────────────────────────────────────────
|
||||||
|
REDIS_HOST: Joi.string()
|
||||||
|
.required()
|
||||||
|
.description('مضيف Redis'),
|
||||||
|
|
||||||
|
REDIS_PORT: Joi.number()
|
||||||
|
.port()
|
||||||
|
.default(6400)
|
||||||
|
.description('منفذ Redis'),
|
||||||
|
|
||||||
|
// ── JWT ────────────────────────────────────────────────
|
||||||
|
JWT_SECRET: Joi.string()
|
||||||
|
.min(64)
|
||||||
|
.required()
|
||||||
|
.description('مفتاح JWT الرئيسي (64 حرف كحد أدنى)'),
|
||||||
|
|
||||||
|
JWT_EXPIRY: Joi.string()
|
||||||
|
.default('15m')
|
||||||
|
.description('مدة صلاحية Access Token'),
|
||||||
|
|
||||||
|
JWT_REFRESH_SECRET: Joi.string()
|
||||||
|
.min(64)
|
||||||
|
.required()
|
||||||
|
.description('مفتاح JWT للتجديد (مختلف عن الرئيسي)'),
|
||||||
|
|
||||||
|
JWT_REFRESH_EXPIRY: Joi.string()
|
||||||
|
.default('7d')
|
||||||
|
.description('مدة صلاحية Refresh Token'),
|
||||||
|
|
||||||
|
// ── Encryption ─────────────────────────────────────────
|
||||||
|
ENCRYPTION_KEY: Joi.string()
|
||||||
|
.length(64)
|
||||||
|
.hex()
|
||||||
|
.required()
|
||||||
|
.description('مفتاح التشفير AES-256 (32 بايت = 64 حرف hex)'),
|
||||||
|
|
||||||
|
// ── JoFotara ───────────────────────────────────────────
|
||||||
|
JOFOTARA_SANDBOX_URL: Joi.string()
|
||||||
|
.uri()
|
||||||
|
.required()
|
||||||
|
.description('رابط بيئة الاختبار لجو فوترة'),
|
||||||
|
|
||||||
|
JOFOTARA_PROD_URL: Joi.string()
|
||||||
|
.uri()
|
||||||
|
.required()
|
||||||
|
.description('رابط بيئة الإنتاج لجو فوترة'),
|
||||||
|
|
||||||
|
JOFOTARA_ENV: Joi.string()
|
||||||
|
.valid('sandbox', 'production')
|
||||||
|
.default('sandbox')
|
||||||
|
.description('بيئة جو فوترة المستخدمة'),
|
||||||
|
|
||||||
|
// ── Gemini AI ──────────────────────────────────────────
|
||||||
|
GEMINI_API_KEY: Joi.string()
|
||||||
|
.required()
|
||||||
|
.description('مفتاح Google Gemini API'),
|
||||||
|
|
||||||
|
GEMINI_MODEL: Joi.string()
|
||||||
|
.default('gemini-2.0-flash-lite')
|
||||||
|
.description('نموذج Gemini المستخدم'),
|
||||||
|
|
||||||
|
// ── File Storage ───────────────────────────────────────
|
||||||
|
STORAGE_PATH: Joi.string()
|
||||||
|
.default('./uploads')
|
||||||
|
.description('مسار تخزين الملفات'),
|
||||||
|
|
||||||
|
// ── Email ──────────────────────────────────────────────
|
||||||
|
RESEND_API_KEY: Joi.string()
|
||||||
|
.required()
|
||||||
|
.description('مفتاح Resend API للبريد الإلكتروني'),
|
||||||
|
|
||||||
|
EMAIL_FROM: Joi.string()
|
||||||
|
.email()
|
||||||
|
.required()
|
||||||
|
.description('عنوان المرسل للبريد الإلكتروني'),
|
||||||
|
|
||||||
|
// ── Domain ─────────────────────────────────────────────
|
||||||
|
DOMAIN: Joi.string()
|
||||||
|
.required()
|
||||||
|
.description('اسم النطاق الرئيسي'),
|
||||||
|
});
|
||||||
27
backend/src/config/jwt.config.ts
Normal file
27
backend/src/config/jwt.config.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق — JWT Configuration
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { JwtModuleAsyncOptions } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
export const jwtConfig: JwtModuleAsyncOptions = {
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
secret: configService.getOrThrow<string>('JWT_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: configService.get<string>('JWT_EXPIRY', '15m'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh token config — used separately in AuthService
|
||||||
|
*/
|
||||||
|
export const getRefreshTokenConfig = (configService: ConfigService) => ({
|
||||||
|
secret: configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||||
|
expiresIn: configService.get<string>('JWT_REFRESH_EXPIRY', '7d'),
|
||||||
|
});
|
||||||
78
backend/src/main.ts
Normal file
78
backend/src/main.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Application Bootstrap
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* نقطة الدخول الرئيسية مع طبقات الأمان الكاملة:
|
||||||
|
* - Helmet (HTTP Security Headers)
|
||||||
|
* - CORS (مقيد بنطاقات محددة)
|
||||||
|
* - Global Validation Pipe (تنقية المدخلات)
|
||||||
|
* - Global Exception Filter (أخطاء موحدة)
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
const logger = new Logger('Bootstrap');
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger: ['error', 'warn', 'log', 'debug'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const configService = app.get(ConfigService);
|
||||||
|
const port = configService.get<number>('PORT', 3300);
|
||||||
|
const allowedOrigins = configService.get<string>('ALLOWED_ORIGINS', '');
|
||||||
|
|
||||||
|
// ── LAYER 1: HTTP Security Headers (Helmet) ──────────
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: false, // API only — no CSP needed
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── LAYER 2: CORS — مقيد بنطاقات محددة فقط ───────────
|
||||||
|
app.enableCors({
|
||||||
|
origin: allowedOrigins.split(',').map((o) => o.trim()),
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── LAYER 3: Global Validation Pipe ───────────────────
|
||||||
|
// whitelist: يحذف أي حقل غير معرّف في الـ DTO
|
||||||
|
// forbidNonWhitelisted: يرفض الطلب إذا وُجد حقل زائد
|
||||||
|
// transform: يحوّل الأنواع تلقائياً (string → number, etc.)
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: {
|
||||||
|
enableImplicitConversion: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── LAYER 4: Global Exception Filter ──────────────────
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||||
|
|
||||||
|
// ── API Prefix ────────────────────────────────────────
|
||||||
|
app.setGlobalPrefix('api', {
|
||||||
|
exclude: ['/'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Start Server ──────────────────────────────────────
|
||||||
|
await app.listen(port, '0.0.0.0');
|
||||||
|
|
||||||
|
logger.log(`══════════════════════════════════════════════`);
|
||||||
|
logger.log(`🟢 مُصادَق (Musadaq) running on port ${port}`);
|
||||||
|
logger.log(`🌍 Environment: ${configService.get('NODE_ENV')}`);
|
||||||
|
logger.log(`🔒 CORS: ${allowedOrigins}`);
|
||||||
|
logger.log(`══════════════════════════════════════════════`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
51
backend/src/migrations/1713304298000-InitialSchema.ts
Normal file
51
backend/src/migrations/1713304298000-InitialSchema.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class InitialSchema1713304298000 implements MigrationInterface {
|
||||||
|
name = 'InitialSchema1713304298000'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// ── Create Enums ──────────────────────────────────────
|
||||||
|
await queryRunner.query(`CREATE TYPE "public"."tenants_status_enum" AS ENUM('active', 'suspended', 'trial')`);
|
||||||
|
await queryRunner.query(`CREATE TYPE "public"."users_role_enum" AS ENUM('admin', 'accountant', 'viewer')`);
|
||||||
|
await queryRunner.query(`CREATE TYPE "public"."subscriptions_plan_enum" AS ENUM('basic', 'office', 'pro', 'enterprise')`);
|
||||||
|
await queryRunner.query(`CREATE TYPE "public"."subscriptions_status_enum" AS ENUM('active', 'past_due', 'cancelled')`);
|
||||||
|
|
||||||
|
// ── Tenants ───────────────────────────────────────────
|
||||||
|
await queryRunner.query(`CREATE TABLE "tenants" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "name" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "phone" character varying(20), "status" "public"."tenants_status_enum" NOT NULL DEFAULT 'trial', "trial_ends_at" TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_71458e0a4f5c5b5f5f5f5f5f5f5" UNIQUE ("email"), CONSTRAINT "PK_71458e0a4f5c5b5f5f5f5f5f5f5" PRIMARY KEY ("id"))`);
|
||||||
|
|
||||||
|
// ── Users ─────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "tenant_id" uuid NOT NULL, "name" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "password_hash" character varying(255) NOT NULL, "role" "public"."users_role_enum" NOT NULL, "refresh_token_hash" character varying(255), "is_active" boolean NOT NULL DEFAULT true, "last_login_at" TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`);
|
||||||
|
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_tenant_email_unique" ON "users" ("tenant_id", "email")`);
|
||||||
|
|
||||||
|
// ── Companies ─────────────────────────────────────────
|
||||||
|
await queryRunner.query(`CREATE TABLE "companies" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "tenant_id" uuid NOT NULL, "name" character varying(255) NOT NULL, "name_en" character varying(255), "tax_identification_number" character varying(20) NOT NULL, "address" text, "jofotara_client_id_encrypted" text, "jofotara_secret_key_encrypted" text, "jofotara_income_source_sequence" character varying(50), "is_active" boolean NOT NULL DEFAULT true, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_d4c35295f3d3b66e0d3ca60938a" PRIMARY KEY ("id"))`);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_companies_name" ON "companies" ("name")`);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_companies_tin" ON "companies" ("tax_identification_number")`);
|
||||||
|
|
||||||
|
// ── Subscriptions ─────────────────────────────────────
|
||||||
|
await queryRunner.query(`CREATE TABLE "subscriptions" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "tenant_id" uuid NOT NULL, "plan" "public"."subscriptions_plan_enum" NOT NULL, "max_companies" integer NOT NULL, "max_invoices_per_month" integer NOT NULL, "price_jod" numeric(10,2) NOT NULL, "billing_cycle" "public"."subscriptions_status_enum" NOT NULL DEFAULT 'active', "current_period_start" TIMESTAMP, "current_period_end" TIMESTAMP, "invoices_used_this_month" integer NOT NULL DEFAULT 0, "status" "public"."subscriptions_status_enum" NOT NULL DEFAULT 'active', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_subscriptions_id" PRIMARY KEY ("id"))`);
|
||||||
|
|
||||||
|
// ── Foreign Keys ──────────────────────────────────────
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "FK_users_tenant" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "companies" ADD CONSTRAINT "FK_companies_tenant" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "subscriptions" ADD CONSTRAINT "FK_subscriptions_tenant" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "subscriptions" DROP CONSTRAINT "FK_subscriptions_tenant"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "companies" DROP CONSTRAINT "FK_companies_tenant"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "FK_users_tenant"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_companies_tin"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_companies_name"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "companies"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_tenant_email_unique"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "users"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "tenants"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "subscriptions"`);
|
||||||
|
await queryRunner.query(`DROP TYPE "public"."subscriptions_status_enum"`);
|
||||||
|
await queryRunner.query(`DROP TYPE "public"."subscriptions_plan_enum"`);
|
||||||
|
await queryRunner.query(`DROP TYPE "public"."users_role_enum"`);
|
||||||
|
await queryRunner.query(`DROP TYPE "public"."tenants_status_enum"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
74
backend/src/modules/auth/auth.controller.ts
Normal file
74
backend/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Authentication Controller
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تسجيل مكتب جديد + مدير مسؤول
|
||||||
|
*/
|
||||||
|
@Post('register')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async register(@Body() dto: RegisterDto) {
|
||||||
|
return this.authService.register(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تسجيل الدخول
|
||||||
|
*/
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async login(@Body() dto: LoginDto) {
|
||||||
|
return this.authService.login(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تجديد التوكن
|
||||||
|
*/
|
||||||
|
@UseGuards(AuthGuard('jwt-refresh'))
|
||||||
|
@Post('refresh')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async refresh(@CurrentUser() user: any) {
|
||||||
|
return this.authService.refresh(user.id, user.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تسجيل الخروج
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async logout(@CurrentUser() user: any) {
|
||||||
|
return this.authService.logout(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* الملف الشخصي
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post('profile')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async profile(@CurrentUser() user: any) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/src/modules/auth/auth.module.ts
Normal file
30
backend/src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Auth Module
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
|
||||||
|
import { jwtConfig } from '../../config/jwt.config';
|
||||||
|
import { User } from '../users/entities/user.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.registerAsync(jwtConfig),
|
||||||
|
TypeOrmModule.forFeature([User]),
|
||||||
|
ConfigModule,
|
||||||
|
],
|
||||||
|
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
|
||||||
|
controllers: [AuthController],
|
||||||
|
exports: [AuthService, JwtStrategy, PassportModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
204
backend/src/modules/auth/auth.service.ts
Normal file
204
backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Authentication Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
ConflictException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
import { Tenant, TenantStatus } from '../../tenants/entities/tenant.entity';
|
||||||
|
import { UserRole } from '../../users/enums/role.enum';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { Subscription, SubscriptionPlan, SubscriptionStatus } from '../../subscriptions/entities/subscription.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private jwtService: JwtService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تسجيل مستخدم جديد (مدير مكتب)
|
||||||
|
*/
|
||||||
|
async register(dto: RegisterDto) {
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Check if user exists
|
||||||
|
const existingUser = await queryRunner.manager.findOne(User, {
|
||||||
|
where: { email: dto.email },
|
||||||
|
});
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException('Email already registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create Tenant (Accounting Office)
|
||||||
|
const tenant = queryRunner.manager.create(Tenant, {
|
||||||
|
name: dto.tenantName,
|
||||||
|
email: dto.email, // Use same email for office contact initially
|
||||||
|
phone: dto.phone,
|
||||||
|
status: TenantStatus.TRIAL,
|
||||||
|
trial_ends_at: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days trial
|
||||||
|
});
|
||||||
|
const savedTenant = await queryRunner.manager.save(tenant);
|
||||||
|
|
||||||
|
// 3. Create Default Subscription (Trial)
|
||||||
|
const subscription = queryRunner.manager.create(Subscription, {
|
||||||
|
tenant_id: savedTenant.id,
|
||||||
|
plan: SubscriptionPlan.BASIC,
|
||||||
|
max_companies: 1,
|
||||||
|
max_invoices_per_month: 200,
|
||||||
|
price_jod: 15, // Basic price
|
||||||
|
status: SubscriptionStatus.ACTIVE,
|
||||||
|
current_period_start: new Date(),
|
||||||
|
current_period_end: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
|
||||||
|
});
|
||||||
|
await queryRunner.manager.save(subscription);
|
||||||
|
|
||||||
|
// 4. Create Admin User
|
||||||
|
const passwordHash = await this.hashPassword(dto.password);
|
||||||
|
const user = queryRunner.manager.create(User, {
|
||||||
|
tenant_id: savedTenant.id,
|
||||||
|
name: dto.name,
|
||||||
|
email: dto.email,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
role: UserRole.ADMIN,
|
||||||
|
});
|
||||||
|
const savedUser = await queryRunner.manager.save(user);
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: savedUser.id,
|
||||||
|
tenantId: savedTenant.id,
|
||||||
|
message: 'Registration successful',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
if (error instanceof ConflictException) throw error;
|
||||||
|
throw new InternalServerErrorException('Registration failed');
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تسجيل دخول
|
||||||
|
*/
|
||||||
|
async login(dto: LoginDto) {
|
||||||
|
const user = await this.dataSource.getRepository(User).findOne({
|
||||||
|
where: { email: dto.email, is_active: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !(await this.comparePassword(dto.password, user.password_hash))) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
sub: user.id,
|
||||||
|
tenantId: user.tenant_id,
|
||||||
|
role: user.role
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = await this.jwtService.signAsync(payload);
|
||||||
|
const refreshToken = await this.generateRefreshToken(payload);
|
||||||
|
|
||||||
|
// Save refresh token hash
|
||||||
|
await this.updateRefreshToken(user.id, refreshToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
tenantId: user.tenant_id,
|
||||||
|
},
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تجديد التوكن
|
||||||
|
*/
|
||||||
|
async refresh(userId: string, refreshToken: string) {
|
||||||
|
const user = await this.dataSource.getRepository(User).findOne({
|
||||||
|
where: { id: userId, is_active: true },
|
||||||
|
select: ['id', 'tenant_id', 'role', 'refresh_token_hash'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.refresh_token_hash) {
|
||||||
|
throw new UnauthorizedException('Access Denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await bcrypt.compare(refreshToken, user.refresh_token_hash);
|
||||||
|
if (!isMatch) {
|
||||||
|
throw new UnauthorizedException('Access Denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
sub: user.id,
|
||||||
|
tenantId: user.tenant_id,
|
||||||
|
role: user.role
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = await this.jwtService.signAsync(payload);
|
||||||
|
const newRefreshToken = await this.generateRefreshToken(payload);
|
||||||
|
|
||||||
|
await this.updateRefreshToken(user.id, newRefreshToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تسجيل خروج
|
||||||
|
*/
|
||||||
|
async logout(userId: string) {
|
||||||
|
await this.dataSource.getRepository(User).update(userId, {
|
||||||
|
refresh_token_hash: undefined,
|
||||||
|
});
|
||||||
|
return { message: 'Logged out' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async comparePassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateRefreshToken(payload: any): Promise<string> {
|
||||||
|
return this.jwtService.signAsync(payload, {
|
||||||
|
secret: this.configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||||
|
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRY', '7d'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateRefreshToken(userId: string, refreshToken: string) {
|
||||||
|
const hash = await bcrypt.hash(refreshToken, 10);
|
||||||
|
await this.dataSource.getRepository(User).update(userId, {
|
||||||
|
refresh_token_hash: hash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/modules/auth/dto/login.dto.ts
Normal file
16
backend/src/modules/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Login DTO
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
31
backend/src/modules/auth/dto/register.dto.ts
Normal file
31
backend/src/modules/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Register DTO
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IsEmail, IsNotEmpty, IsString, MinLength, IsOptional, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
tenantName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, {
|
||||||
|
message: 'Password is too weak (Must have uppercase, lowercase, and number/special char)',
|
||||||
|
})
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
43
backend/src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal file
43
backend/src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — JWT Refresh Strategy
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
|
(request: Request) => {
|
||||||
|
// Extract from HttpOnly Cookie (prod) or Header (dev)
|
||||||
|
return request?.cookies?.refreshToken || request?.headers?.['x-refresh-token'];
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
passReqToCallback: true,
|
||||||
|
secretOrKey: configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(req: Request, payload: any) {
|
||||||
|
const refreshToken = req?.cookies?.refreshToken || req?.headers?.['x-refresh-token'];
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new UnauthorizedException('Refresh token missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
tenantId: payload.tenantId,
|
||||||
|
role: payload.role,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
43
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
43
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — JWT Strategy
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: any) {
|
||||||
|
const user = await this.dataSource.getRepository(User).findOne({
|
||||||
|
where: { id: payload.sub, is_active: true },
|
||||||
|
select: ['id', 'tenant_id', 'role'], // Add only necessary fields to context
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('User not found or inactive');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
tenantId: user.tenant_id,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
72
backend/src/modules/companies/company.controller.ts
Normal file
72
backend/src/modules/companies/company.controller.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Companies Controller
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
Put,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CompaniesService } from './company.service';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../../common/decorators/roles.decorator';
|
||||||
|
import { UserRole } from '../users/enums/role.enum';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@Controller('companies')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class CompaniesController {
|
||||||
|
constructor(private companiesService: CompaniesService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
|
||||||
|
async create(@CurrentUser() user: any, @Body() dto: any) {
|
||||||
|
return this.companiesService.create(user.tenantId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async findAll(@CurrentUser() user: any) {
|
||||||
|
return this.companiesService.findAll(user.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(@CurrentUser() user: any, @Param('id') id: string) {
|
||||||
|
return this.companiesService.findOne(user.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
|
||||||
|
async update(
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: any,
|
||||||
|
) {
|
||||||
|
return this.companiesService.update(user.tenantId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ربط بيانات جو فوترة المشفرة
|
||||||
|
*/
|
||||||
|
@Put(':id/jofotara')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async setJoFotara(
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: { clientId: string; secretKey: string },
|
||||||
|
) {
|
||||||
|
return this.companiesService.setJoFotaraCredentials(
|
||||||
|
user.tenantId,
|
||||||
|
id,
|
||||||
|
dto.clientId,
|
||||||
|
dto.secretKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/src/modules/companies/company.module.ts
Normal file
24
backend/src/modules/companies/company.module.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Companies Module
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { CompaniesService } from './company.service';
|
||||||
|
import { CompaniesController } from './company.controller';
|
||||||
|
import { Company } from './entities/company.entity';
|
||||||
|
import { EncryptionService } from '../../services/encryption/encryption.service';
|
||||||
|
import { SubscriptionsModule } from '../subscriptions/subscription.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Company]),
|
||||||
|
forwardRef(() => SubscriptionsModule),
|
||||||
|
],
|
||||||
|
providers: [CompaniesService, EncryptionService],
|
||||||
|
controllers: [CompaniesController],
|
||||||
|
exports: [CompaniesService],
|
||||||
|
})
|
||||||
|
export class CompaniesModule {}
|
||||||
113
backend/src/modules/companies/company.service.ts
Normal file
113
backend/src/modules/companies/company.service.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Companies Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Company } from './entities/company.entity';
|
||||||
|
import { EncryptionService } from '../../services/encryption/encryption.service';
|
||||||
|
import { SubscriptionsService } from '../subscriptions/subscription.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CompaniesService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Company)
|
||||||
|
private companyRepository: Repository<Company>,
|
||||||
|
private encryptionService: EncryptionService,
|
||||||
|
@Inject(forwardRef(() => SubscriptionsService))
|
||||||
|
private subscriptionsService: SubscriptionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* إنشاء شركة جديدة مع التحقق من حدود الاشتراك
|
||||||
|
*/
|
||||||
|
async create(tenantId: string, dto: any): Promise<Company> {
|
||||||
|
// 1. Check subscription limits
|
||||||
|
const canCreate = await this.subscriptionsService.checkCompanyLimit(tenantId);
|
||||||
|
if (!canCreate) {
|
||||||
|
throw new ForbiddenException('Company limit reached for your current plan');
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = this.companyRepository.create({
|
||||||
|
...dto,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.companyRepository.save(company);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قائمة الشركات التابعة للمكتب
|
||||||
|
*/
|
||||||
|
async findAll(tenantId: string): Promise<Company[]> {
|
||||||
|
return this.companyRepository.find({
|
||||||
|
where: { tenant_id: tenantId, is_active: true },
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تفاصيل شركة محددة
|
||||||
|
*/
|
||||||
|
async findOne(tenantId: string, id: string): Promise<Company> {
|
||||||
|
const company = await this.companyRepository.findOne({
|
||||||
|
where: { id, tenant_id: tenantId },
|
||||||
|
});
|
||||||
|
if (!company) throw new NotFoundException('Company not found');
|
||||||
|
return company;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تحديث بيانات شركة
|
||||||
|
*/
|
||||||
|
async update(tenantId: string, id: string, dto: any): Promise<Company> {
|
||||||
|
const company = await this.findOne(tenantId, id);
|
||||||
|
Object.assign(company, dto);
|
||||||
|
return this.companyRepository.save(company);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* حفظ مفاتيح جو فوترة (مُشفرة)
|
||||||
|
*/
|
||||||
|
async setJoFotaraCredentials(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
clientId: string,
|
||||||
|
secretKey: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const company = await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
company.jofotara_client_id_encrypted = this.encryptionService.encrypt(clientId);
|
||||||
|
company.jofotara_secret_key_encrypted = this.encryptionService.encrypt(secretKey);
|
||||||
|
|
||||||
|
await this.companyRepository.save(company);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* الحصول على المفاتيح (مفكوك تشفيرها) — للاستخدام الداخلي فقط
|
||||||
|
*/
|
||||||
|
async getDecryptedCredentials(tenantId: string, id: string) {
|
||||||
|
const company = await this.companyRepository.findOne({
|
||||||
|
where: { id, tenant_id: tenantId },
|
||||||
|
select: ['jofotara_client_id_encrypted', 'jofotara_secret_key_encrypted'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!company || !company.jofotara_client_id_encrypted || !company.jofotara_secret_key_encrypted) {
|
||||||
|
throw new NotFoundException('JoFotara credentials not set for this company');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId: this.encryptionService.decrypt(company.jofotara_client_id_encrypted),
|
||||||
|
secretKey: this.encryptionService.decrypt(company.jofotara_secret_key_encrypted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
63
backend/src/modules/companies/entities/company.entity.ts
Normal file
63
backend/src/modules/companies/entities/company.entity.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Company Entity (Client of Accounting Office)
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../tenants/entities/tenant.entity';
|
||||||
|
|
||||||
|
@Entity('companies')
|
||||||
|
export class Company {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenant_id!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Tenant, (tenant) => tenant.companies, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant!: Tenant;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
@Index()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
name_en?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
@Index()
|
||||||
|
tax_identification_number!: string; // الرقم الضريبي
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
// ── JoFotara Credentials (Encrypted) ───────────────────
|
||||||
|
@Column({ type: 'text', nullable: true, select: false })
|
||||||
|
jofotara_client_id_encrypted?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, select: false })
|
||||||
|
jofotara_secret_key_encrypted?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
jofotara_income_source_sequence?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
is_active!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamp' })
|
||||||
|
created_at!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamp' })
|
||||||
|
updated_at!: Date;
|
||||||
|
}
|
||||||
25
backend/src/modules/health/health.controller.ts
Normal file
25
backend/src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Health Check Controller
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(private dataSource: DataSource) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async check() {
|
||||||
|
const dbConnected = this.dataSource.isInitialized;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
uptime: process.uptime(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
database: dbConnected ? 'connected' : 'disconnected',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/src/modules/invoices/entities/invoice-line.entity.ts
Normal file
48
backend/src/modules/invoices/entities/invoice-line.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Invoice Line Entity
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Invoice } from './invoice.entity';
|
||||||
|
|
||||||
|
@Entity('invoice_lines')
|
||||||
|
export class InvoiceLine {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'invoice_id', type: 'uuid' })
|
||||||
|
invoice_id!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Invoice, (invoice) => invoice.lines, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'invoice_id' })
|
||||||
|
invoice!: Invoice;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
line_number!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 3 })
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 3 })
|
||||||
|
unit_price!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 3, default: 0 })
|
||||||
|
discount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 5, scale: 4 }) // e.g., 0.1600
|
||||||
|
tax_rate!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 3 })
|
||||||
|
line_total!: number;
|
||||||
|
}
|
||||||
122
backend/src/modules/invoices/entities/invoice.entity.ts
Normal file
122
backend/src/modules/invoices/entities/invoice.entity.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Invoice Entity
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../tenants/entities/tenant.entity';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { InvoiceLine } from './invoice-line.entity';
|
||||||
|
|
||||||
|
export enum InvoiceStatus {
|
||||||
|
UPLOADED = 'uploaded',
|
||||||
|
EXTRACTING = 'extracting',
|
||||||
|
EXTRACTED = 'extracted',
|
||||||
|
VALIDATED = 'validated',
|
||||||
|
VALIDATION_FAILED = 'validation_failed',
|
||||||
|
SUBMITTING = 'submitting',
|
||||||
|
APPROVED = 'approved',
|
||||||
|
REJECTED = 'rejected',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('invoices')
|
||||||
|
export class Invoice {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenant_id!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant!: Tenant;
|
||||||
|
|
||||||
|
@Column({ name: 'company_id', type: 'uuid' })
|
||||||
|
company_id!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company!: Company;
|
||||||
|
|
||||||
|
// ── Invoice Identity ─────────────────────────────────────
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
invoice_number?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: true })
|
||||||
|
invoice_date?: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: ['cash', 'credit'], default: 'cash' })
|
||||||
|
invoice_type!: 'cash' | 'credit';
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: '388' }) // 388: Sales, 381: Credit Note
|
||||||
|
ubl_type_code!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: '013' }) // 013: Cash, 023: Credit
|
||||||
|
payment_method_code!: string;
|
||||||
|
|
||||||
|
// ── Parties ──────────────────────────────────────────────
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
supplier_tin?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
supplier_name?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
supplier_address?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
buyer_tin?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
buyer_national_id?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
buyer_name?: string;
|
||||||
|
|
||||||
|
// ── Totals (decimal 15,3) ────────────────────────────────
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 3, default: 0 })
|
||||||
|
subtotal!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 3, default: 0 })
|
||||||
|
discount_total!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 3, default: 0 })
|
||||||
|
tax_amount!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 3, default: 0 })
|
||||||
|
grand_total!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'char', length: 3, default: 'JOD' })
|
||||||
|
currency_code!: string;
|
||||||
|
|
||||||
|
// ── Processing Status ────────────────────────────────────
|
||||||
|
@Column({ type: 'enum', enum: InvoiceStatus, default: InvoiceStatus.UPLOADED })
|
||||||
|
status!: InvoiceStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
original_file_path?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 4, scale: 3, nullable: true })
|
||||||
|
ai_confidence_score?: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamp' })
|
||||||
|
created_at!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamp' })
|
||||||
|
updated_at!: Date;
|
||||||
|
|
||||||
|
// ── One-to-Many Relationship ─────────────────────────────
|
||||||
|
@OneToMany(() => InvoiceLine, (line) => line.invoice, { cascade: true })
|
||||||
|
lines!: InvoiceLine[];
|
||||||
|
}
|
||||||
90
backend/src/modules/invoices/gemini-extractor.service.ts
Normal file
90
backend/src/modules/invoices/gemini-extractor.service.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Gemini AI Extraction Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* يقوم باستخراج البيانات من صور/ملفات الفواتير باستخدام Gemini.
|
||||||
|
* يضمن تحويل البيانات غير المهيكلة إلى JSON مطابق لمعايير UBL 2.1.
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GeminiExtractorService {
|
||||||
|
private readonly logger = new Logger(GeminiExtractorService.name);
|
||||||
|
private genAI: GoogleGenerativeAI;
|
||||||
|
private model: any;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
const apiKey = this.configService.getOrThrow<string>('GEMINI_API_KEY');
|
||||||
|
this.genAI = new GoogleGenerativeAI(apiKey);
|
||||||
|
this.model = this.genAI.getGenerativeModel({
|
||||||
|
model: this.configService.get<string>('GEMINI_MODEL', 'gemini-1.5-flash'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* استخراج البيانات من صورة الفاتورة
|
||||||
|
*/
|
||||||
|
async extractInvoiceData(filePath: string, storageRoot: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const fullPath = path.join(storageRoot, filePath);
|
||||||
|
const fileData = fs.readFileSync(fullPath);
|
||||||
|
|
||||||
|
const prompt = `
|
||||||
|
You are a Jordanian tax expert. Extract all details from this invoice image for the Jordan National Invoicing System (JoFotara / JoInvoice).
|
||||||
|
The output MUST be a strict JSON object following this schema:
|
||||||
|
{
|
||||||
|
"invoice_number": "string",
|
||||||
|
"invoice_date": "YYYY-MM-DD",
|
||||||
|
"invoice_type": "cash" | "credit",
|
||||||
|
"supplier_name": "string",
|
||||||
|
"supplier_tin": "string (10 digits)",
|
||||||
|
"buyer_name": "string (optional)",
|
||||||
|
"buyer_tin": "string (optional)",
|
||||||
|
"subtotal": number (before discount and tax),
|
||||||
|
"discount_total": number (total discount),
|
||||||
|
"tax_amount": number (total tax),
|
||||||
|
"grand_total": number (final amount),
|
||||||
|
"currency_code": "JOD",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"line_number": number,
|
||||||
|
"description": "string",
|
||||||
|
"quantity": number,
|
||||||
|
"unit_price": number,
|
||||||
|
"discount": number,
|
||||||
|
"tax_rate": number (e.g. 0.16 for 16%),
|
||||||
|
"line_total": number (quantity * unit_price - discount)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Return ONLY the JSON. No markdown formatting. If a field is missing, return null.
|
||||||
|
Pay close attention to Jordanian Tax Rules (subtotal - discount + tax = grand_total).
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.model.generateContent([
|
||||||
|
prompt,
|
||||||
|
{
|
||||||
|
inlineData: {
|
||||||
|
data: fileData.toString('base64'),
|
||||||
|
mimeType: 'image/jpeg', // Adjusted based on file extension in prod
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const responseText = result.response.text();
|
||||||
|
// Clean up markdown if any
|
||||||
|
const cleanedJson = responseText.replace(/```json|```/g, '').trim();
|
||||||
|
|
||||||
|
return JSON.parse(cleanedJson);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`AI Extraction failed: ${error.message}`);
|
||||||
|
throw new InternalServerErrorException('AI Extraction failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
backend/src/modules/invoices/invoice.controller.ts
Normal file
79
backend/src/modules/invoices/invoice.controller.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Invoices Controller
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { InvoicesService } from './invoice.service';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../../common/decorators/roles.decorator';
|
||||||
|
import { UserRole } from '../users/enums/role.enum';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@Controller('invoices')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class InvoicesController {
|
||||||
|
constructor(private invoicesService: InvoicesService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* رفع فاتورة لشركة محددة
|
||||||
|
*/
|
||||||
|
@Post('upload/:companyId')
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
async upload(
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.invoicesService.upload(user.tenantId, companyId, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قائمة الفواتير لشركة محددة
|
||||||
|
*/
|
||||||
|
@Get('company/:companyId')
|
||||||
|
async findAll(
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||||
|
) {
|
||||||
|
return this.invoicesService.findAll(user.tenantId, companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تفاصيل فاتورة محددة
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.invoicesService.findOne(user.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تحديث بيانات الفاتورة يدوياً
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية
|
||||||
|
*/
|
||||||
|
@Post(':id/submit')
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
|
||||||
|
async submit(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.invoicesService.submitToJoFotara(user.tenantId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/src/modules/invoices/invoice.module.ts
Normal file
44
backend/src/modules/invoices/invoice.module.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Invoices Module (Finalized)
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { InvoicesService } from './invoice.service';
|
||||||
|
import { InvoicesController } from './invoice.controller';
|
||||||
|
import { Invoice } from './entities/invoice.entity';
|
||||||
|
import { InvoiceLine } from './entities/invoice-line.entity';
|
||||||
|
import { InvoiceProcessor } from './invoice.processor';
|
||||||
|
import { GeminiExtractorService } from './gemini-extractor.service';
|
||||||
|
import { UBLGeneratorService } from './ubl-generator.service';
|
||||||
|
import { JoFotaraGatewayService } from './jofotara-gateway.service';
|
||||||
|
import { LocalStorageService } from '../../services/storage/local-storage.service';
|
||||||
|
import { SubscriptionsModule } from '../subscriptions/subscription.module';
|
||||||
|
import { TaxValidationModule } from '../validation/tax-validation.module';
|
||||||
|
import { CompaniesModule } from '../companies/company.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: 'invoice-processing',
|
||||||
|
}),
|
||||||
|
forwardRef(() => SubscriptionsModule),
|
||||||
|
forwardRef(() => CompaniesModule),
|
||||||
|
TaxValidationModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
InvoicesService,
|
||||||
|
InvoiceProcessor,
|
||||||
|
GeminiExtractorService,
|
||||||
|
UBLGeneratorService,
|
||||||
|
JoFotaraGatewayService,
|
||||||
|
LocalStorageService,
|
||||||
|
],
|
||||||
|
controllers: [InvoicesController],
|
||||||
|
exports: [InvoicesService],
|
||||||
|
})
|
||||||
|
export class InvoicesModule {}
|
||||||
160
backend/src/modules/invoices/invoice.processor.ts
Normal file
160
backend/src/modules/invoices/invoice.processor.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Invoice Processor (Queue Consumer)
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* المستهلك الرئيسي لطابور معالجة الفواتير (Bull Queue).
|
||||||
|
* يربط بين الذكاء الاصطناعي، التحقق الضريبي، وتوليد الـ XML.
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Process, Processor, OnQueueActive, OnQueueCompleted, OnQueueFailed } from '@nestjs/bull';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Job } from 'bull';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { Invoice, InvoiceStatus } from './entities/invoice.entity';
|
||||||
|
import { InvoiceLine } from './entities/invoice-line.entity';
|
||||||
|
import { GeminiExtractorService } from './gemini-extractor.service';
|
||||||
|
import { TaxValidationService } from '../validation/tax-validation.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Processor('invoice-processing')
|
||||||
|
export class InvoiceProcessor {
|
||||||
|
private readonly logger = new Logger(InvoiceProcessor.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Invoice)
|
||||||
|
private invoiceRepository: Repository<Invoice>,
|
||||||
|
@InjectRepository(InvoiceLine)
|
||||||
|
private lineRepository: Repository<InvoiceLine>,
|
||||||
|
private geminiExtractor: GeminiExtractorService,
|
||||||
|
private taxValidation: TaxValidationService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@OnQueueActive()
|
||||||
|
onActive(job: Job) {
|
||||||
|
this.logger.log(`Processing job ${job.id} of type ${job.name}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnQueueCompleted()
|
||||||
|
onComplete(job: Job, result: any) {
|
||||||
|
this.logger.log(`Completed job ${job.id} for invoice ${job.data.invoiceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnQueueFailed()
|
||||||
|
onError(job: Job, error: Error) {
|
||||||
|
this.logger.error(`Job ${job.id} failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* الخطوة الأولى: استخراج البيانات باستخدام AI
|
||||||
|
*/
|
||||||
|
@Process('extract-data')
|
||||||
|
async handleExtraction(job: Job<{ invoiceId: string; filePath: string; tenantId: string }>) {
|
||||||
|
const { invoiceId, filePath } = job.data;
|
||||||
|
const storageRoot = this.configService.get<string>('STORAGE_PATH', './uploads');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Update status to EXTRACTING
|
||||||
|
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.EXTRACTING });
|
||||||
|
|
||||||
|
// 2. Extract data via Gemini
|
||||||
|
const data = await this.geminiExtractor.extractInvoiceData(filePath, storageRoot);
|
||||||
|
|
||||||
|
// 3. Save extracted data in a transaction
|
||||||
|
await this.saveExtractedData(invoiceId, data);
|
||||||
|
|
||||||
|
this.logger.log(`Extraction successful for invoice ${invoiceId}`);
|
||||||
|
} catch (error) {
|
||||||
|
await this.invoiceRepository.update(invoiceId, {
|
||||||
|
status: InvoiceStatus.VALIDATION_FAILED,
|
||||||
|
// Optional: Save error message in a notes column
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* حفظ البيانات المستخرجة في قاعدة البيانات
|
||||||
|
*/
|
||||||
|
private async saveExtractedData(invoiceId: string, data: any) {
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Update Invoice Header
|
||||||
|
await queryRunner.manager.update(Invoice, invoiceId, {
|
||||||
|
invoice_number: data.invoice_number,
|
||||||
|
invoice_date: data.invoice_date,
|
||||||
|
invoice_type: data.invoice_type || 'cash',
|
||||||
|
supplier_name: data.supplier_name,
|
||||||
|
supplier_tin: data.supplier_tin,
|
||||||
|
buyer_name: data.buyer_name,
|
||||||
|
buyer_tin: data.buyer_tin,
|
||||||
|
subtotal: data.subtotal,
|
||||||
|
discount_total: data.discount_total,
|
||||||
|
tax_amount: data.tax_amount,
|
||||||
|
grand_total: data.grand_total,
|
||||||
|
currency_code: data.currency_code || 'JOD',
|
||||||
|
status: InvoiceStatus.EXTRACTED,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Clear old lines if any (shouldn't happen on first extract)
|
||||||
|
await queryRunner.manager.delete(InvoiceLine, { invoice_id: invoiceId });
|
||||||
|
|
||||||
|
// 3. Create new lines
|
||||||
|
if (data.lines && Array.isArray(data.lines)) {
|
||||||
|
const lines = data.lines.map((l: any) =>
|
||||||
|
queryRunner.manager.create(InvoiceLine, {
|
||||||
|
invoice_id: invoiceId,
|
||||||
|
line_number: l.line_number,
|
||||||
|
description: l.description,
|
||||||
|
quantity: l.quantity,
|
||||||
|
unit_price: l.unit_price,
|
||||||
|
discount: l.discount || 0,
|
||||||
|
tax_rate: l.tax_rate,
|
||||||
|
line_total: l.line_total,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await queryRunner.manager.save(InvoiceLine, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
|
// 4. Trigger Auto-Validation (Internal Step)
|
||||||
|
await this.autoValidate(invoiceId);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* الخطوة الثانية: التحقق الضريبي التلقائي
|
||||||
|
*/
|
||||||
|
private async autoValidate(invoiceId: string) {
|
||||||
|
const invoice = await this.invoiceRepository.findOne({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
relations: ['lines'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) return;
|
||||||
|
|
||||||
|
const result = this.taxValidation.validateInvoice(invoice);
|
||||||
|
|
||||||
|
if (result.isValid) {
|
||||||
|
await this.invoiceRepository.update(invoiceId, { status: InvoiceStatus.VALIDATED });
|
||||||
|
} else {
|
||||||
|
await this.invoiceRepository.update(invoiceId, {
|
||||||
|
status: InvoiceStatus.VALIDATION_FAILED,
|
||||||
|
// Optional: Save detailed error list
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
backend/src/modules/invoices/invoice.service.ts
Normal file
149
backend/src/modules/invoices/invoice.service.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Invoices Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
|
forwardRef,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
|
import { Queue } from 'bull';
|
||||||
|
import { Invoice, InvoiceStatus } from './entities/invoice.entity';
|
||||||
|
import { LocalStorageService } from '../../services/storage/local-storage.service';
|
||||||
|
import { SubscriptionsService } from '../subscriptions/subscription.service';
|
||||||
|
import { UBLGeneratorService } from './ubl-generator.service';
|
||||||
|
import { JoFotaraGatewayService } from './jofotara-gateway.service';
|
||||||
|
import { CompaniesService } from '../companies/company.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InvoicesService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Invoice)
|
||||||
|
private invoiceRepository: Repository<Invoice>,
|
||||||
|
private localStorageService: LocalStorageService,
|
||||||
|
@Inject(forwardRef(() => SubscriptionsService))
|
||||||
|
private subscriptionsService: SubscriptionsService,
|
||||||
|
@InjectQueue('invoice-processing')
|
||||||
|
private invoiceQueue: Queue,
|
||||||
|
private ublGenerator: UBLGeneratorService,
|
||||||
|
private jofotaraGateway: JoFotaraGatewayService,
|
||||||
|
@Inject(forwardRef(() => CompaniesService))
|
||||||
|
private companiesService: CompaniesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* رفع فاتورة جديدة وبدء المعالجة
|
||||||
|
*/
|
||||||
|
async upload(
|
||||||
|
tenantId: string,
|
||||||
|
companyId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<Invoice> {
|
||||||
|
const canUpload = await this.subscriptionsService.checkInvoiceLimit(tenantId);
|
||||||
|
if (!canUpload) {
|
||||||
|
throw new ForbiddenException('Monthly invoice limit reached for your current plan');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${Date.now()}-${file.originalname}`;
|
||||||
|
const filePath = await this.localStorageService.saveFile(
|
||||||
|
tenantId,
|
||||||
|
companyId,
|
||||||
|
fileName,
|
||||||
|
file.buffer,
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoice = this.invoiceRepository.create({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
company_id: companyId,
|
||||||
|
original_file_path: filePath,
|
||||||
|
status: InvoiceStatus.UPLOADED,
|
||||||
|
});
|
||||||
|
const savedInvoice = await this.invoiceRepository.save(invoice);
|
||||||
|
|
||||||
|
await this.invoiceQueue.add('extract-data', {
|
||||||
|
invoiceId: savedInvoice.id,
|
||||||
|
tenantId,
|
||||||
|
companyId,
|
||||||
|
filePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.subscriptionsService.incrementInvoiceCount(tenantId);
|
||||||
|
|
||||||
|
return savedInvoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قائمة الفواتير لشركة محددة
|
||||||
|
*/
|
||||||
|
async findAll(tenantId: string, companyId: string): Promise<Invoice[]> {
|
||||||
|
return this.invoiceRepository.find({
|
||||||
|
where: { tenant_id: tenantId, company_id: companyId },
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تفاصيل فاتورة
|
||||||
|
*/
|
||||||
|
async findOne(tenantId: string, id: string): Promise<Invoice> {
|
||||||
|
const invoice = await this.invoiceRepository.findOne({
|
||||||
|
where: { id, tenant_id: tenantId },
|
||||||
|
relations: ['lines'],
|
||||||
|
});
|
||||||
|
if (!invoice) throw new NotFoundException('Invoice not found');
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تحديث بيانات الفاتورة يدوياً
|
||||||
|
*/
|
||||||
|
async update(tenantId: string, id: string, updateData: any): Promise<Invoice> {
|
||||||
|
const invoice = await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
if (invoice.status === InvoiceStatus.APPROVED || invoice.status === InvoiceStatus.SUBMITTING) {
|
||||||
|
throw new ForbiddenException('Cannot edit already approved or submitted invoice');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(invoice, updateData);
|
||||||
|
return this.invoiceRepository.save(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية
|
||||||
|
*/
|
||||||
|
async submitToJoFotara(tenantId: string, id: string): Promise<any> {
|
||||||
|
const invoice = await this.findOne(tenantId, id);
|
||||||
|
|
||||||
|
if (invoice.status !== InvoiceStatus.VALIDATED && invoice.status !== InvoiceStatus.VALIDATION_FAILED && invoice.status !== InvoiceStatus.EXTRACTED) {
|
||||||
|
throw new ForbiddenException('Invoice must be validated or extracted before submission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await this.companiesService.findOne(tenantId, invoice.company_id);
|
||||||
|
const credentials = await this.companiesService.getDecryptedCredentials(tenantId, invoice.company_id);
|
||||||
|
|
||||||
|
const xml = this.ublGenerator.generateXML(invoice, company);
|
||||||
|
|
||||||
|
await this.invoiceRepository.update(id, { status: InvoiceStatus.SUBMITTING });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.jofotaraGateway.submitInvoice(
|
||||||
|
xml,
|
||||||
|
credentials.clientId,
|
||||||
|
credentials.secretKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.invoiceRepository.update(id, { status: InvoiceStatus.APPROVED });
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
await this.invoiceRepository.update(id, { status: InvoiceStatus.VALIDATION_FAILED });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
backend/src/modules/invoices/jofotara-gateway.service.ts
Normal file
79
backend/src/modules/invoices/jofotara-gateway.service.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — JoFotara Gateway Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* يقوم بالتواصل مع بوابة دائرة ضريبة الدخل والمبيعات الأردنية (ISTD).
|
||||||
|
* يدير عملية تسجيل الفواتير والحصول على الـ Clearance أو الـ Reporting.
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JoFotaraGatewayService {
|
||||||
|
private readonly logger = new Logger(JoFotaraGatewayService.name);
|
||||||
|
private readonly sandboxUrl: string;
|
||||||
|
private readonly prodUrl: string;
|
||||||
|
private readonly currentEnv: string;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
this.sandboxUrl = this.configService.getOrThrow<string>('JOFOTARA_SANDBOX_URL');
|
||||||
|
this.prodUrl = this.configService.getOrThrow<string>('JOFOTARA_PROD_URL');
|
||||||
|
this.currentEnv = this.configService.get<string>('JOFOTARA_ENV', 'sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* إرسال الفاتورة إلى بوابة جو فوترة
|
||||||
|
*/
|
||||||
|
async submitInvoice(
|
||||||
|
xmlContent: string,
|
||||||
|
clientId: string,
|
||||||
|
secretKey: string,
|
||||||
|
): Promise<any> {
|
||||||
|
const url = this.currentEnv === 'production' ? this.prodUrl : this.sandboxUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${url}/submit`,
|
||||||
|
{
|
||||||
|
invoice: Buffer.from(xmlContent).toString('base64'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-Id': clientId,
|
||||||
|
'X-Secret-Key': secretKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`JoFotara API Error: ${error.response?.data || error.message}`);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`Failed to submit invoice to JoFotara: ${error.response?.data?.message || 'Unknown Error'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* التحقق من حالة الفاتورة
|
||||||
|
*/
|
||||||
|
async checkStatus(uuid: string, clientId: string, secretKey: string): Promise<any> {
|
||||||
|
const url = this.currentEnv === 'production' ? this.prodUrl : this.sandboxUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${url}/status/${uuid}`, {
|
||||||
|
headers: {
|
||||||
|
'X-Client-Id': clientId,
|
||||||
|
'X-Secret-Key': secretKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new InternalServerErrorException('Failed to check invoice status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
backend/src/modules/invoices/ubl-generator.service.ts
Normal file
93
backend/src/modules/invoices/ubl-generator.service.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — UBL 2.1 Generator Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* يقوم بإنشاء ملفات XML المتوافقة مع معيار UBL 2.1 المطلوبة من
|
||||||
|
* دائرة ضريبة الدخل والمبيعات الأردنية (ISTD).
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { create } from 'xmlbuilder2';
|
||||||
|
import { Invoice } from './entities/invoice.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UBLGeneratorService {
|
||||||
|
/**
|
||||||
|
* توليد UBL 2.1 XML لفاتورة مبيعات
|
||||||
|
*/
|
||||||
|
generateXML(invoice: Invoice, company: any): string {
|
||||||
|
const doc = create({ version: '1.0', encoding: 'UTF-8' })
|
||||||
|
.ele('Invoice', {
|
||||||
|
xmlns: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
|
||||||
|
'xmlns:cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
|
||||||
|
'xmlns:cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
|
||||||
|
})
|
||||||
|
.ele('cbc:UBLVersionID').txt('2.1').up()
|
||||||
|
.ele('cbc:CustomizationID').txt('TRX-1.0').up()
|
||||||
|
.ele('cbc:ID').txt(invoice.invoice_number || 'N/A').up()
|
||||||
|
.ele('cbc:IssueDate').txt(invoice.invoice_date?.toISOString().split('T')[0] || '').up()
|
||||||
|
.ele('cbc:InvoiceTypeCode').txt(invoice.ubl_type_code).up()
|
||||||
|
.ele('cbc:DocumentCurrencyCode').txt(invoice.currency_code).up()
|
||||||
|
|
||||||
|
// ── AccountingSupplierParty (المُصدر) ───────────────
|
||||||
|
.ele('cac:AccountingSupplierParty')
|
||||||
|
.ele('cac:Party')
|
||||||
|
.ele('cac:PartyIdentification')
|
||||||
|
.ele('cbc:ID').txt(company.tax_identification_number).up()
|
||||||
|
.up()
|
||||||
|
.ele('cac:PartyName')
|
||||||
|
.ele('cbc:Name').txt(company.name).up()
|
||||||
|
.up()
|
||||||
|
.ele('cac:PostalAddress')
|
||||||
|
.ele('cbc:StreetName').txt(company.address || '').up()
|
||||||
|
.ele('cac:Country')
|
||||||
|
.ele('cbc:IdentificationCode').txt('JO').up()
|
||||||
|
.up()
|
||||||
|
.up()
|
||||||
|
.up()
|
||||||
|
.up()
|
||||||
|
|
||||||
|
// ── AccountingCustomerParty (المشتري) ───────────────
|
||||||
|
.ele('cac:AccountingCustomerParty')
|
||||||
|
.ele('cac:Party')
|
||||||
|
.ele('cac:PartyIdentification')
|
||||||
|
.ele('cbc:ID').txt(invoice.buyer_tin || invoice.buyer_national_id || '').up()
|
||||||
|
.up()
|
||||||
|
.ele('cac:PartyName')
|
||||||
|
.ele('cbc:Name').txt(invoice.buyer_name || '').up()
|
||||||
|
.up()
|
||||||
|
.up()
|
||||||
|
.up()
|
||||||
|
|
||||||
|
// ── TaxTotal ───────────────────────────────────────
|
||||||
|
.ele('cac:TaxTotal')
|
||||||
|
.ele('cbc:TaxAmount', { currencyID: invoice.currency_code }).txt(invoice.tax_amount.toString()).up()
|
||||||
|
.up()
|
||||||
|
|
||||||
|
// ── LegalMonetaryTotal ─────────────────────────────
|
||||||
|
.ele('cac:LegalMonetaryTotal')
|
||||||
|
.ele('cbc:LineExtensionAmount', { currencyID: invoice.currency_code }).txt(invoice.subtotal.toString()).up()
|
||||||
|
.ele('cbc:TaxExclusiveAmount', { currencyID: invoice.currency_code }).txt(invoice.subtotal.toString()).up()
|
||||||
|
.ele('cbc:TaxInclusiveAmount', { currencyID: invoice.currency_code }).txt(invoice.grand_total.toString()).up()
|
||||||
|
.ele('cbc:PayableAmount', { currencyID: invoice.currency_code }).txt(invoice.grand_total.toString()).up()
|
||||||
|
.up();
|
||||||
|
|
||||||
|
// ── InvoiceLines ─────────────────────────────────────
|
||||||
|
invoice.lines.forEach((line) => {
|
||||||
|
doc.ele('cac:InvoiceLine')
|
||||||
|
.ele('cbc:ID').txt(line.line_number.toString()).up()
|
||||||
|
.ele('cbc:InvoicedQuantity', { unitCode: 'PCE' }).txt(line.quantity.toString()).up()
|
||||||
|
.ele('cbc:LineExtensionAmount', { currencyID: invoice.currency_code }).txt(line.line_total.toString()).up()
|
||||||
|
.ele('cac:Item')
|
||||||
|
.ele('cbc:Description').txt(line.description).up()
|
||||||
|
.up()
|
||||||
|
.ele('cac:Price')
|
||||||
|
.ele('cbc:PriceAmount', { currencyID: invoice.currency_code }).txt(line.unit_price.toString()).up()
|
||||||
|
.up()
|
||||||
|
.up();
|
||||||
|
});
|
||||||
|
|
||||||
|
return doc.end({ prettyPrint: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Subscription Entity
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../tenants/entities/tenant.entity';
|
||||||
|
|
||||||
|
export enum SubscriptionPlan {
|
||||||
|
BASIC = 'basic',
|
||||||
|
OFFICE = 'office',
|
||||||
|
PRO = 'pro',
|
||||||
|
ENTERPRISE = 'enterprise',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SubscriptionStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
PAST_DUE = 'past_due',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('subscriptions')
|
||||||
|
export class Subscription {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenant_id!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Tenant, (tenant) => tenant.subscriptions, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant!: Tenant;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: SubscriptionPlan,
|
||||||
|
})
|
||||||
|
plan!: SubscriptionPlan;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
max_companies!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
max_invoices_per_month!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||||
|
price_jod!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ['monthly', 'annual'],
|
||||||
|
default: 'monthly',
|
||||||
|
})
|
||||||
|
billing_cycle!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true })
|
||||||
|
current_period_start?: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true })
|
||||||
|
current_period_end?: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
invoices_used_this_month!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: SubscriptionStatus,
|
||||||
|
default: SubscriptionStatus.ACTIVE,
|
||||||
|
})
|
||||||
|
status!: SubscriptionStatus;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamp' })
|
||||||
|
created_at!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamp' })
|
||||||
|
updated_at!: Date;
|
||||||
|
}
|
||||||
21
backend/src/modules/subscriptions/subscription.module.ts
Normal file
21
backend/src/modules/subscriptions/subscription.module.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Subscriptions Module
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { SubscriptionsService } from './subscription.service';
|
||||||
|
import { Subscription } from './entities/subscription.entity';
|
||||||
|
import { CompaniesModule } from '../companies/company.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Subscription]),
|
||||||
|
forwardRef(() => CompaniesModule),
|
||||||
|
],
|
||||||
|
providers: [SubscriptionsService],
|
||||||
|
exports: [SubscriptionsService],
|
||||||
|
})
|
||||||
|
export class SubscriptionsModule {}
|
||||||
64
backend/src/modules/subscriptions/subscription.service.ts
Normal file
64
backend/src/modules/subscriptions/subscription.service.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Subscriptions Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* يدير خطص الاشتراك والحدود المسموح بها للمكاتب.
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Subscription, SubscriptionStatus } from './entities/subscription.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SubscriptionsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Subscription)
|
||||||
|
private subscriptionRepository: Repository<Subscription>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* الحصول على اشتراك المكتب الحالي
|
||||||
|
*/
|
||||||
|
async findActive(tenantId: string): Promise<Subscription> {
|
||||||
|
const subscription = await this.subscriptionRepository.findOne({
|
||||||
|
where: { tenant_id: tenantId, status: SubscriptionStatus.ACTIVE },
|
||||||
|
});
|
||||||
|
if (!subscription) throw new NotFoundException('Active subscription not found');
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* هل يُسمح بإضافة شركة جديدة؟
|
||||||
|
*/
|
||||||
|
async checkCompanyLimit(tenantId: string): Promise<boolean> {
|
||||||
|
const sub = await this.findActive(tenantId);
|
||||||
|
|
||||||
|
// Check current company count
|
||||||
|
const count = await this.subscriptionRepository.manager.count('companies', {
|
||||||
|
where: { tenant_id: tenantId, is_active: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return count < sub.max_companies || sub.max_companies === -1; // -1 for unlimited
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* هل يُسمح برفع فاتورة جديدة؟
|
||||||
|
*/
|
||||||
|
async checkInvoiceLimit(tenantId: string): Promise<boolean> {
|
||||||
|
const sub = await this.findActive(tenantId);
|
||||||
|
|
||||||
|
return sub.invoices_used_this_month < sub.max_invoices_per_month || sub.max_invoices_per_month === -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* زيادة عداد الفواتير بعد الرفع الناجح
|
||||||
|
*/
|
||||||
|
async incrementInvoiceCount(tenantId: string): Promise<void> {
|
||||||
|
const sub = await this.findActive(tenantId);
|
||||||
|
await this.subscriptionRepository.update(sub.id, {
|
||||||
|
invoices_used_this_month: sub.invoices_used_this_month + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
64
backend/src/modules/tenants/entities/tenant.entity.ts
Normal file
64
backend/src/modules/tenants/entities/tenant.entity.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Tenant Entity (Accounting Office)
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
|
import { Subscription } from '../../subscriptions/entities/subscription.entity';
|
||||||
|
|
||||||
|
export enum TenantStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
SUSPENDED = 'suspended',
|
||||||
|
TRIAL = 'trial',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('tenants')
|
||||||
|
export class Tenant {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, unique: true })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: TenantStatus,
|
||||||
|
default: TenantStatus.TRIAL,
|
||||||
|
})
|
||||||
|
status!: TenantStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true })
|
||||||
|
trial_ends_at?: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamp' })
|
||||||
|
created_at!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamp' })
|
||||||
|
updated_at!: Date;
|
||||||
|
|
||||||
|
// ── Relationships ───────────────────────────────────────
|
||||||
|
@OneToMany(() => User, (user) => user.tenant)
|
||||||
|
users!: User[];
|
||||||
|
|
||||||
|
@OneToMany(() => Company, (company) => company.tenant)
|
||||||
|
companies!: Company[];
|
||||||
|
|
||||||
|
@OneToMany(() => Subscription, (subscription) => subscription.tenant)
|
||||||
|
subscriptions!: Subscription[];
|
||||||
|
}
|
||||||
30
backend/src/modules/tenants/tenant.controller.ts
Normal file
30
backend/src/modules/tenants/tenant.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Tenants Controller
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
|
||||||
|
import { TenantsService } from './tenant.service';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../../common/decorators/roles.decorator';
|
||||||
|
import { UserRole } from '../users/enums/role.enum';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@Controller('tenants')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class TenantsController {
|
||||||
|
constructor(private tenantsService: TenantsService) {}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
async getProfile(@CurrentUser() user: any) {
|
||||||
|
return this.tenantsService.findOne(user.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('me')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async updateProfile(@CurrentUser() user: any, @Body() updateData: any) {
|
||||||
|
return this.tenantsService.update(user.tenantId, updateData);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/src/modules/tenants/tenant.module.ts
Normal file
19
backend/src/modules/tenants/tenant.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Tenants Module
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { TenantsService } from './tenant.service';
|
||||||
|
import { TenantsController } from './tenant.controller';
|
||||||
|
import { Tenant } from './entities/tenant.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Tenant])],
|
||||||
|
providers: [TenantsService],
|
||||||
|
controllers: [TenantsController],
|
||||||
|
exports: [TenantsService],
|
||||||
|
})
|
||||||
|
export class TenantsModule {}
|
||||||
29
backend/src/modules/tenants/tenant.service.ts
Normal file
29
backend/src/modules/tenants/tenant.service.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Tenants Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Tenant } from './entities/tenant.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Tenant)
|
||||||
|
private tenantRepository: Repository<Tenant>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<Tenant> {
|
||||||
|
const tenant = await this.tenantRepository.findOne({ where: { id } });
|
||||||
|
if (!tenant) throw new NotFoundException('Tenant not found');
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, updateData: Partial<Tenant>): Promise<Tenant> {
|
||||||
|
await this.tenantRepository.update(id, updateData);
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
backend/src/modules/users/entities/user.entity.ts
Normal file
62
backend/src/modules/users/entities/user.entity.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — User Entity
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../tenants/entities/tenant.entity';
|
||||||
|
import { UserRole } from '../enums/role.enum';
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
@Index(['tenant_id', 'email'], { unique: true })
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenant_id!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Tenant, (tenant) => tenant.users, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant!: Tenant;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, select: false }) // Hide password by default
|
||||||
|
password_hash!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: UserRole,
|
||||||
|
})
|
||||||
|
role!: UserRole;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true, select: false })
|
||||||
|
refresh_token_hash?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
is_active!: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true })
|
||||||
|
last_login_at?: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamp' })
|
||||||
|
created_at!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamp' })
|
||||||
|
updated_at!: Date;
|
||||||
|
}
|
||||||
11
backend/src/modules/users/enums/role.enum.ts
Normal file
11
backend/src/modules/users/enums/role.enum.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — User Roles
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum UserRole {
|
||||||
|
ADMIN = 'admin', // مدير المكتب (قادر على الإضافة والتعديل الكامل)
|
||||||
|
ACCOUNTANT = 'accountant', // محاسب (قادر على رفع الفواتير وإدارتها)
|
||||||
|
VIEWER = 'viewer', // مشاهد (قادر على الاطلاع فقط)
|
||||||
|
}
|
||||||
49
backend/src/modules/users/user.controller.ts
Normal file
49
backend/src/modules/users/user.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Users Controller
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Delete,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UsersService } from './user.service';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../../common/decorators/roles.decorator';
|
||||||
|
import { UserRole } from './enums/role.enum';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async create(@CurrentUser() user: any, @Body() dto: any) {
|
||||||
|
return this.usersService.create(user.tenantId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async findAll(@CurrentUser() user: any) {
|
||||||
|
return this.usersService.findAll(user.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(@CurrentUser() user: any, @Param('id') id: string) {
|
||||||
|
return this.usersService.findOne(user.tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async remove(@CurrentUser() user: any, @Param('id') id: string) {
|
||||||
|
return this.usersService.remove(user.tenantId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/src/modules/users/user.module.ts
Normal file
19
backend/src/modules/users/user.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Users Module
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { UsersService } from './user.service';
|
||||||
|
import { UsersController } from './user.controller';
|
||||||
|
import { User } from './entities/user.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
|
providers: [UsersService],
|
||||||
|
controllers: [UsersController],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
71
backend/src/modules/users/user.service.ts
Normal file
71
backend/src/modules/users/user.service.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Users Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { User } from './entities/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private userRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* إضافة مستخدم لمكتب محاسبة
|
||||||
|
*/
|
||||||
|
async create(tenantId: string, dto: any): Promise<User> {
|
||||||
|
const existing = await this.userRepository.findOne({
|
||||||
|
where: { email: dto.email, tenant_id: tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('User with this email already exists in this office');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, 12);
|
||||||
|
|
||||||
|
const user = this.userRepository.create({
|
||||||
|
...dto,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قائمة مستخدمي المكتب
|
||||||
|
*/
|
||||||
|
async findAll(tenantId: string): Promise<User[]> {
|
||||||
|
return this.userRepository.find({
|
||||||
|
where: { tenant_id: tenantId, is_active: true },
|
||||||
|
order: { created_at: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تفاصيل مستخدم
|
||||||
|
*/
|
||||||
|
async findOne(tenantId: string, id: string): Promise<User> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id, tenant_id: tenantId },
|
||||||
|
});
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تعطيل مستخدم
|
||||||
|
*/
|
||||||
|
async remove(tenantId: string, id: string): Promise<void> {
|
||||||
|
const user = await this.findOne(tenantId, id);
|
||||||
|
await this.userRepository.update(id, { is_active: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/modules/validation/tax-validation.module.ts
Normal file
14
backend/src/modules/validation/tax-validation.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Tax Validation Module
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TaxValidationService } from './tax-validation.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [TaxValidationService],
|
||||||
|
exports: [TaxValidationService],
|
||||||
|
})
|
||||||
|
export class TaxValidationModule {}
|
||||||
103
backend/src/modules/validation/tax-validation.service.ts
Normal file
103
backend/src/modules/validation/tax-validation.service.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Tax Validation Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* محرك التحقق من القواعد الضريبية الأردنية (ISTD Rules).
|
||||||
|
* يضمن دقة الحسابات قبل إرسالها إلى "جو فوترة".
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Invoice } from '../invoices/entities/invoice.entity';
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TaxValidationService {
|
||||||
|
private readonly logger = new Logger(TaxValidationService.name);
|
||||||
|
private readonly PRECISION = 0.005; // السماحية في الفروقات العشرية البسيطة
|
||||||
|
|
||||||
|
/**
|
||||||
|
* التحقق الشامل من الفاتورة
|
||||||
|
*/
|
||||||
|
validateInvoice(invoice: Invoice): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 1. Rule 001: التحقق من مجموع بنود الفاتورة (Subtotal)
|
||||||
|
this.checkRule001(invoice, errors);
|
||||||
|
|
||||||
|
// 2. Rule 002: التحقق من قيمة الضريبة (Tax Amount)
|
||||||
|
this.checkRule002(invoice, errors);
|
||||||
|
|
||||||
|
// 3. Rule 003: التحقق من الخصومات (Discounts)
|
||||||
|
this.checkRule003(invoice, errors);
|
||||||
|
|
||||||
|
// 4. Rule 004: التحقق من المجموع النهائي (Grand Total)
|
||||||
|
this.checkRule004(invoice, errors);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule 001: Σ (Quantity * UnitPrice) = Subtotal (Before Tax/Discount)
|
||||||
|
*/
|
||||||
|
private checkRule001(invoice: Invoice, errors: string[]) {
|
||||||
|
const calculatedSubtotal = invoice.lines.reduce(
|
||||||
|
(sum, line) => sum + Number(line.quantity) * Number(line.unit_price),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Math.abs(calculatedSubtotal - Number(invoice.subtotal)) > this.PRECISION) {
|
||||||
|
errors.push(`خطأ في القاعدة 001: مجموع البنود (${calculatedSubtotal}) لا يطابق المجموع الفرعي المسجل (${invoice.subtotal})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule 002: TaxAmount = (Subtotal - Discount) * TaxRate
|
||||||
|
* ملاحظة: يجب التحقق لكل بند بشكل منفصل أو للمجموع حسب نوع الفاتورة
|
||||||
|
*/
|
||||||
|
private checkRule002(invoice: Invoice, errors: string[]) {
|
||||||
|
const calculatedTax = invoice.lines.reduce(
|
||||||
|
(sum, line) => {
|
||||||
|
const lineBeforeTax = (Number(line.quantity) * Number(line.unit_price)) - Number(line.discount);
|
||||||
|
return sum + (lineBeforeTax * Number(line.tax_rate));
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Math.abs(calculatedTax - Number(invoice.tax_amount)) > this.PRECISION) {
|
||||||
|
errors.push(`خطأ في القاعدة 002: قيمة الضريبة المحسوبة (${calculatedTax.toFixed(3)}) لا تطابق القيمة المسجلة (${invoice.tax_amount})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule 003: Σ Line Discounts = Total Discount
|
||||||
|
*/
|
||||||
|
private checkRule003(invoice: Invoice, errors: string[]) {
|
||||||
|
const totalLineDiscounts = invoice.lines.reduce(
|
||||||
|
(sum, line) => sum + Number(line.discount),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Math.abs(totalLineDiscounts - Number(invoice.discount_total)) > this.PRECISION) {
|
||||||
|
errors.push(`خطأ في القاعدة 003: مجموع خصومات البنود (${totalLineDiscounts}) لا يطابق إجمالي الخصم (${invoice.discount_total})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule 004: GrandTotal = Subtotal - Discount + Tax
|
||||||
|
*/
|
||||||
|
private checkRule004(invoice: Invoice, errors: string[]) {
|
||||||
|
const calculatedGrandTotal = Number(invoice.subtotal) - Number(invoice.discount_total) + Number(invoice.tax_amount);
|
||||||
|
|
||||||
|
if (Math.abs(calculatedGrandTotal - Number(invoice.grand_total)) > this.PRECISION) {
|
||||||
|
errors.push(`خطأ في القاعدة 004: المجموع النهائي المحسوب (${calculatedGrandTotal.toFixed(3)}) لا يطابق المسجل (${invoice.grand_total})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
backend/src/services/encryption/encryption.service.ts
Normal file
79
backend/src/services/encryption/encryption.service.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Encryption Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* يستخدم خوارزمية AES-256-GCM لتشفير البيانات الحساسة.
|
||||||
|
* يُستخدم بشكل أساسي لمفاتيح جو فوترة (API Keys).
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EncryptionService {
|
||||||
|
private readonly algorithm = 'aes-256-gcm';
|
||||||
|
private readonly key: Buffer;
|
||||||
|
private readonly ivLength = 16;
|
||||||
|
private readonly tagLength = 16;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
const k = this.configService.getOrThrow<string>('ENCRYPTION_KEY');
|
||||||
|
this.key = Buffer.from(k, 'hex');
|
||||||
|
|
||||||
|
if (this.key.length !== 32) {
|
||||||
|
throw new Error('Encryption key must be 32 bytes (64 hex characters)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تشفير النص
|
||||||
|
* التنسيق الناتج: iv:tag:encryptedData (hex)
|
||||||
|
*/
|
||||||
|
encrypt(text: string): string {
|
||||||
|
try {
|
||||||
|
const iv = crypto.randomBytes(this.ivLength);
|
||||||
|
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
|
||||||
|
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(text, 'utf8'),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||||
|
} catch (error) {
|
||||||
|
throw new InternalServerErrorException('Encryption failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فك تشفير النص
|
||||||
|
*/
|
||||||
|
decrypt(encryptedText: string): string {
|
||||||
|
try {
|
||||||
|
const parts = encryptedText.split(':');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error('Invalid encrypted text format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = Buffer.from(parts[0], 'hex');
|
||||||
|
const tag = Buffer.from(parts[1], 'hex');
|
||||||
|
const encryptedData = Buffer.from(parts[2], 'hex');
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(encryptedData),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
} catch (error) {
|
||||||
|
throw new InternalServerErrorException('Decryption failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
backend/src/services/storage/local-storage.service.ts
Normal file
74
backend/src/services/storage/local-storage.service.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Local Storage Service
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalStorageService {
|
||||||
|
private readonly storageRoot: string;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {
|
||||||
|
this.storageRoot = this.configService.get<string>('STORAGE_PATH', './uploads');
|
||||||
|
|
||||||
|
// Ensure the root storage directory exists
|
||||||
|
if (!fs.existsSync(this.storageRoot)) {
|
||||||
|
fs.mkdirSync(this.storageRoot, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* حفظ ملف في التخزين المحلي
|
||||||
|
*/
|
||||||
|
async saveFile(
|
||||||
|
tenantId: string,
|
||||||
|
companyId: string,
|
||||||
|
fileName: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const relativePath = path.join(
|
||||||
|
tenantId,
|
||||||
|
companyId,
|
||||||
|
'invoices',
|
||||||
|
now.getFullYear().toString(),
|
||||||
|
(now.getMonth() + 1).toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fullPath = path.join(this.storageRoot, relativePath);
|
||||||
|
|
||||||
|
// Create directories if they don't exist
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.mkdirSync(fullPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(fullPath, fileName);
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
|
||||||
|
// Return the relative path to be stored in the database
|
||||||
|
return path.join(relativePath, fileName);
|
||||||
|
} catch (error) {
|
||||||
|
throw new InternalServerErrorException('File storage failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* حذف ملف
|
||||||
|
*/
|
||||||
|
async deleteFile(filePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const fullPath = path.join(this.storageRoot, filePath);
|
||||||
|
if (fs.existsSync(fullPath)) {
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail the request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/tsconfig.build.json
Normal file
7
backend/tsconfig.build.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./"
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
28
backend/tsconfig.json
Normal file
28
backend/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@config/*": ["src/config/*"],
|
||||||
|
"@common/*": ["src/common/*"],
|
||||||
|
"@modules/*": ["src/modules/*"],
|
||||||
|
"@services/*": ["src/services/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
docker-compose.yml
Normal file
103
docker-compose.yml
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# مُصادَق (Musadaq) — Full Stack Docker Compose
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# Services:
|
||||||
|
# - Frontend: React/Vite (80 -> Proxy to API)
|
||||||
|
# - API: NestJS (3300)
|
||||||
|
# - DB: PostgreSQL (5300 internally)
|
||||||
|
# - Redis: Cache & Queue (6400 internally)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ── Frontend (Ingress) ──────────────────────────────────
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./musadaq-frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: musadaq-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- musadaq-network
|
||||||
|
|
||||||
|
# ── NestJS API ─────────────────────────────────────────
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./musadaq-backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: musadaq-api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3300:3300"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=5432
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- DB_USER=${DB_USER}
|
||||||
|
- DB_PASS=${DB_PASS}
|
||||||
|
- DB_NAME=${DB_NAME}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
|
||||||
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
|
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||||
|
- JOFOTARA_SANDBOX_URL=${JOFOTARA_SANDBOX_URL}
|
||||||
|
- JOFOTARA_PROD_URL=${JOFOTARA_PROD_URL}
|
||||||
|
- JOFOTARA_ENV=${JOFOTARA_ENV}
|
||||||
|
- STORAGE_PATH=/app/uploads
|
||||||
|
volumes:
|
||||||
|
- invoice_uploads:/app/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- musadaq-network
|
||||||
|
|
||||||
|
# ── PostgreSQL ────────────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: musadaq-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${DB_USER}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASS}
|
||||||
|
- POSTGRES_DB=${DB_NAME}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- musadaq-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── Redis ─────────────────────────────────────────────
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: musadaq-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- musadaq-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
invoice_uploads:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
musadaq-network:
|
||||||
|
driver: bridge
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
29
frontend/Dockerfile
Normal file
29
frontend/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# مُصادَق (Musadaq) — Frontend Dockerfile (Vite)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# ── Build Stage ───────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Production Stage ──────────────────────────────────────
|
||||||
|
FROM nginx:stable-alpine
|
||||||
|
|
||||||
|
# Copy built files to nginx
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy custom nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>musadaq-frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
frontend/nginx.conf
Normal file
47
frontend/nginx.conf
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# مُصادَق (Musadaq) — Frontend Nginx Config
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# ── Security Headers ──────────────────────────────────────
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
|
||||||
|
# Gzip Compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# ── Root Path (SPA Support) ────────────────────────────────
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Proxy /api requests to the backend ──────────────────────
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://api:3300/api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static Assets Caching
|
||||||
|
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg)$ {
|
||||||
|
expires 6M;
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
3323
frontend/package-lock.json
generated
Normal file
3323
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "musadaq-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@tanstack/react-query": "^5.99.0",
|
||||||
|
"@tanstack/react-query-devtools": "^5.99.0",
|
||||||
|
"axios": "^1.15.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-hook-form": "^7.72.1",
|
||||||
|
"react-router-dom": "^7.14.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"postcss": "^8.5.10",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.0",
|
||||||
|
"vite": "^8.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
38
frontend/src/App.tsx
Normal file
38
frontend/src/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from './store/authStore';
|
||||||
|
import { MainLayout } from './components/layout/MainLayout';
|
||||||
|
import LoginPage from './pages/auth/LoginPage';
|
||||||
|
import RegisterPage from './pages/auth/RegisterPage';
|
||||||
|
import { DashboardPage } from './pages/dashboard/DashboardPage';
|
||||||
|
import { InvoicesPage } from './pages/invoices/InvoicesPage';
|
||||||
|
|
||||||
|
// ── Protected Route Guard ─────────────────────────────────
|
||||||
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
{/* Public Routes */}
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
|
||||||
|
{/* Protected Dashboard Routes */}
|
||||||
|
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
|
||||||
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="invoices" element={<InvoicesPage />} />
|
||||||
|
<Route path="companies" element={<div className="text-3xl font-bold">إدارة الشركات</div>} />
|
||||||
|
<Route path="staff" element={<div className="text-3xl font-bold">إدارة الموظفين</div>} />
|
||||||
|
<Route path="settings" element={<div className="text-3xl font-bold">الإعدادات</div>} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Fallback */}
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
frontend/src/api/client.ts
Normal file
65
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — API Client
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3300/api';
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
withCredentials: true, // Required for HttpOnly refresh cookies
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Request Interceptor (JWT) ──────────────────────────────
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Response Interceptor (Token Rotation) ──────────────────
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
// If 401 and not already retrying
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Attempt to refresh tokens
|
||||||
|
const { data } = await axios.post(
|
||||||
|
`${API_URL}/auth/refresh`,
|
||||||
|
{},
|
||||||
|
{ withCredentials: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', data.accessToken);
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||||
|
|
||||||
|
return apiClient(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
// If refresh fails, clear and redirect to login
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
59
frontend/src/components/layout/MainLayout.tsx
Normal file
59
frontend/src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Main Layout Shell
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { Bell, Search, User } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
|
||||||
|
export const MainLayout = () => {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex bg-slate-50 min-h-screen rtl overflow-hidden">
|
||||||
|
{/* ── Desktop Sidebar ───────────────────────────────────── */}
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col h-screen overflow-y-auto">
|
||||||
|
{/* ── Top Navigation ──────────────────────────────────── */}
|
||||||
|
<header className="h-16 bg-white/80 backdrop-blur-md sticky top-0 z-30 border-b border-slate-100 px-8 flex items-center justify-between shadow-sm">
|
||||||
|
<div className="flex items-center gap-4 bg-slate-50 px-4 py-2 rounded-xl group focus-within:ring-2 focus-within:ring-primary-100 transition-all border border-transparent focus-within:border-primary-200">
|
||||||
|
<Search className="w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="بحث سريع..."
|
||||||
|
className="bg-transparent border-none outline-none text-sm w-64 text-slate-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<button className="p-2 text-slate-400 hover:bg-slate-50 hover:text-primary-600 rounded-xl transition-all relative">
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full border-2 border-white"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pl-2 border-r border-slate-100">
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-sm font-semibold text-slate-900">{user?.name}</p>
|
||||||
|
<p className="text-[12px] text-slate-500 uppercase tracking-wider font-medium">
|
||||||
|
{user?.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-slate-100 rounded-full flex items-center justify-center border-2 border-white shadow-sm ring-1 ring-slate-100">
|
||||||
|
<User className="text-slate-400 w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Main Content Area ───────────────────────────────── */}
|
||||||
|
<main className="p-8 pb-16 flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
70
frontend/src/components/layout/Sidebar.tsx
Normal file
70
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Premium Sidebar
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
FileText,
|
||||||
|
Building2,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
LogOut
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ icon: LayoutDashboard, label: 'الرئيسية', path: '/dashboard' },
|
||||||
|
{ icon: FileText, label: 'الفواتير', path: '/invoices' },
|
||||||
|
{ icon: Building2, label: 'الشركات', path: '/companies' },
|
||||||
|
{ icon: Users, label: 'الموظفون', path: '/staff' },
|
||||||
|
{ icon: Settings, label: 'الإعدادات', path: '/settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Sidebar = () => {
|
||||||
|
const clearAuth = useAuthStore((state) => state.clearAuth);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 h-screen glass border-l border-slate-200 sticky top-0 flex flex-col p-4">
|
||||||
|
<div className="flex items-center gap-3 px-2 py-6">
|
||||||
|
<div className="w-10 h-10 bg-primary-600 rounded-xl flex items-center justify-center shadow-lg shadow-primary-500/30">
|
||||||
|
<FileText className="text-white w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold bg-gradient-to-br from-slate-900 to-slate-500 bg-clip-text text-transparent">
|
||||||
|
مُصادَق
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 mt-4 space-y-1">
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary-50 text-primary-600 shadow-sm border border-primary-100'
|
||||||
|
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-900'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-slate-100">
|
||||||
|
<button
|
||||||
|
onClick={clearAuth}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 w-full rounded-xl text-red-500 hover:bg-red-50 transition-all group"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||||
|
<span className="font-medium">تسجيل الخروج</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
frontend/src/index.css
Normal file
43
frontend/src/index.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary-50: oklch(0.97 0.01 240);
|
||||||
|
--color-primary-100: oklch(0.93 0.03 240);
|
||||||
|
--color-primary-200: oklch(0.87 0.06 240);
|
||||||
|
--color-primary-300: oklch(0.78 0.12 240);
|
||||||
|
--color-primary-400: oklch(0.66 0.18 240);
|
||||||
|
--color-primary-500: oklch(0.55 0.22 240);
|
||||||
|
--color-primary-600: oklch(0.48 0.23 240);
|
||||||
|
--color-primary-700: oklch(0.40 0.20 240);
|
||||||
|
--color-primary-800: oklch(0.33 0.16 240);
|
||||||
|
--color-primary-900: oklch(0.28 0.12 240);
|
||||||
|
--color-primary-950: oklch(0.18 0.08 240);
|
||||||
|
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: "Fira Code", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-slate-50 text-slate-900 antialiased;
|
||||||
|
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
@apply font-semibold tracking-tight text-slate-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.glass {
|
||||||
|
@apply bg-white/70 backdrop-blur-md border border-white/20 shadow-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-premium {
|
||||||
|
@apply bg-white border border-slate-200 shadow-sm hover:shadow-md transition-all duration-200 rounded-xl overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg shadow-sm transition-all active:scale-95;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
130
frontend/src/pages/auth/LoginPage.tsx
Normal file
130
frontend/src/pages/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Premium Login Page
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { LogIn, Mail, Lock, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import apiClient from '../../api/client';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('بريد إلكتروني غير صالح'),
|
||||||
|
password: z.string().min(8, 'كلمة المرور يجب أن لا تقل عن 8 أحرف'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginForm = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/auth/login', data);
|
||||||
|
const { user, accessToken } = response.data;
|
||||||
|
setAuth(user, accessToken);
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.message || 'فشل تسجيل الدخول. يرجى التحقق من البيانات.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6 relative overflow-hidden rtl">
|
||||||
|
{/* ── Background Aesthetics ────────────────────────────────── */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full opacity-10 pointer-events-none">
|
||||||
|
<div className="absolute top-[-20%] left-[-10%] w-[600px] h-[600px] bg-primary-300 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-blue-300 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="w-full max-w-md glass p-10 rounded-3xl shadow-2xl relative z-10"
|
||||||
|
>
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<div className="w-16 h-16 bg-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-primary-500/20">
|
||||||
|
<LogIn className="text-white w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-2">أهلاً بك في مُصادَق</h1>
|
||||||
|
<p className="text-slate-500">منصة أتمتة الفواتير الضريبية الأردنية</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 10 }}
|
||||||
|
className="bg-red-50 border border-red-100 p-4 rounded-xl flex items-center gap-3 text-red-600 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">البريد الإلكتروني</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
{...register('email')}
|
||||||
|
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
|
||||||
|
placeholder="name@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.email && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">كلمة المرور</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
{...register('password')}
|
||||||
|
type="password"
|
||||||
|
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.password && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.password.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full btn-primary h-14 text-lg mt-4 flex items-center justify-center gap-3 shadow-lg shadow-primary-500/25"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-6 h-6 animate-spin" /> : 'تسجيل الدخول'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 text-center text-slate-500 text-sm">
|
||||||
|
ليس لديك حساب؟{' '}
|
||||||
|
<Link to="/register" className="text-primary-600 font-bold hover:underline">
|
||||||
|
أنشئ حساباً جديداً
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
frontend/src/pages/auth/RegisterPage.tsx
Normal file
206
frontend/src/pages/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Premium Register Page
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { UserPlus, Mail, Lock, Building, Phone, ArrowLeft, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
import apiClient from '../../api/client';
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
tenantName: z.string().min(3, 'اسم المكتب يجب أن لا يقل عن 3 أحرف'),
|
||||||
|
name: z.string().min(3, 'الاسم يجب أن لا يقل عن 3 أحرف'),
|
||||||
|
email: z.string().email('بريد إلكتروني غير صالح'),
|
||||||
|
password: z.string().min(8, 'كلمة المرور يجب أن لا تقل عن 8 أحرف'),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegisterForm = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { register, handleSubmit, trigger, formState: { errors } } = useForm<RegisterForm>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextStep = async () => {
|
||||||
|
const fields = step === 1 ? ['tenantName', 'phone'] : ['name', 'email', 'password'];
|
||||||
|
const isValid = await trigger(fields as any);
|
||||||
|
if (isValid) setStep(step + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await apiClient.post('/auth/register', data);
|
||||||
|
navigate('/login', { state: { message: 'تم إنشاء الحساب بنجاح! يرجى تسجيل الدخول.' } });
|
||||||
|
} catch (err) {
|
||||||
|
alert('فشل إنشاء الحساب. تأكد من أن البريد الإلكتروني لم يُستخدم من قبل.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6 relative overflow-hidden rtl font-sans">
|
||||||
|
{/* ── Background Aesthetics ────────────────────────────────── */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full opacity-10 pointer-events-none">
|
||||||
|
<div className="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] bg-primary-300 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-20%] left-[-10%] w-[600px] h-[600px] bg-blue-300 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="w-full max-w-lg glass p-10 rounded-3xl shadow-2xl relative z-10"
|
||||||
|
>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-primary-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-primary-500/20">
|
||||||
|
<UserPlus className="text-white w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-2">إنشاء حساب جديد</h1>
|
||||||
|
<p className="text-slate-500">ابدأ رحلتك في أتمتة الفواتير الضريبية</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Progress Indicator ─────────────────────────────────── */}
|
||||||
|
<div className="flex gap-2 mb-8 items-center justify-center">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1.5 rounded-full transition-all duration-300 ${step >= i ? 'w-12 bg-primary-600' : 'w-4 bg-slate-200'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === 1 ? (
|
||||||
|
<motion.div
|
||||||
|
key="step1"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">اسم مكتب المحاسبة</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Building className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
{...register('tenantName')}
|
||||||
|
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
|
||||||
|
placeholder="شركة الفوترة للمحاسبة"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.tenantName && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.tenantName.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">رقم الهاتف (اختياري)</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
{...register('phone')}
|
||||||
|
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
|
||||||
|
placeholder="079 XXXXXXX"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={nextStep}
|
||||||
|
className="w-full btn-primary h-14 text-lg flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
التالي
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="step2"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">الاسم الكامل (للمدير)</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<input
|
||||||
|
{...register('name')}
|
||||||
|
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 px-4 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
|
||||||
|
placeholder="أحمد محمد"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.name && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">البريد الإلكتروني</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
{...register('email')}
|
||||||
|
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
|
||||||
|
placeholder="admin@office.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.email && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-slate-700 mb-2 mr-1">كلمة المرور</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400 group-focus-within:text-primary-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
{...register('password')}
|
||||||
|
type="password"
|
||||||
|
className="w-full bg-slate-50/50 border border-slate-200 rounded-xl py-3 pl-4 pr-11 focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 outline-none transition-all"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.password && <p className="text-red-500 text-[12px] mt-1 mr-1">{errors.password.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
className="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-600 font-bold py-4 rounded-xl transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
السابق
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-[2] btn-primary py-4 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-6 h-6 animate-spin" /> : 'إنشاء الحساب'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 text-center text-slate-500 text-sm">
|
||||||
|
لديك حساب بالفعل؟{' '}
|
||||||
|
<Link to="/login" className="text-primary-600 font-bold hover:underline">
|
||||||
|
سجل دخولك من هنا
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
frontend/src/pages/dashboard/DashboardPage.tsx
Normal file
118
frontend/src/pages/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Dashboard Statistics Components
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
TrendingUp,
|
||||||
|
Wallet,
|
||||||
|
ArrowUpRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: 'إجمالي الفواتير', value: '1,280', icon: FileText, color: 'text-primary-600', bg: 'bg-primary-50', change: '+12%' },
|
||||||
|
{ label: 'تمت مصادقتها', value: '1,150', icon: CheckCircle2, color: 'text-emerald-600', bg: 'bg-emerald-50', change: '+18%' },
|
||||||
|
{ label: 'قيد المراجعة', value: '42', icon: AlertCircle, color: 'text-amber-600', bg: 'bg-amber-50', change: '-5%' },
|
||||||
|
{ label: 'مجموع الضريبة (JOD)', value: '14,250.000', icon: Wallet, color: 'text-blue-600', bg: 'bg-blue-50', change: '+8%' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DashboardPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900">لوحة التحكم</h2>
|
||||||
|
<p className="text-slate-500 mt-1">نظرة عامة على نشاطك الضريبي هذا الشهر.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className="bg-white border border-slate-200 text-slate-700 font-semibold py-2.5 px-6 rounded-xl shadow-sm hover:bg-slate-50 transition-all flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-primary-500" />
|
||||||
|
تصدير التقارير
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary py-2.5 px-6 rounded-xl flex items-center gap-2 shadow-lg shadow-primary-500/25">
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
فاتورة جديدة
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Stats Grid ────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{stats.map((stat, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.1 }}
|
||||||
|
className="card-premium p-6 group cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className={`p-3 rounded-2xl ${stat.bg} ${stat.color} transition-transform group-hover:scale-110 duration-300`}>
|
||||||
|
<stat.icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-1 text-[12px] font-bold px-2 py-1 rounded-full ${stat.change.startsWith('+') ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'}`}>
|
||||||
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
|
{stat.change}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-500 text-sm font-medium">{stat.label}</p>
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900 mt-1">{stat.value}</h3>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main Dashboard Content (Placeholder for Charts/Lists) ── */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="card-premium h-[400px] p-6 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h4 className="font-bold text-lg">تحليلات الفوترة الأسبوعية</h4>
|
||||||
|
<select className="bg-slate-50 border border-slate-100 rounded-lg py-1.5 px-3 text-sm font-medium outline-none">
|
||||||
|
<option>آخر 7 أيام</option>
|
||||||
|
<option>آخر 30 يوم</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-slate-50 rounded-2xl border border-dashed border-slate-200 flex items-center justify-center">
|
||||||
|
<p className="text-slate-400 text-sm font-medium italic">رسم بياني توضيحي (Chart integration goes here)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="card-premium p-6 bg-primary-600 text-white shadow-xl shadow-primary-500/30">
|
||||||
|
<h4 className="font-bold text-lg mb-2">استهلاك الاشتراك الحالي</h4>
|
||||||
|
<p className="text-primary-100 text-sm mb-6">لقد استهلكت 65% من حصتك الشهرية من الفواتير.</p>
|
||||||
|
<div className="w-full h-3 bg-white/20 rounded-full overflow-hidden mb-6">
|
||||||
|
<div className="w-2/3 h-full bg-white rounded-full shadow-lg" />
|
||||||
|
</div>
|
||||||
|
<button className="w-full bg-white text-primary-600 font-bold py-3 rounded-xl hover:bg-primary-50 transition-all">
|
||||||
|
ترقية الباقة الآن
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-premium p-6">
|
||||||
|
<h4 className="font-bold text-lg mb-4">آخر النشاطات</h4>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="flex items-center gap-3 p-2 hover:bg-slate-50 rounded-xl transition-all cursor-pointer">
|
||||||
|
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
|
||||||
|
<FileText className="w-5 h-5 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-bold text-slate-800">فاتورة مبيعات #A-2024-001</p>
|
||||||
|
<p className="text-[12px] text-slate-500">منذ 10 دقائق · تمت المصادقة</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
159
frontend/src/pages/invoices/InvoicesPage.tsx
Normal file
159
frontend/src/pages/invoices/InvoicesPage.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Invoices Management Page
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Eye,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
MoreVertical,
|
||||||
|
FileImage,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const invoices = [
|
||||||
|
{ id: '1', number: 'INV-2024-001', company: 'شركة الأمل', date: '2024-04-15', total: '150.000', status: 'approved', type: 'cash' },
|
||||||
|
{ id: '2', number: 'INV-2024-002', company: 'سوبرماركت المدينة', date: '2024-04-16', total: '2,400.000', status: 'validated', type: 'credit' },
|
||||||
|
{ id: '3', number: 'OCR_PENDING', company: 'مخبز السلام', date: '2024-04-16', total: '0.000', status: 'extracting', type: 'cash' },
|
||||||
|
{ id: '4', number: 'INV-2024-003', company: 'مكتبة النجاح', date: '2024-04-14', total: '85.250', status: 'validation_failed', type: 'cash' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const StatusBadge = ({ status }: { status: string }) => {
|
||||||
|
const config: any = {
|
||||||
|
approved: { color: 'text-emerald-700 bg-emerald-50 border-emerald-100', icon: CheckCircle2, label: 'تم التصديق' },
|
||||||
|
validated: { color: 'text-blue-700 bg-blue-50 border-blue-100', icon: Clock, label: 'جاهز للإرسال' },
|
||||||
|
extracting: { color: 'text-amber-700 bg-amber-50 border-amber-100', icon: Clock, label: 'قيد الاستخراج AI' },
|
||||||
|
validation_failed: { color: 'text-red-700 bg-red-50 border-red-100', icon: AlertCircle, label: 'خطأ في التحقق' },
|
||||||
|
};
|
||||||
|
const { color, icon: Icon, label } = config[status] || config.extracting;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold border ${color}`}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InvoicesPage = () => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 h-full flex flex-col">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900">إدارة الفواتير</h2>
|
||||||
|
<p className="text-slate-500 mt-1">عرض، معالجة، وإرسال الفواتير الضريبية لبوابة الضريبة.</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary py-3 px-8 rounded-2xl flex items-center gap-2 shadow-xl shadow-primary-500/25 active:scale-95 transition-all">
|
||||||
|
<Upload className="w-5 h-5" />
|
||||||
|
رفع فاتورة جديدة
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Filter & Search Bar ──────────────────────────────── */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1 glass border-slate-200 rounded-2xl px-4 py-3 flex items-center gap-3">
|
||||||
|
<Search className="w-5 h-5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ابحث برقم الفاتورة، اسم الشركة، أو التاريخ..."
|
||||||
|
className="bg-transparent border-none outline-none flex-1 text-slate-800 text-sm"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="glass border-slate-200 px-6 rounded-2xl flex items-center gap-2 text-slate-600 hover:bg-slate-50 transition-all font-semibold">
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
فلترة متقدمة
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Invoices Table ───────────────────────────────────── */}
|
||||||
|
<div className="flex-1 card-premium overflow-hidden flex flex-col bg-white">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-right border-collapse">
|
||||||
|
<thead className="bg-slate-50/80 border-b border-slate-100">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-sm font-bold text-slate-500">رقم الفاتورة</th>
|
||||||
|
<th className="px-6 py-4 text-sm font-bold text-slate-500">الشركة المصدرة</th>
|
||||||
|
<th className="px-6 py-4 text-sm font-bold text-slate-500">التاريخ</th>
|
||||||
|
<th className="px-6 py-4 text-sm font-bold text-slate-500">النوع</th>
|
||||||
|
<th className="px-6 py-4 text-sm font-bold text-slate-500">المجموع (JOD)</th>
|
||||||
|
<th className="px-6 py-4 text-sm font-bold text-slate-500">الحالة</th>
|
||||||
|
<th className="px-6 py-4 text-sm font-bold text-slate-500 w-20">إجراءات</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{invoices.map((inv, idx) => (
|
||||||
|
<motion.tr
|
||||||
|
key={inv.id}
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: idx * 0.05 }}
|
||||||
|
className="hover:bg-slate-50/50 transition-colors group cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 font-bold text-slate-900">{inv.number}</td>
|
||||||
|
<td className="px-6 py-4 text-slate-600 font-medium">{inv.company}</td>
|
||||||
|
<td className="px-6 py-4 text-slate-500 text-sm">{inv.date}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`text-[11px] font-bold px-2 py-0.5 rounded uppercase tracking-wider ${inv.type === 'cash' ? 'bg-indigo-50 text-indigo-600' : 'bg-orange-50 text-orange-600'}`}>
|
||||||
|
{inv.type === 'cash' ? 'نقدي' : 'ذمم'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-mono font-bold text-slate-800">{inv.total}</td>
|
||||||
|
<td className="px-6 py-4"><StatusBadge status={inv.status} /></td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-all">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all">
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Empty State Mock (Hidden if data exists) ───────────── */}
|
||||||
|
{invoices.length === 0 && (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-20 text-center">
|
||||||
|
<div className="w-24 h-24 bg-slate-50 rounded-full flex items-center justify-center mb-6 border border-slate-100">
|
||||||
|
<Upload className="w-10 h-10 text-slate-300" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-900 mb-2">لا توجد فواتير بعد</h3>
|
||||||
|
<p className="text-slate-500 max-w-sm mb-8">ابدأ برفع أول فاتورة ليقوم محرك الذكاء الاصطناعي باستخراج بياناتها ومصادقتها ضريبياً.</p>
|
||||||
|
<button className="btn-primary py-3 px-8 rounded-2xl flex items-center gap-2">
|
||||||
|
ارفع فاتورتك الأولى
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Pagination ───────────────────────────────────────── */}
|
||||||
|
<footer className="px-6 py-4 bg-slate-50/50 border-t border-slate-100 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-slate-500">عرض 1-10 من أصل 1,280 فاتورة</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="p-2 text-slate-400 hover:text-slate-600 disabled:opacity-30 border border-slate-200 rounded-xl bg-white shadow-sm">
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-slate-400 hover:text-slate-600 disabled:opacity-30 border border-slate-200 rounded-xl bg-white shadow-sm">
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
frontend/src/store/authStore.ts
Normal file
43
frontend/src/store/authStore.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
* مُصادَق (Musadaq) — Auth Store (Zustand)
|
||||||
|
* ════════════════════════════════════════════════════════════
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
setAuth: (user: User, token: string) => void;
|
||||||
|
clearAuth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
setAuth: (user, token) => {
|
||||||
|
localStorage.setItem('access_token', token);
|
||||||
|
set({ user, isAuthenticated: true });
|
||||||
|
},
|
||||||
|
clearAuth: () => {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
set({ user: null, isAuthenticated: false });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'musadaq-auth-storage',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
25
frontend/tsconfig.app.json
Normal file
25
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
45
musadaq/.gitignore
vendored
Normal file
45
musadaq/.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
45
musadaq/.metadata
Normal file
45
musadaq/.metadata
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
- platform: android
|
||||||
|
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
- platform: ios
|
||||||
|
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
- platform: linux
|
||||||
|
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
- platform: macos
|
||||||
|
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
- platform: web
|
||||||
|
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
- platform: windows
|
||||||
|
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
17
musadaq/README.md
Normal file
17
musadaq/README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# musadaq_mobile
|
||||||
|
|
||||||
|
A new Flutter project.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||||
|
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||||
|
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||||
|
|
||||||
|
For help getting started with Flutter development, view the
|
||||||
|
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
||||||
28
musadaq/analysis_options.yaml
Normal file
28
musadaq/analysis_options.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
|
# check for errors, warnings, and lints.
|
||||||
|
#
|
||||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
# The lint rules applied to this project can be customized in the
|
||||||
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
# included above or to enable additional rules. A list of all available lints
|
||||||
|
# and their documentation is published at https://dart.dev/lints.
|
||||||
|
#
|
||||||
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
|
# section below, it can also be suppressed for a single line of code
|
||||||
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
|
# producing the lint.
|
||||||
|
rules:
|
||||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
14
musadaq/android/.gitignore
vendored
Normal file
14
musadaq/android/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Remember to never publicly share your keystore.
|
||||||
|
# See https://flutter.dev/to/reference-keystore
|
||||||
|
key.properties
|
||||||
|
**/*.keystore
|
||||||
|
**/*.jks
|
||||||
44
musadaq/android/app/build.gradle.kts
Normal file
44
musadaq/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.musadaq.app.musadaq_mobile"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "com.musadaq.app.musadaq_mobile"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
7
musadaq/android/app/src/debug/AndroidManifest.xml
Normal file
7
musadaq/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
45
musadaq/android/app/src/main/AndroidManifest.xml
Normal file
45
musadaq/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="musadaq_mobile"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.musadaq.app.musadaq_mobile
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user