Update codebase

This commit is contained in:
Hamza-Ayed
2026-07-16 16:01:28 +03:00
parent 951f6d80e1
commit 83f2d0854a
16 changed files with 343 additions and 48 deletions
+5
View File
@@ -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",
+4
View File
@@ -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 {
+29
View File
@@ -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<string>('redis.host'),
port: cfg.get<number>('redis.port'),
db: cfg.get<number>('redis.db'),
keyPrefix: cfg.get<string>('redis.keyPrefix'),
maxRetriesPerRequest: null,
}),
},
],
exports: [REDIS],
})
export class RedisModule {}
+8
View File
@@ -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',
@@ -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<void> {
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<void> {
await q.query(`DROP TABLE IF EXISTS tripz_users`);
}
}
@@ -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);
}
}
+29
View File
@@ -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<string>('JWT_SECRET') || 'change_me_jwt_secret',
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES') || '15m',
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
+77
View File
@@ -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<string>('jwt.refreshExpires') ?? '30d' },
),
user,
};
}
}
@@ -0,0 +1,3 @@
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
@@ -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<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user?.role === role);
}
}
@@ -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<string>('JWT_SECRET') || 'change_me_jwt_secret',
});
}
async validate(payload: any) {
return {
userId: payload.sub,
phone: payload.phone,
role: payload.role,
tenantId: payload.tenant_id
};
}
}
@@ -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;
}
+11
View File
@@ -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 {}
@@ -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<User>,
) {}
async findByPhone(tenantId: string, phone: string): Promise<User | null> {
return this.userRepository.findOne({ where: { tenant_id: tenantId, phone } });
}
async findById(tenantId: string, id: string): Promise<User | null> {
return this.userRepository.findOne({ where: { tenant_id: tenantId, id } });
}
async create(tenantId: string, phone: string, role: string = 'rider'): Promise<User> {
const user = this.userRepository.create({ tenant_id: tenantId, phone, role: role as UserRole });
return this.userRepository.save(user);
}
}