2026-04-15-4
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
@@ -8,6 +8,10 @@ import { MapsModule } from './maps/maps.module';
|
||||
import { GeocodingModule } from './geocoding/geocoding.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { UsageModule } from './usage/usage.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { MailModule } from './common/mail.module';
|
||||
import { UsageInterceptor } from './usage/usage.interceptor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -33,7 +37,16 @@ import { ThrottlerModule } from '@nestjs/throttler';
|
||||
TelemetryModule,
|
||||
MapsModule,
|
||||
GeocodingModule,
|
||||
UsageModule,
|
||||
BillingModule,
|
||||
MailModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: UsageInterceptor,
|
||||
},
|
||||
],
|
||||
providers: [],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ApiKey } from './entities/api-key.entity';
|
||||
import { RedisModule } from '../common/redis.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TenantController } from './tenant.controller';
|
||||
import { FirebaseAdminService } from './firebase-admin.service';
|
||||
import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -14,8 +16,8 @@ import { TenantController } from './tenant.controller';
|
||||
RedisModule,
|
||||
],
|
||||
controllers: [TenantController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
providers: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
|
||||
exports: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
|
||||
})
|
||||
export class AuthModule implements OnModuleInit {
|
||||
constructor(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createHash } from 'crypto';
|
||||
import { ApiKey } from './entities/api-key.entity';
|
||||
import { Tenant } from './entities/tenant.entity';
|
||||
import { Tenant, TenantPlan } from './entities/tenant.entity';
|
||||
import { RedisService } from '../common/redis.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -16,7 +16,7 @@ export class AuthService {
|
||||
@InjectRepository(Tenant)
|
||||
private readonly tenantRepository: Repository<Tenant>,
|
||||
private readonly redisService: RedisService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Validate an API key and check its restrictions (Origin/Referer).
|
||||
@@ -26,7 +26,7 @@ export class AuthService {
|
||||
// 1. Check Redis Cache first
|
||||
const cacheKey = `auth:apikey:${key}`;
|
||||
const cachedData = await this.redisService.get<{ tenant: Tenant; apiKey: ApiKey; rateLimit: number }>(cacheKey);
|
||||
|
||||
|
||||
if (cachedData) {
|
||||
this.validateRestrictions(cachedData.apiKey, origin, referer);
|
||||
return cachedData;
|
||||
@@ -53,9 +53,9 @@ export class AuthService {
|
||||
|
||||
// 4. Update Cache (TTL 1 hour)
|
||||
await this.redisService.set(cacheKey, result, 3600);
|
||||
|
||||
|
||||
// 5. Update lastUsedAt asynchronously
|
||||
this.apiKeyRepository.update(apiKey.id, { lastUsedAt: new Date() }).catch(err =>
|
||||
this.apiKeyRepository.update(apiKey.id, { lastUsedAt: new Date() }).catch(err =>
|
||||
this.logger.error(`Failed to update lastUsedAt for API key ${apiKey.id}: ${err.message}`)
|
||||
);
|
||||
|
||||
@@ -105,10 +105,10 @@ export class AuthService {
|
||||
async seedDefaultKey(name: string, email: string, keyString: string): Promise<void> {
|
||||
let tenant = await this.tenantRepository.findOne({ where: { email } });
|
||||
if (!tenant) {
|
||||
tenant = await this.tenantRepository.save({
|
||||
name,
|
||||
email,
|
||||
isActive: true
|
||||
tenant = await this.tenantRepository.save({
|
||||
name,
|
||||
email,
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const key = `in_${createHash('md5').update(Math.random().toString()).digest('hex').substring(0, 24)}`;
|
||||
|
||||
|
||||
const apiKey = this.apiKeyRepository.create({
|
||||
key,
|
||||
secretHash: this.hashSecret(key),
|
||||
@@ -164,4 +164,51 @@ export class AuthService {
|
||||
if (!tenant) throw new NotFoundException('No tenants found in system');
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a tenant by Firebase UID or create one if it doesn't exist.
|
||||
* Supports linking Google accounts to existing email-based tenants.
|
||||
*/
|
||||
async findOrCreateByFirebaseUid(uid: string, email: string, name: string, photoUrl?: string): Promise<Tenant> {
|
||||
// 1. Try finding by Firebase UID
|
||||
let tenant = await this.tenantRepository.findOne({ where: { firebaseUid: uid } });
|
||||
|
||||
if (!tenant) {
|
||||
// 2. Try finding by email for account linking
|
||||
tenant = await this.tenantRepository.findOne({ where: { email } });
|
||||
|
||||
if (tenant) {
|
||||
this.logger.log(`Linking existing tenant ${email} to Firebase UID: ${uid}`);
|
||||
tenant.firebaseUid = uid;
|
||||
if (photoUrl) tenant.photoUrl = photoUrl;
|
||||
tenant = await this.tenantRepository.save(tenant);
|
||||
} else {
|
||||
// 3. Create new if neither found
|
||||
this.logger.log(`Creating new tenant for Firebase user: ${email} (${uid})`);
|
||||
const isAdmin = email === 'hamzaaleghwairyeen@gmail.com' || email === 'hamzadoctor@gmail.com';
|
||||
tenant = await this.tenantRepository.save({
|
||||
firebaseUid: uid,
|
||||
email,
|
||||
name,
|
||||
photoUrl,
|
||||
plan: isAdmin ? TenantPlan.ENTERPRISE : TenantPlan.FREE,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Auto-upgrade admins if they exist but are on lower plan
|
||||
const isAdmin = email === 'hamzaaleghwairyeen@gmail.com' || email === 'hamzadoctor@gmail.com';
|
||||
if (isAdmin && tenant.plan !== TenantPlan.ENTERPRISE) {
|
||||
tenant.plan = TenantPlan.ENTERPRISE;
|
||||
await this.tenantRepository.save(tenant);
|
||||
}
|
||||
|
||||
// Update info if changed
|
||||
if (tenant.photoUrl !== photoUrl || tenant.name !== name) {
|
||||
await this.tenantRepository.update(tenant.id, { photoUrl, name });
|
||||
}
|
||||
}
|
||||
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import { ApiKey } from './api-key.entity';
|
||||
|
||||
export enum TenantPlan {
|
||||
FREE = 'FREE',
|
||||
PREMIUM = 'PREMIUM',
|
||||
PRO = 'PRO',
|
||||
ENTERPRISE = 'ENTERPRISE',
|
||||
}
|
||||
|
||||
export enum TenantRole {
|
||||
USER = 'USER',
|
||||
ADMIN = 'ADMIN',
|
||||
}
|
||||
|
||||
@Entity('tenants')
|
||||
export class Tenant {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -25,6 +30,19 @@ export class Tenant {
|
||||
})
|
||||
plan: TenantPlan;
|
||||
|
||||
@Column({ nullable: true, unique: true })
|
||||
firebaseUid: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TenantRole,
|
||||
default: TenantRole.USER,
|
||||
})
|
||||
role: TenantRole;
|
||||
|
||||
@Column({ nullable: true })
|
||||
photoUrl: string;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as admin from 'firebase-admin';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class FirebaseAdminService implements OnModuleInit {
|
||||
private readonly logger = new Logger(FirebaseAdminService.name);
|
||||
private firebaseApp: admin.app.App;
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const keyPath = this.configService.get<string>('FIREBASE_SERVICE_ACCOUNT_PATH');
|
||||
|
||||
if (!keyPath) {
|
||||
this.logger.error('FIREBASE_SERVICE_ACCOUNT_PATH is not defined in environment variables');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve path relative to workspace root if it's not absolute
|
||||
const absolutePath = path.isAbsolute(keyPath)
|
||||
? keyPath
|
||||
: path.resolve(process.cwd(), keyPath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
this.logger.error(`Firebase service account key not found at: ${absolutePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceAccount = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
|
||||
|
||||
this.firebaseApp = admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
});
|
||||
|
||||
this.logger.log('✅ Firebase Admin SDK initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to initialize Firebase Admin SDK: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyIdToken(token: string): Promise<admin.auth.DecodedIdToken> {
|
||||
try {
|
||||
return await admin.auth().verifyIdToken(token);
|
||||
} catch (error) {
|
||||
this.logger.error(`Token verification failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { FirebaseAdminService } from '../firebase-admin.service';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class FirebaseAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(FirebaseAuthGuard.name);
|
||||
|
||||
constructor(
|
||||
private firebaseAdminService: FirebaseAdminService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.split('Bearer ')[1];
|
||||
|
||||
try {
|
||||
// 1. Verify Firebase ID Token
|
||||
const decodedToken = await this.firebaseAdminService.verifyIdToken(token);
|
||||
|
||||
// 2. Find or create Tenant based on Firebase info
|
||||
const tenant = await this.authService.findOrCreateByFirebaseUid(
|
||||
decodedToken.uid,
|
||||
decodedToken.email || '',
|
||||
decodedToken.name || decodedToken.email?.split('@')[0] || 'Unknown User',
|
||||
decodedToken.picture,
|
||||
);
|
||||
|
||||
if (!tenant.isActive) {
|
||||
throw new UnauthorizedException('Tenant account is disabled');
|
||||
}
|
||||
|
||||
// 3. Attach tenant to request for controllers
|
||||
request['tenant'] = tenant;
|
||||
request['user'] = decodedToken;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Firebase Auth failed: ${error.message}`);
|
||||
throw new UnauthorizedException(error.message || 'Authentication failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,23 @@
|
||||
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Req } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CreateKeyDto } from './dto/management/create-key.dto';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
|
||||
|
||||
@ApiTags('auth')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Controller('auth/management')
|
||||
export class TenantController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Get('keys/:tenantId')
|
||||
@ApiOperation({ summary: 'Get all API keys for a tenant' })
|
||||
async getKeys(@Param('tenantId') tenantId: string) {
|
||||
return this.authService.getApiKeys(tenantId);
|
||||
}
|
||||
|
||||
@Post('keys/:tenantId')
|
||||
@Post('keys')
|
||||
@ApiOperation({ summary: 'Create a new API key' })
|
||||
async createKey(
|
||||
@Param('tenantId') tenantId: string,
|
||||
@Req() req: any,
|
||||
@Body() dto: CreateKeyDto
|
||||
) {
|
||||
const tenantId = req.tenant.id;
|
||||
return this.authService.createApiKey(
|
||||
tenantId,
|
||||
dto.name,
|
||||
@@ -28,9 +26,16 @@ export class TenantController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('keys')
|
||||
@ApiOperation({ summary: 'Get all API keys for the authenticated tenant' })
|
||||
async getKeys(@Req() req: any) {
|
||||
const tenantId = req.tenant.id;
|
||||
return this.authService.getApiKeys(tenantId);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Identify the default tenant (Demo Only)' })
|
||||
async getMe() {
|
||||
return this.authService.getDefaultTenant();
|
||||
@ApiOperation({ summary: 'Get current authenticated tenant info' })
|
||||
async getMe(@Req() req: any) {
|
||||
return req.tenant;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Controller, Get, Post, Body, Query, UseGuards, Req, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { BillingService } from './billing.service';
|
||||
import { PayMobProvider } from './providers/paymob.provider';
|
||||
import { BinanceProvider } from './providers/binance.provider';
|
||||
import { FirebaseAuthGuard } from '../auth/guards/firebase-auth.guard';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { PaymentProvider, PaymentStatus } from './entities/transaction.entity';
|
||||
|
||||
@ApiTags('billing')
|
||||
@Controller('billing')
|
||||
export class BillingController {
|
||||
private readonly logger = new Logger(BillingController.name);
|
||||
|
||||
constructor(
|
||||
private billingService: BillingService,
|
||||
private paymobProvider: PayMobProvider,
|
||||
private binanceProvider: BinanceProvider,
|
||||
) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Get('subscription')
|
||||
@ApiOperation({ summary: 'Get current subscription details' })
|
||||
async getSubscription(@Req() req: any) {
|
||||
return this.billingService.getSubscription(req.tenant.id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Post('checkout')
|
||||
@ApiOperation({ summary: 'Initialize a checkout session' })
|
||||
async checkout(@Req() req: any, @Body() body: { plan: string; provider: PaymentProvider }) {
|
||||
const tenantId = req.tenant.id;
|
||||
const plan = body.plan;
|
||||
const amount = plan === 'PRO' ? 40 : 0; // Price logic
|
||||
|
||||
if (body.provider === PaymentProvider.PAYMOB) {
|
||||
const { paymentKey, orderId } = await this.paymobProvider.createPaymentKey(tenantId, amount, plan);
|
||||
|
||||
// Iframe URL (Using the new provided Iframe ID)
|
||||
const iframeId = this.billingService.getIframeId();
|
||||
const checkoutUrl = `https://accept.paymob.com/api/acceptance/iframes/${iframeId}?payment_token=${paymentKey}`;
|
||||
|
||||
return { checkoutUrl, orderId };
|
||||
}
|
||||
|
||||
if (body.provider === PaymentProvider.BINANCE) {
|
||||
return this.binanceProvider.createOrder(tenantId, amount, plan);
|
||||
}
|
||||
|
||||
throw new BadRequestException('Invalid payment provider');
|
||||
}
|
||||
|
||||
/**
|
||||
* PayMob Transaction Processed Webhook
|
||||
* This is called by PayMob when a transaction is attempted
|
||||
*/
|
||||
@Post('webhooks/paymob')
|
||||
@ApiOperation({ summary: 'PayMob Payment Webhook' })
|
||||
async handlePaymobWebhook(@Body() body: any, @Query('hmac') hmac: string) {
|
||||
this.logger.log(`📥 PayMob Webhook Received: Transaction ID ${body.obj?.id}`);
|
||||
|
||||
// 1. Verify HMAC
|
||||
if (!this.paymobProvider.verifyHmac(body.obj, hmac)) {
|
||||
this.logger.error('❌ PayMob HMAC Verification Failed');
|
||||
throw new BadRequestException('Invalid signature');
|
||||
}
|
||||
|
||||
// 2. Process payment if successful
|
||||
if (body.obj.success === true) {
|
||||
// Extract metadata from extra_description (format: tenantId|plan)
|
||||
const extraDesc = body.obj.order?.shipping_data?.extra_description || "";
|
||||
const [tenantId, plan] = extraDesc.split('|');
|
||||
|
||||
if (!tenantId) {
|
||||
this.logger.error(`❌ PayMob Webhook failed: No tenantId in metadata. Raw: ${extraDesc}`);
|
||||
return { status: 'error', message: 'No tenantId found' };
|
||||
}
|
||||
|
||||
await this.billingService.processSuccessfulPayment(
|
||||
body.obj.id.toString(),
|
||||
PaymentProvider.PAYMOB,
|
||||
body.obj.amount_cents / 100,
|
||||
{
|
||||
tenantId,
|
||||
plan: plan || 'PRO'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { status: 'success' };
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Get('invoices')
|
||||
@ApiOperation({ summary: 'Get payment history' })
|
||||
async getInvoices(@Req() req: any) {
|
||||
return this.billingService.getTransactionHistory(req.tenant.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Subscription } from './entities/subscription.entity';
|
||||
import { Transaction } from './entities/transaction.entity';
|
||||
import { BillingService } from './billing.service';
|
||||
import { BillingController } from './billing.controller';
|
||||
import { PayMobProvider } from './providers/paymob.provider';
|
||||
import { BinanceProvider } from './providers/binance.provider';
|
||||
import { Tenant } from '../auth/entities/tenant.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Subscription, Transaction, Tenant]),
|
||||
],
|
||||
providers: [BillingService, PayMobProvider, BinanceProvider],
|
||||
controllers: [BillingController],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Subscription, SubscriptionStatus } from './entities/subscription.entity';
|
||||
import { Transaction, PaymentStatus, PaymentProvider } from './entities/transaction.entity';
|
||||
import { Tenant, TenantPlan } from '../auth/entities/tenant.entity';
|
||||
import { MailService } from '../common/mail.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
private readonly logger = new Logger(BillingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Subscription)
|
||||
private subscriptionRepository: Repository<Subscription>,
|
||||
@InjectRepository(Transaction)
|
||||
private transactionRepository: Repository<Transaction>,
|
||||
@InjectRepository(Tenant)
|
||||
private tenantRepository: Repository<Tenant>,
|
||||
private configService: ConfigService,
|
||||
private mailService: MailService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get configured PayMob Iframe ID
|
||||
*/
|
||||
getIframeId(): string {
|
||||
return this.configService.get('PAYMOB_IFRAME_ID', '837992');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current subscription for a tenant
|
||||
*/
|
||||
async getSubscription(tenantId: string): Promise<Subscription> {
|
||||
let sub = await this.subscriptionRepository.findOne({ where: { tenantId } });
|
||||
|
||||
// Auto-initialize if not exists
|
||||
if (!sub) {
|
||||
sub = await this.subscriptionRepository.save({
|
||||
tenantId,
|
||||
plan: 'FREE',
|
||||
monthlyRequestLimit: 8000,
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
});
|
||||
}
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful payment from any provider
|
||||
*/
|
||||
async processSuccessfulPayment(
|
||||
externalTxId: string,
|
||||
provider: PaymentProvider,
|
||||
amount: number,
|
||||
metadata: any
|
||||
) {
|
||||
// 1. Find or create transaction
|
||||
let txn = await this.transactionRepository.findOne({ where: { externalTransactionId: externalTxId } });
|
||||
|
||||
if (txn && txn.status === PaymentStatus.SUCCESS) {
|
||||
this.logger.warn(`Transaction ${externalTxId} already processed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!txn) {
|
||||
// This might happen if webhook arrives before front-end redirect
|
||||
// Logic to resolve tenantId from metadata should be here
|
||||
const tenantId = metadata.tenantId;
|
||||
if (!tenantId) throw new BadRequestException('No tenantId found in payment metadata');
|
||||
|
||||
txn = await this.transactionRepository.save({
|
||||
tenantId,
|
||||
externalTransactionId: externalTxId,
|
||||
amount,
|
||||
provider,
|
||||
status: PaymentStatus.SUCCESS,
|
||||
metadata,
|
||||
});
|
||||
} else {
|
||||
txn.status = PaymentStatus.SUCCESS;
|
||||
txn.metadata = { ...txn.metadata, ...metadata };
|
||||
await this.transactionRepository.save(txn);
|
||||
}
|
||||
|
||||
// 2. Upgrade the subscription and tenant plan
|
||||
const tenantId = txn.tenantId;
|
||||
const plan = metadata.plan || 'PRO'; // Default to PRO for paid txns
|
||||
|
||||
await this.upgradeTenantPlan(tenantId, plan as TenantPlan);
|
||||
|
||||
// 3. Send Invoice Email
|
||||
try {
|
||||
const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } });
|
||||
if (tenant && tenant.email) {
|
||||
await this.mailService.sendInvoiceEmail(tenant.email, {
|
||||
tenantName: tenant.name,
|
||||
plan: plan,
|
||||
amount: `${amount} EGP`,
|
||||
transactionId: externalTxId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to send invoice email for tx ${externalTxId}: ${err.message}`);
|
||||
}
|
||||
|
||||
this.logger.log(`✅ Tenant ${tenantId} upgraded to ${plan} after payment ${externalTxId}`);
|
||||
}
|
||||
|
||||
private async upgradeTenantPlan(tenantId: string, plan: TenantPlan) {
|
||||
// Update Tenant
|
||||
await this.tenantRepository.update(tenantId, { plan });
|
||||
|
||||
// Update Subscription
|
||||
const limits = {
|
||||
[TenantPlan.FREE]: 8000,
|
||||
[TenantPlan.PRO]: 50000,
|
||||
[TenantPlan.ENTERPRISE]: 1000000,
|
||||
};
|
||||
|
||||
const sub = await this.getSubscription(tenantId);
|
||||
sub.plan = plan;
|
||||
sub.monthlyRequestLimit = limits[plan] || 8000;
|
||||
sub.status = SubscriptionStatus.ACTIVE;
|
||||
sub.currentPeriodStart = new Date();
|
||||
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
sub.currentPeriodEnd = nextMonth;
|
||||
|
||||
await this.subscriptionRepository.save(sub);
|
||||
}
|
||||
|
||||
async getTransactionHistory(tenantId: string): Promise<Transaction[]> {
|
||||
return this.transactionRepository.find({
|
||||
where: { tenantId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Tenant } from '../../auth/entities/tenant.entity';
|
||||
|
||||
export enum SubscriptionStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
PAST_DUE = 'PAST_DUE',
|
||||
CANCELED = 'CANCELED',
|
||||
TRIALING = 'TRIALING',
|
||||
}
|
||||
|
||||
@Entity('subscriptions')
|
||||
export class Subscription {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ default: 'FREE' })
|
||||
plan: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: SubscriptionStatus,
|
||||
default: SubscriptionStatus.ACTIVE,
|
||||
})
|
||||
status: SubscriptionStatus;
|
||||
|
||||
@Column({ type: 'int', default: 8000 })
|
||||
monthlyRequestLimit: number;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
currentPeriodStart: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
currentPeriodEnd: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
canceledAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum PaymentStatus {
|
||||
PENDING = 'PENDING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
FAILED = 'FAILED',
|
||||
REFUNDED = 'REFUNDED',
|
||||
}
|
||||
|
||||
export enum PaymentProvider {
|
||||
PAYMOB = 'PAYMOB',
|
||||
BINANCE = 'BINANCE',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
|
||||
@Entity('transactions')
|
||||
export class Transaction {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column()
|
||||
tenantId: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
subscriptionId: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||
amount: number;
|
||||
|
||||
@Column({ default: 'USD' })
|
||||
currency: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: PaymentProvider,
|
||||
})
|
||||
provider: PaymentProvider;
|
||||
|
||||
@Index()
|
||||
@Column({ nullable: true })
|
||||
externalTransactionId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: PaymentStatus,
|
||||
default: PaymentStatus.PENDING,
|
||||
})
|
||||
status: PaymentStatus;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class BinanceProvider {
|
||||
private readonly logger = new Logger(BinanceProvider.name);
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Mock Binance Pay Order Creation
|
||||
* Will lead to a success/fail redirect for testing
|
||||
*/
|
||||
async createOrder(tenantId: string, amount: number, plan: string) {
|
||||
this.logger.log(`[Mock] Creating Binance Pay order for ${tenantId} - ${plan}`);
|
||||
|
||||
// In production, this calls https://bpay.binanceapi.com/binancepay/openapi/v2/order
|
||||
// and returns a checkoutUrl.
|
||||
|
||||
return {
|
||||
checkoutUrl: `https://map-dashbord.intaleqapp.com/api/billing/mock-binance-success?tenantId=${tenantId}&plan=${plan}`,
|
||||
prepayId: `mock_${Date.now()}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Webhook Signature Verification
|
||||
*/
|
||||
verifySignature(payload: any, signature: string): boolean {
|
||||
// In production, uses HMAC-SHA512 with Binance Secret Key
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class PayMobProvider {
|
||||
private readonly logger = new Logger(PayMobProvider.name);
|
||||
private readonly baseUrl = 'https://accept.paymob.com/api';
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Step 1 & 2: Authenticate and Create Order
|
||||
*/
|
||||
async createPaymentKey(tenantId: string, amount: number, plan: string): Promise<{ paymentKey: string; orderId: string }> {
|
||||
try {
|
||||
// 1. Authentication Request
|
||||
const authRes = await axios.post(`${this.baseUrl}/auth/tokens`, {
|
||||
api_key: this.configService.get('PAYMOB_API_KEY'),
|
||||
});
|
||||
const authToken = authRes.data.token;
|
||||
|
||||
// 2. Order Registration
|
||||
const orderRes = await axios.post(`${this.baseUrl}/ecommerce/orders`, {
|
||||
auth_token: authToken,
|
||||
delivery_needed: "false",
|
||||
amount_cents: amount * 100, // PayMob uses cents
|
||||
currency: "EGP", // Or USD based on integration
|
||||
items: [{
|
||||
name: `${plan} Subscription`,
|
||||
amount_cents: amount * 100,
|
||||
description: `Intaleq Maps ${plan} Plan`
|
||||
}],
|
||||
shipping_data: {
|
||||
// Mandatory dummy data + Metadata for identification
|
||||
first_name: "Intaleq",
|
||||
last_name: "User",
|
||||
email: "user@intaleq.com",
|
||||
phone_number: "+201000000000",
|
||||
extra_description: `${tenantId}|${plan}` // Used to identify tenant in webhook
|
||||
}
|
||||
});
|
||||
const orderId = orderRes.data.id;
|
||||
|
||||
// 3. Payment Key Generation
|
||||
const keyRes = await axios.post(`${this.baseUrl}/acceptance/payment_keys`, {
|
||||
auth_token: authToken,
|
||||
amount_cents: amount * 100,
|
||||
expiration: 3600,
|
||||
order_id: orderId,
|
||||
billing_data: {
|
||||
apartment: "NA",
|
||||
email: "user@intaleq.com",
|
||||
floor: "NA",
|
||||
first_name: "Intaleq",
|
||||
street: "NA",
|
||||
building: "NA",
|
||||
phone_number: "+201000000000",
|
||||
shipping_method: "NA",
|
||||
postal_code: "NA",
|
||||
city: "NA",
|
||||
country: "NA",
|
||||
last_name: "User",
|
||||
state: "NA"
|
||||
},
|
||||
currency: "EGP",
|
||||
integration_id: this.configService.get('PAYMOB_INTEGRATION_ID') || 4556055,
|
||||
lock_order_when_paid: "false"
|
||||
});
|
||||
|
||||
return {
|
||||
paymentKey: keyRes.data.token,
|
||||
orderId: orderId.toString()
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`PayMob Payment Key failed: ${error.response?.data ? JSON.stringify(error.response.data) : error.message}`);
|
||||
throw new BadRequestException('Failed to initialize PayMob payment');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature from PayMob Webhook
|
||||
*/
|
||||
verifyHmac(payload: any, hmac: string): boolean {
|
||||
const secret = this.configService.get('PAYMOB_HMAC_SECRET');
|
||||
if (!secret) return false;
|
||||
|
||||
// Concat fields in specific order as per PayMob docs
|
||||
const {
|
||||
amount_cents,
|
||||
created_at,
|
||||
currency,
|
||||
error_occured,
|
||||
has_parent_transaction,
|
||||
id,
|
||||
integration_id,
|
||||
is_3d_secure,
|
||||
is_auth,
|
||||
is_capture,
|
||||
is_refunded,
|
||||
is_standalone_payment,
|
||||
is_voided,
|
||||
order,
|
||||
owner,
|
||||
pending,
|
||||
source_data_pan,
|
||||
source_data_sub_type,
|
||||
source_data_type,
|
||||
success
|
||||
} = payload;
|
||||
|
||||
const source_pan = source_data_pan || "";
|
||||
const source_sub_type = source_data_sub_type || "";
|
||||
const source_type = source_data_type || "";
|
||||
|
||||
const data = [
|
||||
amount_cents,
|
||||
created_at,
|
||||
currency,
|
||||
error_occured,
|
||||
has_parent_transaction,
|
||||
id,
|
||||
integration_id,
|
||||
is_3d_secure,
|
||||
is_auth,
|
||||
is_capture,
|
||||
is_refunded,
|
||||
is_standalone_payment,
|
||||
is_voided,
|
||||
order.id, // PayMob sends order as object
|
||||
owner,
|
||||
pending,
|
||||
source_pan,
|
||||
source_sub_type,
|
||||
source_type,
|
||||
success
|
||||
].join("");
|
||||
|
||||
const hash = crypto
|
||||
.createHmac('sha512', secret)
|
||||
.update(data)
|
||||
.digest('hex');
|
||||
|
||||
return hash === hmac;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
private transporter: nodemailer.Transporter;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const host = this.configService.get<string>('SMTP_HOST');
|
||||
const port = this.configService.get<number>('SMTP_PORT') || 587;
|
||||
const user = this.configService.get<string>('SMTP_USER');
|
||||
const pass = this.configService.get<string>('SMTP_PASS');
|
||||
|
||||
if (host && user && pass) {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465, // true for 465, false for other ports
|
||||
auth: {
|
||||
user,
|
||||
pass,
|
||||
},
|
||||
});
|
||||
this.logger.log(`📧 MailService initialized with host: ${host}`);
|
||||
} else {
|
||||
this.logger.warn('⚠️ SMTP settings not fully configured. MailService will run in mock mode.');
|
||||
}
|
||||
}
|
||||
|
||||
async sendInvoiceEmail(to: string, data: {
|
||||
tenantName: string,
|
||||
plan: string,
|
||||
amount: string,
|
||||
transactionId: string
|
||||
}): Promise<boolean> {
|
||||
const from = this.configService.get<string>('SMTP_FROM') || 'support@intaleqapp.com';
|
||||
|
||||
const htmlContent = `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: auto; border: 1px solid #eee; padding: 20px; border-radius: 10px;">
|
||||
<h2 style="color: #2563eb;">Intaleq Maps - Payment Successful</h2>
|
||||
<p>Hello <b>${data.tenantName}</b>,</p>
|
||||
<p>Thank you for subscribing to our <b>${data.plan}</b> plan. Your payment has been processed successfully.</p>
|
||||
|
||||
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 5px 0;"><b>Plan:</b> ${data.plan}</p>
|
||||
<p style="margin: 5px 0;"><b>Amount:</b> ${data.amount}</p>
|
||||
<p style="margin: 5px 0;"><b>Transaction ID:</b> ${data.transactionId}</p>
|
||||
<p style="margin: 5px 0;"><b>Date:</b> ${new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
|
||||
<p>You can now access all your premium features from your dashboard.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #64748b;">If you have any questions, contact us at ${from}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!this.transporter) {
|
||||
this.logger.warn(`[Mock Email] To: ${to}, Plan: ${data.plan}, Amount: ${data.amount}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.transporter.sendMail({
|
||||
from: `"Intaleq Support" <${from}>`,
|
||||
to,
|
||||
subject: `Invoice: ${data.plan} Subscription - Intaleq Maps`,
|
||||
html: htmlContent,
|
||||
});
|
||||
this.logger.log(`✅ Invoice email sent to ${to}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to send email: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,13 @@ export class RedisService implements OnModuleDestroy {
|
||||
@Inject('REDIS_CLIENT') private readonly redisClient: RedisClientType,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the raw Redis client for atomic operations (incr, etc.)
|
||||
*/
|
||||
getClient(): RedisClientType {
|
||||
return this.redisClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in Redis with optional TTL (Time To Live).
|
||||
* حفظ قيمة في ذاكرة Redis مع وقت انتهاء اختياري
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Entity, Column, Index } from 'typeorm';
|
||||
import { BasePlace } from './base-place.entity';
|
||||
|
||||
export enum CandidateStatus {
|
||||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED'
|
||||
}
|
||||
|
||||
export enum CountryCode {
|
||||
JORDAN = 'JORDAN',
|
||||
SYRIA = 'SYRIA',
|
||||
EGYPT = 'EGYPT'
|
||||
}
|
||||
|
||||
@Entity('map_candidates')
|
||||
export class MapCandidate extends BasePlace {
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CandidateStatus,
|
||||
default: CandidateStatus.PENDING
|
||||
})
|
||||
@Index()
|
||||
status: CandidateStatus;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
submittedBy: string; // Email or User UID
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
rejectionReason: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CountryCode,
|
||||
default: CountryCode.JORDAN
|
||||
})
|
||||
@Index()
|
||||
country: CountryCode;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: any; // For photos, extra contact info, etc.
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import { JordanResearchService } from './jordan-research.service';
|
||||
import { AdministrativeLinkingService } from './administrative-linking.service';
|
||||
import { NeighborhoodPoint } from './entities/neighborhood-point.entity';
|
||||
import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity';
|
||||
import { MapCandidate } from './entities/map-candidate.entity';
|
||||
import { MapRefinementService } from './map-refinement.service';
|
||||
import { MapRefinementController } from './map-refinement.controller';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import * as redisStore from 'cache-manager-redis-store';
|
||||
|
||||
@@ -27,7 +30,8 @@ import * as redisStore from 'cache-manager-redis-store';
|
||||
OsmPointWithArea,
|
||||
AdminBoundary,
|
||||
NeighborhoodPoint,
|
||||
NeighborhoodPolygon
|
||||
NeighborhoodPolygon,
|
||||
MapCandidate
|
||||
]),
|
||||
CacheModule.register({
|
||||
store: redisStore,
|
||||
@@ -36,14 +40,15 @@ import * as redisStore from 'cache-manager-redis-store';
|
||||
ttl: 3600, // 1 hour in seconds for version 1-2, or ms for v3
|
||||
}),
|
||||
],
|
||||
controllers: [GeocodingController],
|
||||
controllers: [GeocodingController, MapRefinementController],
|
||||
providers: [
|
||||
GeocodingService,
|
||||
GeocodingInitService,
|
||||
AdminBoundariesService,
|
||||
JordanResearchService,
|
||||
AdministrativeLinkingService
|
||||
AdministrativeLinkingService,
|
||||
MapRefinementService
|
||||
],
|
||||
exports: [GeocodingService],
|
||||
exports: [GeocodingService, MapRefinementService],
|
||||
})
|
||||
export class GeocodingModule {}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Controller, Get, Post, Patch, Body, Param, UseGuards, Req, Query } from '@nestjs/common';
|
||||
import { MapRefinementService } from './map-refinement.service';
|
||||
import { FirebaseAuthGuard } from '../auth/guards/firebase-auth.guard';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiHeader } from '@nestjs/swagger';
|
||||
import { CandidateStatus } from './entities/map-candidate.entity';
|
||||
|
||||
@ApiTags('map-refinement')
|
||||
@Controller('map-refinement')
|
||||
export class MapRefinementController {
|
||||
constructor(private readonly refinementService: MapRefinementService) {}
|
||||
|
||||
@Post('suggest')
|
||||
@ApiOperation({ summary: 'Suggest a new location for the map (Client App)' })
|
||||
@ApiHeader({ name: 'x-api-key', description: 'API Key for usage tracking' })
|
||||
async suggest(@Req() req: any, @Body() dto: any) {
|
||||
// Both Firebase Auth and API Key are allowed here
|
||||
const userId = req.user?.uid || req.tenant?.id || 'anonymous';
|
||||
return this.refinementService.suggestPlace(dto, userId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Get('candidates')
|
||||
@ApiOperation({ summary: 'List suggested places for review (Dashboard)' })
|
||||
async list(@Query('status') status?: CandidateStatus) {
|
||||
return this.refinementService.getCandidates(status);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Patch('candidates/:id/approve')
|
||||
@ApiOperation({ summary: 'Approve a candidate and move to production (Dashboard)' })
|
||||
async approve(@Param('id') id: string) {
|
||||
return this.refinementService.approveCandidate(parseInt(id, 10));
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Patch('candidates/:id/reject')
|
||||
@ApiOperation({ summary: 'Reject a candidate (Dashboard)' })
|
||||
async reject(@Param('id') id: string, @Body() body: { reason: string }) {
|
||||
return this.refinementService.rejectCandidate(parseInt(id, 10), body.reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { MapCandidate, CandidateStatus, CountryCode } from './entities/map-candidate.entity';
|
||||
import { PlaceJordan } from './entities/place-jordan.entity';
|
||||
import { PlaceSyria } from './entities/place-syria.entity';
|
||||
import { PlaceEgypt } from './entities/place-egypt.entity';
|
||||
|
||||
@Injectable()
|
||||
export class MapRefinementService {
|
||||
private readonly logger = new Logger(MapRefinementService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(MapCandidate)
|
||||
private candidateRepository: Repository<MapCandidate>,
|
||||
@InjectRepository(PlaceJordan)
|
||||
private jordanRepository: Repository<PlaceJordan>,
|
||||
@InjectRepository(PlaceSyria)
|
||||
private syriaRepository: Repository<PlaceSyria>,
|
||||
@InjectRepository(PlaceEgypt)
|
||||
private egyptRepository: Repository<PlaceEgypt>,
|
||||
) {}
|
||||
|
||||
async suggestPlace(dto: any, submittedBy: string): Promise<MapCandidate> {
|
||||
const candidate = this.candidateRepository.create({
|
||||
...dto,
|
||||
submittedBy,
|
||||
status: CandidateStatus.PENDING,
|
||||
location: {
|
||||
type: 'Point',
|
||||
coordinates: [parseFloat(dto.longitude), parseFloat(dto.latitude)],
|
||||
},
|
||||
});
|
||||
return this.candidateRepository.save(candidate) as unknown as Promise<MapCandidate>;
|
||||
}
|
||||
|
||||
async getCandidates(status?: CandidateStatus): Promise<MapCandidate[]> {
|
||||
return this.candidateRepository.find({
|
||||
where: status ? { status } : {},
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async approveCandidate(id: number): Promise<any> {
|
||||
const candidate = await this.candidateRepository.findOne({ where: { id } });
|
||||
if (!candidate) throw new NotFoundException('Candidate not found');
|
||||
|
||||
// Transfer to production table based on country
|
||||
const placeData = {
|
||||
latitude: candidate.latitude,
|
||||
longitude: candidate.longitude,
|
||||
name: candidate.name,
|
||||
name_ar: candidate.name_ar,
|
||||
name_en: candidate.name_en,
|
||||
category: candidate.category,
|
||||
address: candidate.address,
|
||||
description: candidate.description,
|
||||
location: candidate.location, // GeoJSON
|
||||
source: `suggested_by_${candidate.submittedBy}`,
|
||||
};
|
||||
|
||||
let repo: Repository<any>;
|
||||
switch (candidate.country) {
|
||||
case CountryCode.SYRIA: repo = this.syriaRepository; break;
|
||||
case CountryCode.EGYPT: repo = this.egyptRepository; break;
|
||||
default: repo = this.jordanRepository;
|
||||
}
|
||||
|
||||
const approvedPlace = repo.create(placeData);
|
||||
await repo.save(approvedPlace);
|
||||
|
||||
// Update candidate status
|
||||
candidate.status = CandidateStatus.APPROVED;
|
||||
await this.candidateRepository.save(candidate);
|
||||
|
||||
this.logger.log(`✅ Approved candidate ${id}: ${candidate.name} moved to production.`);
|
||||
return { success: true, place: approvedPlace };
|
||||
}
|
||||
|
||||
async rejectCandidate(id: number, reason: string): Promise<MapCandidate> {
|
||||
const candidate = await this.candidateRepository.findOne({ where: { id } });
|
||||
if (!candidate) throw new NotFoundException('Candidate not found');
|
||||
|
||||
candidate.status = CandidateStatus.REJECTED;
|
||||
candidate.rejectionReason = reason;
|
||||
return this.candidateRepository.save(candidate) as unknown as Promise<MapCandidate>;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NestFactory, Reflector } from '@nestjs/core';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import helmet from 'helmet';
|
||||
import * as express from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
@@ -10,9 +11,6 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// 1. Modern Security Headers (Prevention of XSS, Clickjacking, etc.)
|
||||
app.use(helmet());
|
||||
|
||||
// 2. Enable CORS with specific defaults (can be refined via Tenant settings)
|
||||
app.enableCors();
|
||||
|
||||
// 3. Global API prefix
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Controller, Get, Query, UseGuards, Req } from '@nestjs/common';
|
||||
import { UsageService } from './usage.service';
|
||||
import { FirebaseAuthGuard } from '../auth/guards/firebase-auth.guard';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('usage')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Controller('usage')
|
||||
export class UsageController {
|
||||
constructor(private usageService: UsageService) {}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({ summary: 'Get current month usage summary' })
|
||||
async getSummary(@Req() req: any) {
|
||||
const tenant = req.tenant;
|
||||
const summary = await this.usageService.getUsageSummary(tenant.id);
|
||||
|
||||
// Limits
|
||||
const limits = {
|
||||
FREE: 8000,
|
||||
PRO: 50000,
|
||||
ENTERPRISE: 1000000,
|
||||
};
|
||||
|
||||
const limit = limits[tenant.plan] || 8000;
|
||||
|
||||
return {
|
||||
...summary,
|
||||
limit,
|
||||
percentage: Math.min(((summary.monthlyUsage / limit) * 100), 100).toFixed(1),
|
||||
plan: tenant.plan,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
@ApiOperation({ summary: 'Get daily request volume history' })
|
||||
async getHistory(@Req() req: any, @Query('days') days: number = 30) {
|
||||
const tenantId = req.tenant.id;
|
||||
return this.usageService.getUsageHistory(tenantId, days);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('usage_logs')
|
||||
export class UsageLog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column()
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column()
|
||||
apiKeyId: string;
|
||||
|
||||
@Column()
|
||||
endpoint: string; // e.g., '/maps/style.json', '/geocoding/search'
|
||||
|
||||
@Column()
|
||||
method: string; // 'GET', 'POST', etc.
|
||||
|
||||
@Column({ type: 'int', default: 200 })
|
||||
statusCode: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
responseTimeMs: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
userAgent: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
ipAddress: string;
|
||||
|
||||
@Index()
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { UsageService } from './usage.service';
|
||||
import { TenantPlan } from '../auth/entities/tenant.entity';
|
||||
|
||||
// Quota Limits per Plan
|
||||
const QUOTA_LIMITS: Record<TenantPlan, number> = {
|
||||
[TenantPlan.FREE]: 8000,
|
||||
[TenantPlan.PRO]: 50000,
|
||||
[TenantPlan.ENTERPRISE]: 1000000,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UsageInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(UsageInterceptor.name);
|
||||
constructor(private usageService: UsageService) { }
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const tenant = request.tenant;
|
||||
const apiKey = request.apiKey; // Set by ApiKeyGuard
|
||||
|
||||
// 1. Quota Enforcement (Skip for Management requests / internal)
|
||||
if (tenant && apiKey && !request.url.includes('/auth/management')) {
|
||||
this.logger.debug(`📊 Monitoring request for Tenant: ${tenant.id}`);
|
||||
const plan = tenant.plan || TenantPlan.FREE;
|
||||
const limit = QUOTA_LIMITS[plan];
|
||||
|
||||
const { allowed, used } = await this.usageService.checkQuota(tenant.id, limit);
|
||||
|
||||
if (!allowed) {
|
||||
throw new HttpException({
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: 'Monthly API usage quota exceeded',
|
||||
used,
|
||||
limit,
|
||||
upgrade_url: 'https://map-dashbord.intaleqapp.com/#billing'
|
||||
}, HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
// 2. Async Recording
|
||||
if (tenant && apiKey) {
|
||||
const response = context.switchToHttp().getResponse();
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
|
||||
this.logger.log(`📈 Usage Recorded: ${request.method} ${request.url.split('?')[0]} for Tenant ${tenant.id}`);
|
||||
this.usageService.recordRequest({
|
||||
tenantId: tenant.id,
|
||||
apiKeyId: apiKey.id,
|
||||
endpoint: request.url.split('?')[0],
|
||||
method: request.method,
|
||||
statusCode: response.statusCode,
|
||||
responseTimeMs,
|
||||
userAgent: request.headers['user-agent'],
|
||||
ipAddress: request.ip || request.headers['x-forwarded-for'],
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UsageLog } from './usage.entity';
|
||||
import { UsageService } from './usage.service';
|
||||
import { UsageController } from './usage.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UsageLog]),
|
||||
],
|
||||
providers: [UsageService],
|
||||
controllers: [UsageController],
|
||||
exports: [UsageService],
|
||||
})
|
||||
export class UsageModule {}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UsageLog } from './usage.entity';
|
||||
import { RedisService } from '../common/redis.service';
|
||||
|
||||
@Injectable()
|
||||
export class UsageService {
|
||||
private readonly logger = new Logger(UsageService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UsageLog)
|
||||
private usageRepository: Repository<UsageLog>,
|
||||
private redisService: RedisService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Record a request in both PostgreSQL (for history) and Redis (for instant quota)
|
||||
*/
|
||||
async recordRequest(data: {
|
||||
tenantId: string;
|
||||
apiKeyId: string;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
statusCode: number;
|
||||
responseTimeMs: number;
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
}) {
|
||||
// 1. Log to PostgreSQL (Async)
|
||||
this.usageRepository.save(data).catch((err) => {
|
||||
this.logger.error(`Failed to save usage log to DB: ${err.message}`);
|
||||
});
|
||||
|
||||
// 2. Increment Redis Counter for the current month
|
||||
const monthKey = this.getMonthlyKey(data.tenantId);
|
||||
try {
|
||||
await this.redisService.getClient().incr(monthKey);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to increment Redis usage counter: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current month usage from Redis
|
||||
*/
|
||||
async getMonthlyUsage(tenantId: string): Promise<number> {
|
||||
const key = this.getMonthlyKey(tenantId);
|
||||
const val = await this.redisService.getClient().get(key);
|
||||
return val ? parseInt(val, 10) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tenant has exceeded their monthly quota
|
||||
*/
|
||||
async checkQuota(tenantId: string, limit: number): Promise<{ allowed: boolean; used: number }> {
|
||||
const used = await this.getMonthlyUsage(tenantId);
|
||||
return {
|
||||
allowed: used < limit,
|
||||
used,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage history for charts
|
||||
*/
|
||||
async getUsageHistory(tenantId: string, days: number = 30) {
|
||||
return this.usageRepository
|
||||
.createQueryBuilder('usage')
|
||||
.select("DATE_TRUNC('day', usage.createdAt)", 'date')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('usage.tenantId = :tenantId', { tenantId })
|
||||
.andWhere("usage.createdAt >= NOW() - (:days || ' days')::INTERVAL", { days: days.toString() })
|
||||
.groupBy("DATE_TRUNC('day', usage.createdAt)")
|
||||
.orderBy('date', 'ASC')
|
||||
.getRawMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time summary for the dashboard
|
||||
*/
|
||||
async getUsageSummary(tenantId: string) {
|
||||
const monthlyUsage = await this.getMonthlyUsage(tenantId);
|
||||
|
||||
// Get daily stats and performance metrics
|
||||
const stats = await this.usageRepository
|
||||
.createQueryBuilder('usage')
|
||||
.select('COUNT(*)', 'totalToday')
|
||||
.addSelect('AVG(usage.responseTimeMs)', 'avgLatency')
|
||||
.addSelect('COUNT(CASE WHEN usage.statusCode >= 200 AND usage.statusCode < 300 THEN 1 END)', 'successCount')
|
||||
.where('usage.tenantId = :tenantId', { tenantId })
|
||||
.andWhere('usage.createdAt >= CURRENT_DATE')
|
||||
.getRawOne();
|
||||
|
||||
const totalToday = parseInt(stats.totalToday || '0', 10);
|
||||
const avgLatency = Math.round(parseFloat(stats.avgLatency || '0'));
|
||||
const successRate = totalToday > 0
|
||||
? Math.round((parseInt(stats.successCount || '0', 10) / totalToday) * 100)
|
||||
: 100;
|
||||
|
||||
return {
|
||||
monthlyUsage,
|
||||
totalToday,
|
||||
avgLatency,
|
||||
successRate,
|
||||
lastUpdated: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
private getMonthlyKey(tenantId: string): string {
|
||||
const now = new Date();
|
||||
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
return `usage:${tenantId}:${yearMonth}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user