2026-04-15-4

This commit is contained in:
Hamza-Ayed
2026-04-15 19:56:49 +03:00
parent 9cd1ac4c1d
commit 3d61362602
54 changed files with 12659 additions and 436 deletions
+15 -2
View File
@@ -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 {}
+4 -2
View File
@@ -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(
+57 -10
View File
@@ -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;
}
}
+19 -1
View File
@@ -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');
}
}
}
+18 -13
View File
@@ -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;
}
}
+101
View File
@@ -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);
}
}
+19
View File
@@ -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 {}
+142
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { MailService } from './mail.service';
@Global()
@Module({
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
+78
View File
@@ -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;
}
}
}
+7
View File
@@ -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.
}
+9 -4
View File
@@ -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>;
}
}
+2 -4
View File
@@ -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
+42
View File
@@ -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);
}
}
+43
View File
@@ -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;
}
+74
View File
@@ -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'],
});
}
}),
);
}
}
+15
View File
@@ -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 {}
+115
View File
@@ -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}`;
}
}