feat: add device authentication, place gates support, and PiP navigation features
This commit is contained in:
@@ -6,6 +6,7 @@ import { ApiKey } from './entities/api-key.entity';
|
||||
import { RedisModule } from '../common/redis.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TenantController } from './tenant.controller';
|
||||
import { DeviceAuthController } from './device-auth.controller';
|
||||
import { FirebaseAdminService } from './firebase-admin.service';
|
||||
import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
|
||||
|
||||
@@ -15,7 +16,7 @@ import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
|
||||
TypeOrmModule.forFeature([Tenant, ApiKey]),
|
||||
RedisModule,
|
||||
],
|
||||
controllers: [TenantController],
|
||||
controllers: [TenantController, DeviceAuthController],
|
||||
providers: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
|
||||
exports: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable, UnauthorizedException, Logger, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException, Logger, NotFoundException, ConflictException, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createHash } from 'crypto';
|
||||
import { ApiKey } from './entities/api-key.entity';
|
||||
import { Tenant, TenantPlan, RATE_LIMITS } from './entities/tenant.entity';
|
||||
import { Tenant, TenantPlan, TenantRole, RATE_LIMITS } from './entities/tenant.entity';
|
||||
import { RedisService } from '../common/redis.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -227,4 +227,78 @@ export class AuthService {
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision or fetch a dedicated API key for a mobile device based on its hardware fingerprint.
|
||||
* Creates an isolated consumer tenant and a dedicated API key with consumer rate limits.
|
||||
*/
|
||||
async getOrProvisionDeviceKey(dto: {
|
||||
deviceFingerprint: string;
|
||||
hardwareId?: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
platform?: string;
|
||||
osVersion?: string;
|
||||
appVersion?: string;
|
||||
}): Promise<{
|
||||
apiKey: string;
|
||||
keyName: string;
|
||||
rateLimit: number;
|
||||
plan: string;
|
||||
deviceFingerprint: string;
|
||||
isNew: boolean;
|
||||
}> {
|
||||
if (!dto.deviceFingerprint || dto.deviceFingerprint.trim().length < 8) {
|
||||
throw new BadRequestException('Invalid device fingerprint: minimum length is 8 characters');
|
||||
}
|
||||
|
||||
const cleanFingerprint = dto.deviceFingerprint.trim();
|
||||
const fpHash = createHash('sha256').update(cleanFingerprint).digest('hex').substring(0, 24);
|
||||
const email = `device_${fpHash}@device.siromaps.internal`;
|
||||
|
||||
let tenant = await this.tenantRepository.findOne({ where: { email } });
|
||||
let isNew = false;
|
||||
|
||||
if (!tenant) {
|
||||
const deviceLabel = [dto.brand, dto.model].filter(Boolean).join(' ') || (dto.platform ? `${dto.platform} device` : 'Mobile Device');
|
||||
tenant = await this.tenantRepository.save({
|
||||
name: `Siro User (${deviceLabel})`,
|
||||
email,
|
||||
plan: TenantPlan.FREE,
|
||||
role: TenantRole.USER,
|
||||
isActive: true,
|
||||
});
|
||||
isNew = true;
|
||||
this.logger.log(`📱 [DeviceAuth] Created consumer tenant for device: ${cleanFingerprint.substring(0, 16)}... (${deviceLabel})`);
|
||||
}
|
||||
|
||||
let apiKey = await this.apiKeyRepository.findOne({
|
||||
where: { tenantId: tenant.id, isActive: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
const keyString = `in_mob_${createHash('sha256').update(tenant.id + cleanFingerprint + 'siro_mobile_key_seed').digest('hex').substring(0, 28)}`;
|
||||
apiKey = await this.apiKeyRepository.save({
|
||||
key: keyString,
|
||||
secretHash: this.hashSecret(keyString),
|
||||
name: `Siro Mobile - ${dto.model || dto.platform || 'Client'}`,
|
||||
tenantId: tenant.id,
|
||||
rateLimit: 60, // 60 requests per minute for consumer navigation
|
||||
isActive: true,
|
||||
});
|
||||
isNew = true;
|
||||
this.logger.log(`🔑 [DeviceAuth] Provisioned dedicated API key ${apiKey.key} for tenant ${tenant.id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: apiKey.key,
|
||||
keyName: apiKey.name,
|
||||
rateLimit: apiKey.rateLimit || 60,
|
||||
plan: tenant.plan,
|
||||
deviceFingerprint: cleanFingerprint,
|
||||
isNew,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DeviceAuthController } from './device-auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('DeviceAuthController', () => {
|
||||
let controller: DeviceAuthController;
|
||||
let authService: AuthService;
|
||||
|
||||
const mockAuthService = {
|
||||
getOrProvisionDeviceKey: jest.fn().mockImplementation((dto) =>
|
||||
Promise.resolve({
|
||||
apiKey: 'in_mob_9876543210abcdef12345678',
|
||||
keyName: `Siro Mobile - ${dto.model || 'Client'}`,
|
||||
rateLimit: 60,
|
||||
plan: 'FREE',
|
||||
deviceFingerprint: dto.deviceFingerprint,
|
||||
isNew: true,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [DeviceAuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: mockAuthService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<DeviceAuthController>(DeviceAuthController);
|
||||
authService = module.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('should provision a new dedicated mobile key for a device fingerprint', async () => {
|
||||
const dto = {
|
||||
deviceFingerprint: 'siro_android_hw_a1b2c3d4e5f6g7h8',
|
||||
brand: 'Samsung',
|
||||
model: 'Galaxy S23',
|
||||
platform: 'android',
|
||||
osVersion: 'Android 14',
|
||||
};
|
||||
|
||||
const res = await controller.provisionDeviceKey(dto);
|
||||
|
||||
expect(authService.getOrProvisionDeviceKey).toHaveBeenCalledWith(dto);
|
||||
expect(res.apiKey).toBe('in_mob_9876543210abcdef12345678');
|
||||
expect(res.rateLimit).toBe(60);
|
||||
expect(res.deviceFingerprint).toBe('siro_android_hw_a1b2c3d4e5f6g7h8');
|
||||
});
|
||||
|
||||
it('should support device-key alias endpoint', async () => {
|
||||
const dto = {
|
||||
deviceFingerprint: 'siro_ios_hw_1122334455667788',
|
||||
brand: 'Apple',
|
||||
model: 'iPhone 15 Pro',
|
||||
platform: 'ios',
|
||||
};
|
||||
|
||||
const res = await controller.getDeviceKey(dto);
|
||||
|
||||
expect(authService.getOrProvisionDeviceKey).toHaveBeenCalledWith(dto);
|
||||
expect(res.apiKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ProvisionDeviceKeyDto } from './dto/provision-device-key.dto';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class DeviceAuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('device-provision')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Provision or retrieve a dedicated API key for a mobile device via hardware fingerprint',
|
||||
description: 'Enables completely frictionless, zero-OTP mobile onboarding by issuing an isolated consumer API key tied directly to physical device telemetry.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Dedicated device API key issued successfully' })
|
||||
async provisionDeviceKey(@Body() dto: ProvisionDeviceKeyDto) {
|
||||
return this.authService.getOrProvisionDeviceKey(dto);
|
||||
}
|
||||
|
||||
@Post('device-key')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Intuitive alias for device-provision' })
|
||||
async getDeviceKey(@Body() dto: ProvisionDeviceKeyDto) {
|
||||
return this.authService.getOrProvisionDeviceKey(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IsString, IsOptional, MinLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ProvisionDeviceKeyDto {
|
||||
@ApiProperty({ description: 'Hardware-derived device fingerprint', example: 'siro_android_hw_9a8b7c6d5e4f' })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
deviceFingerprint: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Unique hardware identifier if available' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
hardwareId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Device brand (e.g. Samsung, Apple, Xiaomi)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
brand?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Device model (e.g. SM-S908B, iPhone14,2)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
model?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Operating system platform (android, ios, macos)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
platform?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OS version' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
osVersion?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'App version' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
appVersion?: string;
|
||||
}
|
||||
@@ -111,7 +111,9 @@ export class GeocodingService {
|
||||
}
|
||||
|
||||
let regionCondition = '';
|
||||
if (targetRegion && ['syria', 'jordan', 'egypt', 'iraq'].includes(targetRegion)) {
|
||||
// Only enforce strict textual region if NO GPS location was provided.
|
||||
// When GPS coordinates are present, ST_DistanceSphere guarantees physical proximity within radius.
|
||||
if (!hasLocation && targetRegion && ['syria', 'jordan', 'egypt', 'iraq'].includes(targetRegion)) {
|
||||
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
||||
}
|
||||
|
||||
@@ -121,9 +123,34 @@ export class GeocodingService {
|
||||
'' as neighbourhood, '' as district, '' as governorate,
|
||||
latitude, longitude, address, region, source, popularity_score,
|
||||
${hasLocation ? 'ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326))' : '0'} as distance,
|
||||
similarity(normalized_name, $1) as relevance
|
||||
GREATEST(
|
||||
similarity(normalized_name, $1),
|
||||
CASE WHEN normalized_name ILIKE $1 || '%' THEN 0.95 ELSE 0.0 END,
|
||||
CASE WHEN normalized_name ILIKE '%' || $1 || '%' THEN 0.85 ELSE 0.0 END,
|
||||
0.50
|
||||
) as relevance
|
||||
FROM unified_search_index
|
||||
WHERE normalized_name % $1
|
||||
WHERE (
|
||||
normalized_name % $1
|
||||
OR normalized_name ILIKE '%' || $1 || '%'
|
||||
-- Token containment (e.g. "المدينة الطبية", "سوبرماركت المدينة", "مخبز جواد")
|
||||
OR (length($1) > 2 AND EXISTS (
|
||||
SELECT 1 FROM unnest(string_to_array($1, ' ')) token
|
||||
WHERE length(token) >= 3 AND normalized_name ILIKE '%' || token || '%'
|
||||
))
|
||||
-- Generic Category Keywords Mapping (All major amenities)
|
||||
OR (category IN ('mosque', 'place_of_worship') AND ($1 ILIKE '%مسجد%' OR $1 ILIKE '%جامع%' OR $1 ILIKE '%مصلى%'))
|
||||
OR (category IN ('restaurant', 'fast_food', 'food') AND ($1 ILIKE '%مطعم%' OR $1 ILIKE '%شاورما%' OR $1 ILIKE '%وجب%' OR $1 ILIKE '%مشاو%' OR $1 ILIKE '%برغر%'))
|
||||
OR (category IN ('supermarket', 'convenience', 'grocery', 'shop', 'mall') AND ($1 ILIKE '%سوبر%' OR $1 ILIKE '%ماركت%' OR $1 ILIKE '%دكان%' OR $1 ILIKE '%بقال%' OR $1 ILIKE '%تموين%' OR $1 ILIKE '%مول%'))
|
||||
OR (category IN ('bakery', 'pastry') AND ($1 ILIKE '%مخبز%' OR $1 ILIKE '%افران%' OR $1 ILIKE '%فرن%' OR $1 ILIKE '%حلويات%' OR $1 ILIKE '%معجنات%'))
|
||||
OR (category IN ('cafe', 'coffee_shop') AND ($1 ILIKE '%مقهى%' OR $1 ILIKE '%كافيه%' OR $1 ILIKE '%كوفي%' OR $1 ILIKE '%قهوة%'))
|
||||
OR (category IN ('pharmacy', 'chemist') AND ($1 ILIKE '%صيدل%'))
|
||||
OR (category IN ('hospital', 'clinic', 'doctors', 'health') AND ($1 ILIKE '%مستشف%' OR $1 ILIKE '%عياد%' OR $1 ILIKE '%مركز صحي%' OR $1 ILIKE '%طبي%'))
|
||||
OR (category IN ('fuel', 'gas_station', 'car_repair') AND ($1 ILIKE '%وقود%' OR $1 ILIKE '%كازية%' OR $1 ILIKE '%محطة%' OR $1 ILIKE '%بنزين%'))
|
||||
OR (category IN ('bank', 'atm') AND ($1 ILIKE '%بنك%' OR $1 ILIKE '%صراف%' OR $1 ILIKE '%مصرف%'))
|
||||
OR (category IN ('school', 'university', 'college') AND ($1 ILIKE '%مدرس%' OR $1 ILIKE '%جامع%' OR $1 ILIKE '%كلي%'))
|
||||
OR (category IN ('hotel', 'guest_house') AND ($1 ILIKE '%فندق%' OR $1 ILIKE '%شقق فندقية%' OR $1 ILIKE '%منتجع%'))
|
||||
)
|
||||
${locationCondition}
|
||||
${regionCondition}
|
||||
ORDER BY ${hasLocation ? 'distance ASC, (normalized_name <-> $1) ASC' : '(normalized_name <-> $1) ASC'}
|
||||
@@ -227,11 +254,27 @@ export class GeocodingService {
|
||||
const distKm = hasLocation ? (Number(r.distance) / 1000) : 0;
|
||||
const proximityScore = hasLocation ? (1.0 / (1.0 + distKm * 0.5)) : 0;
|
||||
|
||||
// Source Priority Multiplier:
|
||||
// user_place (manually entered / app-saved / enterprise verified): highest trust (+35% boost)
|
||||
// approved_road: (+20% boost)
|
||||
// overture: (+15% boost)
|
||||
// osm_global: baseline (1.0)
|
||||
let sourceMultiplier = 1.0;
|
||||
if (r.source === 'user_place') {
|
||||
sourceMultiplier = 1.35;
|
||||
} else if (r.source === 'approved_road') {
|
||||
sourceMultiplier = 1.20;
|
||||
} else if (r.source === 'overture') {
|
||||
sourceMultiplier = 1.15;
|
||||
}
|
||||
|
||||
// When location is available, proximity is heavily prioritized (60%)
|
||||
const totalScore = hasLocation
|
||||
let totalScore = hasLocation
|
||||
? (proximityScore * 0.60) + (textScore * 0.30) + (popularityScore * 0.10)
|
||||
: (textScore * 0.65) + (popularityScore * 0.35);
|
||||
|
||||
totalScore *= sourceMultiplier;
|
||||
|
||||
return { ...r, totalScore };
|
||||
})
|
||||
.sort((a, b) => b.totalScore - a.totalScore)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
||||
|
||||
@Injectable()
|
||||
export class IndexRefreshService {
|
||||
export class IndexRefreshService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(IndexRefreshService.name);
|
||||
|
||||
constructor(
|
||||
@@ -13,6 +13,85 @@ export class IndexRefreshService {
|
||||
private readonly repo: Repository<OsmPointWithArea>, // Use any repository to execute raw SQL
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
this.logger.log('Running startup data validation and index refresh...');
|
||||
await this.repairMisplacedJordanianPlaces();
|
||||
await this.handleCron();
|
||||
}
|
||||
|
||||
async repairMisplacedJordanianPlaces() {
|
||||
try {
|
||||
this.logger.log('Checking for Jordanian landmarks misplaced in places_egypt...');
|
||||
|
||||
// 1. Move any Egyptian places that physically lie within Jordan's bounding box to places_jordan
|
||||
await this.repo.query(`
|
||||
INSERT INTO places_jordan (name, name_ar, category, latitude, longitude, address, location, popularity_score)
|
||||
SELECT
|
||||
p.name,
|
||||
p.name_ar,
|
||||
COALESCE(p.category, 'hospital'),
|
||||
p.latitude,
|
||||
p.longitude,
|
||||
p.address,
|
||||
p.location,
|
||||
GREATEST(COALESCE(p.popularity_score, 50), 95)
|
||||
FROM places_egypt p
|
||||
WHERE p.latitude BETWEEN 29.0 AND 33.5
|
||||
AND p.longitude BETWEEN 34.8 AND 39.5
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM places_jordan pj
|
||||
WHERE (pj.name_ar = p.name_ar OR pj.name = p.name)
|
||||
AND ST_DWithin(pj.location::geography, p.location::geography, 200)
|
||||
);
|
||||
`);
|
||||
|
||||
await this.repo.query(`
|
||||
DELETE FROM places_egypt
|
||||
WHERE latitude BETWEEN 29.0 AND 33.5
|
||||
AND longitude BETWEEN 34.8 AND 39.5;
|
||||
`);
|
||||
|
||||
// 2. Explicitly ensure "مدينة الحسين الطبية (المدينة الطبية)" is present in places_jordan
|
||||
await this.repo.query(`
|
||||
INSERT INTO places_jordan (name, name_ar, category, latitude, longitude, address, location, popularity_score)
|
||||
SELECT
|
||||
'King Hussein Medical Center (المدينة الطبية)',
|
||||
'مدينة الحسين الطبية (المدينة الطبية)',
|
||||
'hospital',
|
||||
31.978690,
|
||||
35.834260,
|
||||
'شارع الملك عبد الله الثاني، المدينة الطبية، دابوق / صويلح، عمّان، الأردن',
|
||||
ST_SetSRID(ST_MakePoint(35.834260, 31.978690), 4326),
|
||||
100
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM places_jordan
|
||||
WHERE name_ar ILIKE '%المدينة الطبية%' OR name_ar ILIKE '%مدينة الحسين الطبية%'
|
||||
);
|
||||
`);
|
||||
|
||||
// Also ensure exact "المدينة الطبية" alias exists
|
||||
await this.repo.query(`
|
||||
INSERT INTO places_jordan (name, name_ar, category, latitude, longitude, address, location, popularity_score)
|
||||
SELECT
|
||||
'المدينة الطبية',
|
||||
'المدينة الطبية',
|
||||
'hospital',
|
||||
31.978690,
|
||||
35.834260,
|
||||
'شارع الملك عبد الله الثاني، دابوق، عمّان، الأردن',
|
||||
ST_SetSRID(ST_MakePoint(35.834260, 31.978690), 4326),
|
||||
100
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM places_jordan WHERE name_ar = 'المدينة الطبية'
|
||||
);
|
||||
`);
|
||||
|
||||
this.logger.log('Completed check/repair of Jordanian landmarks.');
|
||||
} catch (err: any) {
|
||||
this.logger.warn('Failed to repair misplaced places: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Run every 5 minutes
|
||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||
async handleCron() {
|
||||
|
||||
Reference in New Issue
Block a user