diff --git a/backend/package.json b/backend/package.json index 708a8e4..a28d565 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,8 @@ "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", + "@nestjs/jwt": "^11.0.0", + "@nestjs/passport": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/platform-socket.io": "^11.0.0", "@nestjs/swagger": "^11.0.0", @@ -32,6 +34,8 @@ "class-validator": "^0.14.1", "dotenv": "^16.4.5", "ioredis": "^5.4.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.12.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -45,6 +49,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.12", "@types/node": "^22.0.0", + "@types/passport-jwt": "^4.0.1", "jest": "^29.7.0", "ts-jest": "^29.2.0", "ts-loader": "^9.5.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3f24410..eb42be6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -6,6 +6,8 @@ import configuration from './config/configuration'; import { TenantMiddleware } from './common/tenant/tenant.middleware'; import { HealthModule } from './modules/health/health.module'; import { TenantsModule } from './modules/tenants/tenants.module'; +import { UsersModule } from './modules/users/users.module'; +import { AuthModule } from './modules/auth/auth.module'; @Module({ imports: [ @@ -31,6 +33,8 @@ import { TenantsModule } from './modules/tenants/tenants.module'; HealthModule, TenantsModule, + UsersModule, + AuthModule, ], }) export class AppModule implements NestModule { diff --git a/backend/src/common/redis/redis.module.ts b/backend/src/common/redis/redis.module.ts new file mode 100644 index 0000000..0867228 --- /dev/null +++ b/backend/src/common/redis/redis.module.ts @@ -0,0 +1,29 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +export const REDIS = 'REDIS_CLIENT'; + +/** + * عميل Redis عالمي — DB رقم 3 وبادئة مفاتيح tripz: للعزل على السيرفر المشترك + * (راجع docs/14). يُستخدم لتخزين OTP والحضور والمطابقة لاحقاً. + */ +@Global() +@Module({ + providers: [ + { + provide: REDIS, + inject: [ConfigService], + useFactory: (cfg: ConfigService) => + new Redis({ + host: cfg.get('redis.host'), + port: cfg.get('redis.port'), + db: cfg.get('redis.db'), + keyPrefix: cfg.get('redis.keyPrefix'), + maxRetriesPerRequest: null, + }), + }, + ], + exports: [REDIS], +}) +export class RedisModule {} diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index cc4d895..6e43b01 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -34,6 +34,14 @@ export default () => ({ refreshExpires: process.env.JWT_REFRESH_EXPIRES ?? '30d', }, + auth: { + // وضع تطوير: رمز OTP ثابت 0000 يُطبع باللوغ بلا مزوّد SMS (راجع docs/07). + // للإنتاج: OTP_DEV_MODE=false + ربط محوّل SMS في P2. + otpDevMode: process.env.OTP_DEV_MODE !== 'false', + otpTtl: parseInt(process.env.OTP_TTL ?? '300', 10), + otpLength: parseInt(process.env.OTP_LENGTH ?? '4', 10), + }, + maps: { tilesUrl: process.env.MAPS_TILES_URL ?? 'http://martin:3000', provider: process.env.MAPS_PROVIDER ?? 'antlaq', diff --git a/backend/src/database/migrations/1721232000000-InitUsers.ts b/backend/src/database/migrations/1721232000000-InitUsers.ts new file mode 100644 index 0000000..a1308c8 --- /dev/null +++ b/backend/src/database/migrations/1721232000000-InitUsers.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * جدول المستخدمين tripz_users + فهرس فريد (tenant_id, phone). + */ +export class InitUsers1721232000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_users ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + phone varchar NOT NULL, + name varchar, + role varchar NOT NULL DEFAULT 'rider', + status varchar NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `); + await q.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_users_tenant_phone" + ON tripz_users (tenant_id, phone) + `); + } + + public async down(q: QueryRunner): Promise { + await q.query(`DROP TABLE IF EXISTS tripz_users`); + } +} diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..15222ba --- /dev/null +++ b/backend/src/modules/auth/auth.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Post, Body, Headers, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from './auth.service'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('send-otp') + async sendOtp( + @Headers('x-tenant-id') tenantId: string, + @Body('phone') phone: string, + ) { + if (!tenantId) throw new UnauthorizedException('Tenant ID (x-tenant-id) is required'); + return this.authService.sendOtp(tenantId, phone); + } + + @Post('verify-otp') + async verifyOtp( + @Headers('x-tenant-id') tenantId: string, + @Body('phone') phone: string, + @Body('code') code: string, + ) { + if (!tenantId) throw new UnauthorizedException('Tenant ID (x-tenant-id) is required'); + return this.authService.verifyOtp(tenantId, phone, code); + } +} diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..02b4c18 --- /dev/null +++ b/backend/src/modules/auth/auth.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { UsersModule } from '../users/users.module'; +import { JwtStrategy } from './strategies/jwt.strategy'; + +@Module({ + imports: [ + UsersModule, + PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET') || 'change_me_jwt_secret', + signOptions: { + expiresIn: configService.get('JWT_EXPIRES') || '15m', + }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..40f1185 --- /dev/null +++ b/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,77 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { UsersService } from '../users/users.service'; +import { User } from '../users/entities/user.entity'; + +@Injectable() +export class AuthService { + private readonly logger = new Logger('Auth'); + + constructor( + private usersService: UsersService, + private jwtService: JwtService, + private config: ConfigService, + ) {} + + private get devCode(): string { + // رمز التطوير الثابت — يُستبدل بمحوّل SMS في P2 (راجع docs/07). + return '1234'; + } + + async sendOtp(tenantId: string, phone: string) { + // وضع تطوير: الرمز ثابت ويُطبع باللوغ بلا مزوّد SMS. + this.logger.log(`OTP for tenant=${tenantId} phone=${phone} => ${this.devCode} (dev)`); + return { + success: true, + message: 'OTP sent', + dev_code: this.config.get('auth.otpDevMode') ? this.devCode : undefined, + }; + } + + async verifyOtp(tenantId: string, phone: string, code: string) { + if (code !== this.devCode) { + throw new UnauthorizedException('Invalid OTP code'); + } + + let user = await this.usersService.findByPhone(tenantId, phone); + if (!user) { + user = await this.usersService.create(tenantId, phone); + } + + return this.issueTokens(user, tenantId); + } + + async refresh(refreshToken: string) { + let payload: any; + try { + payload = this.jwtService.verify(refreshToken); + } catch { + throw new UnauthorizedException('Invalid refresh token'); + } + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Not a refresh token'); + } + const user = await this.usersService.findById(payload.tenant_id, payload.sub); + if (!user) throw new UnauthorizedException('User not found'); + return this.issueTokens(user, user.tenant_id); + } + + /** يصدر access + refresh معاً. */ + private issueTokens(user: User, tenantId: string) { + const base = { + sub: user.id, + phone: user.phone, + role: user.role, + tenant_id: tenantId, + }; + return { + access_token: this.jwtService.sign(base), + refresh_token: this.jwtService.sign( + { ...base, type: 'refresh' }, + { expiresIn: this.config.get('jwt.refreshExpires') ?? '30d' }, + ), + user, + }; + } +} diff --git a/backend/src/modules/auth/decorators/roles.decorator.ts b/backend/src/modules/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..b037672 --- /dev/null +++ b/backend/src/modules/auth/decorators/roles.decorator.ts @@ -0,0 +1,3 @@ +import { SetMetadata } from '@nestjs/common'; + +export const Roles = (...roles: string[]) => SetMetadata('roles', roles); diff --git a/backend/src/modules/auth/guards/jwt-auth.guard.ts b/backend/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/backend/src/modules/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/backend/src/modules/auth/guards/roles.guard.ts b/backend/src/modules/auth/guards/roles.guard.ts new file mode 100644 index 0000000..9c5b1ef --- /dev/null +++ b/backend/src/modules/auth/guards/roles.guard.ts @@ -0,0 +1,19 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride('roles', [ + context.getHandler(), + context.getClass(), + ]); + if (!requiredRoles) { + return true; + } + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user?.role === role); + } +} diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..98c213e --- /dev/null +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -0,0 +1,24 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET') || 'change_me_jwt_secret', + }); + } + + async validate(payload: any) { + return { + userId: payload.sub, + phone: payload.phone, + role: payload.role, + tenantId: payload.tenant_id + }; + } +} diff --git a/backend/src/modules/users/entities/user.entity.ts b/backend/src/modules/users/entities/user.entity.ts new file mode 100644 index 0000000..f7dda06 --- /dev/null +++ b/backend/src/modules/users/entities/user.entity.ts @@ -0,0 +1,36 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export enum UserRole { + RIDER = 'rider', + DRIVER = 'driver', + DISPATCHER = 'dispatcher', + ADMIN = 'admin', +} + +@Entity('users') +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column() + phone: string; + + @Column({ nullable: true }) + name: string; + + // varchar (لا enum) لمطابقة الهجرة وتفادي إنشاء نوع enum في القاعدة. + @Column({ type: 'varchar', default: UserRole.RIDER }) + role: UserRole; + + @Column({ default: 'active' }) + status: string; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts new file mode 100644 index 0000000..f8cc930 --- /dev/null +++ b/backend/src/modules/users/users.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from './entities/user.entity'; +import { UsersService } from './users.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts new file mode 100644 index 0000000..6c2ab0d --- /dev/null +++ b/backend/src/modules/users/users.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User, UserRole } from './entities/user.entity'; + +@Injectable() +export class UsersService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async findByPhone(tenantId: string, phone: string): Promise { + return this.userRepository.findOne({ where: { tenant_id: tenantId, phone } }); + } + + async findById(tenantId: string, id: string): Promise { + return this.userRepository.findOne({ where: { tenant_id: tenantId, id } }); + } + + async create(tenantId: string, phone: string, role: string = 'rider'): Promise { + const user = this.userRepository.create({ tenant_id: tenantId, phone, role: role as UserRole }); + return this.userRepository.save(user); + } +} diff --git a/sync-to-server.sh b/sync-to-server.sh index e9eb0a6..d9f86f2 100755 --- a/sync-to-server.sh +++ b/sync-to-server.sh @@ -1,55 +1,20 @@ #!/usr/bin/env bash # ============================================================ -# Tripz — مزامنة الكود من الماك إلى السيرفر (نصوص فقط) -# الاستخدام من تيرمينال الماك: -# ./sync-to-server.sh # مزامنة فقط -# ./sync-to-server.sh --deploy # مزامنة ثم docker compose up على السيرفر -# ./sync-to-server.sh --logs # عرض لوغ الحاويات على السيرفر -# ملاحظة: لا نبني ولا نشغّل شيئاً على الماك — كل التشغيل على السيرفر عبر Docker. +# Tripz — رفع الكود محلياً إلى Git +# الاستخدام: +# ./sync-to-server.sh "رسالة التحديث" # ============================================================ set -euo pipefail -# ---- إعدادات السيرفر (عدّل عند اللزوم) ---- -SERVER_USER="root" -SERVER_IP="194.163.173.157" -REMOTE_DIR="/home/tripz-llc" # مجلد مخصّص جديد (منفصل عن مواقع CloudPanel) -LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)/" +MSG="${1:-Update codebase}" -SSH="ssh ${SERVER_USER}@${SERVER_IP}" +echo "==> رفع الكود محلياً إلى Git..." +git add . +git commit -m "$MSG" || true +git push origin main -echo "==> مزامنة ${LOCAL_DIR} ⟶ ${SERVER_USER}@${SERVER_IP}:${REMOTE_DIR}" - -# تأكد من وجود المجلد على السيرفر -$SSH "mkdir -p ${REMOTE_DIR}" - -# rsync: يرفع النصوص فقط، يستثني المخرجات والأسرار، ويحذف المحذوف محلياً -# لكن يُبقي .env على السيرفر (يُنشأ هناك ولا يُرفع من الماك) -rsync -avz --delete \ - --exclude='.git/' \ - --exclude='node_modules/' \ - --exclude='dist/' \ - --exclude='build/' \ - --exclude='.dart_tool/' \ - --exclude='**/pgdata/' \ - --exclude='**/redisdata/' \ - --exclude='.env' \ - --exclude='.DS_Store' \ - --exclude='*.log' \ - "${LOCAL_DIR}" "${SERVER_USER}@${SERVER_IP}:${REMOTE_DIR}/" - -echo "==> تمت المزامنة." - -# ---- خيارات إضافية ---- -case "${1:-}" in - --deploy) - echo "==> تشغيل docker compose على السيرفر..." - $SSH "cd ${REMOTE_DIR}/backend && \ - [ -f .env ] || cp .env.example .env && \ - docker compose up -d --build && \ - docker compose exec -T api npm run migration:run || true" - echo "==> التطبيق: http://${SERVER_IP}:4010/api/health" - ;; - --logs) - $SSH "cd ${REMOTE_DIR}/backend && docker compose logs --tail=100 -f" - ;; -esac +echo "==> تمت عملية الرفع بنجاح!" +echo "الآن يمكنك الذهاب إلى تيرمينال السيرفر وتشغيل الأوامر التالية:" +echo " cd /home/tripz-llc" +echo " git pull origin main" +echo " cd backend && docker compose up -d --build"