feat: add Jordan 3D topographic platform, Siro Map cinematic orbit studio, sun-shadow simulation, and sovereign intelligence security gating

This commit is contained in:
Hamza-Ayed
2026-09-19 12:34:26 +03:00
parent 91e9e71ce4
commit 0bf13824c9
180 changed files with 245224 additions and 704 deletions
+4
View File
@@ -13,6 +13,8 @@ import { MailModule } from './common/mail.module';
import { WeatherModule } from './weather/weather.module';
import { TacticalModule } from './tactical/tactical.module';
import { TelemetryModule } from './telemetry/telemetry.module';
import { HeritageModule } from './heritage/heritage.module';
import { CommunityModule } from './community/community.module';
import { UsageInterceptor } from './usage/usage.interceptor';
@Module({
@@ -44,6 +46,8 @@ import { UsageInterceptor } from './usage/usage.interceptor';
WeatherModule,
TacticalModule,
TelemetryModule,
HeritageModule,
CommunityModule,
],
controllers: [],
providers: [
@@ -0,0 +1,30 @@
import { Controller, Post, Get, Body, Param, Query, UseGuards } from '@nestjs/common';
import { CommunityService } from './community.service';
import { SubmitContributionDto } from './dto/submit-contribution.dto';
import { ApiKeyGuard } from '../common/guards/api-key.guard';
@Controller('v1/community')
@UseGuards(ApiKeyGuard)
export class CommunityController {
constructor(private readonly communityService: CommunityService) {}
@Post('contribute')
async submitContribution(@Body() dto: SubmitContributionDto) {
return this.communityService.submitContribution(dto);
}
@Get('profile/:userId')
async getProfile(@Param('userId') userId: string, @Query('name') displayName?: string) {
return this.communityService.getOrCreateProfile(userId, displayName);
}
@Get('leaderboard')
async getLeaderboard(@Query('limit') limit?: string) {
return this.communityService.getLeaderboard(limit ? parseInt(limit, 10) : 20);
}
@Get('badges')
async getBadges() {
return this.communityService.getBadges();
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GuideProfile } from './entities/guide-profile.entity';
import { Contribution } from './entities/contribution.entity';
import { Badge } from './entities/badge.entity';
import { GamificationService } from './gamification.service';
import { CommunityService } from './community.service';
import { CommunityController } from './community.controller';
import { ModerationController } from './moderation.controller';
@Module({
imports: [
TypeOrmModule.forFeature([GuideProfile, Contribution, Badge]),
],
controllers: [CommunityController, ModerationController],
providers: [GamificationService, CommunityService],
exports: [CommunityService, GamificationService],
})
export class CommunityModule {}
+275
View File
@@ -0,0 +1,275 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { GuideProfile } from './entities/guide-profile.entity';
import { Contribution } from './entities/contribution.entity';
import { Badge } from './entities/badge.entity';
import { GamificationService } from './gamification.service';
import { SubmitContributionDto } from './dto/submit-contribution.dto';
import { ReviewContributionDto } from './dto/review-contribution.dto';
export { SubmitContributionDto, ReviewContributionDto };
@Injectable()
export class CommunityService {
constructor(
@InjectRepository(GuideProfile)
private readonly guideRepo: Repository<GuideProfile>,
@InjectRepository(Contribution)
private readonly contributionRepo: Repository<Contribution>,
@InjectRepository(Badge)
private readonly badgeRepo: Repository<Badge>,
private readonly gamificationService: GamificationService,
private readonly dataSource: DataSource,
) {}
/**
* Get or create a Local Guide profile by userId
*/
async getOrCreateProfile(userId: string, displayName?: string): Promise<GuideProfile> {
let profile = await this.guideRepo.findOne({ where: { user_id: userId } });
if (!profile) {
profile = this.guideRepo.create({
user_id: userId,
display_name: displayName || `مرشد_${userId.substring(0, 6)}`,
reputation_score: 10,
total_points: 0,
guide_level: 1,
badge_title: 'مرشد مبتدئ',
});
await this.guideRepo.save(profile);
}
return profile;
}
/**
* Submit a new contribution into the moderation queue
*/
async submitContribution(dto: SubmitContributionDto) {
const guide = await this.getOrCreateProfile(dto.userId, dto.displayName);
const result = await this.dataSource.query(
`INSERT INTO community.contributions
(guide_id, contribution_type, target_table, target_id, place_name, suggested_data, suggested_geom, proof_image_url, notes, status)
VALUES
($1, $2, $3, $4, $5, $6, ST_SetSRID(ST_MakePoint($7, $8), 4326), $9, $10, 'PENDING')
RETURNING id, status, created_at;`,
[
guide.id,
dto.contributionType,
dto.targetTable || 'heritage.landmarks',
dto.targetId || null,
dto.placeName || null,
JSON.stringify(dto.suggestedData || {}),
dto.lng,
dto.lat,
dto.proofImageUrl || null,
dto.notes || null,
],
);
return {
success: true,
message: 'تم إرسال المساهمة بنجاح وهي قيد المراجعة والتدقيق الإداري.',
contributionId: result[0].id,
status: result[0].status,
};
}
/**
* Get pending contributions for manual review
*/
async getPendingContributions(limit = 50) {
const rows = await this.dataSource.query(
`SELECT
c.id, c.contribution_type, c.target_table, c.target_id, c.place_name,
c.suggested_data, c.proof_image_url, c.notes, c.status, c.created_at,
ST_X(c.suggested_geom) AS lng,
ST_Y(c.suggested_geom) AS lat,
g.id AS guide_id, g.display_name AS guide_name, g.total_points AS guide_points, g.guide_level
FROM community.contributions c
LEFT JOIN community.guides_profiles g ON g.id = c.guide_id
WHERE c.status = 'PENDING'
ORDER BY c.created_at ASC
LIMIT $1;`,
[limit],
);
return rows.map((r: any) => ({
id: r.id,
contribution_type: r.contribution_type,
target_table: r.target_table,
target_id: r.target_id,
place_name: r.place_name,
suggested_data: r.suggested_data,
coordinates: { lat: parseFloat(r.lat), lng: parseFloat(r.lng) },
proof_image_url: r.proof_image_url,
notes: r.notes,
created_at: r.created_at,
guide: {
id: r.guide_id,
display_name: r.guide_name,
total_points: r.guide_points,
guide_level: r.guide_level,
},
}));
}
/**
* Approve or reject a contribution in the moderation queue.
* Wrapped in a transaction for atomicity — all or nothing.
*/
async reviewContribution(dto: ReviewContributionDto) {
const contribution = await this.contributionRepo.findOne({
where: { id: dto.contributionId },
relations: ['guide'],
});
if (!contribution) {
throw new NotFoundException(`Contribution #${dto.contributionId} not found.`);
}
if (contribution.status !== 'PENDING') {
throw new BadRequestException(`Contribution has already been reviewed (${contribution.status}).`);
}
return this.dataSource.transaction(async (manager) => {
if (dto.action === 'APPROVE') {
const points = this.gamificationService.getPointsForContribution(contribution.contribution_type);
// 1. Update contribution status
contribution.status = 'APPROVED';
contribution.reviewed_by = dto.reviewerName;
contribution.reviewer_notes = dto.reviewerNotes || 'تم الاعتماد بنجاح';
contribution.points_awarded = points;
contribution.reviewed_at = new Date();
await manager.save(contribution);
// 2. Apply the change to heritage.landmarks based on contribution type
if (contribution.target_table === 'heritage.landmarks') {
if (contribution.contribution_type === 'CONFIRM_GATE' && contribution.target_id) {
await manager.query(
`UPDATE heritage.landmarks
SET access_gate_geom = (SELECT suggested_geom FROM community.contributions WHERE id = $1),
updated_at = CURRENT_TIMESTAMP
WHERE id = $2;`,
[contribution.id, contribution.target_id],
);
} else if (contribution.contribution_type === 'ADD_PARKING' && contribution.target_id) {
await manager.query(
`UPDATE heritage.landmarks
SET parking_geom = (SELECT suggested_geom FROM community.contributions WHERE id = $1),
updated_at = CURRENT_TIMESTAMP
WHERE id = $2;`,
[contribution.id, contribution.target_id],
);
} else if (contribution.contribution_type === 'ADD_PLACE') {
// Insert a new landmark from the contribution data
const data = contribution.suggested_data || {};
await manager.query(
`INSERT INTO heritage.landmarks
(slug, name_ar, name_en, category, era, narrative_ar, centroid_geom, governorate, is_verified, metadata)
VALUES
($1, $2, $3, $4, $5, $6,
(SELECT suggested_geom FROM community.contributions WHERE id = $7),
$8, false, $9);`,
[
data.slug || `contrib-${contribution.id}`,
contribution.place_name || data.name_ar || 'معلم بدون اسم',
data.name_en || null,
data.category || 'مساهمة_مجتمعية',
data.era || null,
data.narrative_ar || contribution.notes || null,
contribution.id,
data.governorate || null,
JSON.stringify({ source: 'community', contribution_id: contribution.id }),
],
);
} else if (contribution.contribution_type === 'ORAL_HISTORY' && contribution.target_id) {
// Append oral history narrative to existing landmark
const data = contribution.suggested_data || {};
await manager.query(
`UPDATE heritage.landmarks
SET narrative_ar = COALESCE(narrative_ar, '') || E'\n---\n' || $1,
audio_url = COALESCE($2, audio_url),
updated_at = CURRENT_TIMESTAMP
WHERE id = $3;`,
[
data.narrative_text || contribution.notes || '',
data.audio_url || null,
contribution.target_id,
],
);
} else if (contribution.contribution_type === 'CORRECT_STREET') {
// Street corrections are logged but not auto-applied to OSM data.
// They feed the manual road refinement pipeline.
}
}
// 3. Update guide points & level
if (contribution.guide) {
const guide = contribution.guide;
guide.total_points += points;
guide.approved_contributions_count += 1;
const levelInfo = this.gamificationService.computeLevelInfo(guide.total_points);
guide.guide_level = levelInfo.level;
guide.badge_title = levelInfo.badgeTitle;
await manager.save(guide);
}
return {
success: true,
action: 'APPROVED',
pointsAwarded: points,
message: `تم اعتماد المساهمة ومنح ${points} نقطة للمرشد بنجاح.`,
};
} else {
// REJECT
contribution.status = 'REJECTED';
contribution.reviewed_by = dto.reviewerName;
contribution.reviewer_notes = dto.reviewerNotes || 'المساهمة غير متوافقة مع معايير التدقيق';
contribution.points_awarded = 0;
contribution.reviewed_at = new Date();
await manager.save(contribution);
if (contribution.guide) {
const guide = contribution.guide;
guide.rejected_contributions_count += 1;
await manager.save(guide);
}
return {
success: true,
action: 'REJECTED',
message: 'تم رفض المساهمة وتسجيل الملاحظات الإدارية.',
};
}
});
}
/**
* Top 20 Guides Leaderboard
*/
async getLeaderboard(limit = 20) {
return this.guideRepo.find({
order: { total_points: 'DESC' },
take: limit,
select: [
'id',
'display_name',
'avatar_url',
'total_points',
'guide_level',
'badge_title',
'approved_contributions_count',
],
});
}
/**
* Get all badges
*/
async getBadges() {
return this.badgeRepo.find({ order: { required_points: 'ASC' } });
}
}
@@ -0,0 +1,22 @@
import { IsNumber, IsString, IsOptional, IsNotEmpty, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export class ReviewContributionDto {
@IsNumber()
@IsNotEmpty()
@Type(() => Number)
contributionId: number;
@IsString()
@IsNotEmpty()
@IsIn(['APPROVE', 'REJECT'])
action: 'APPROVE' | 'REJECT';
@IsString()
@IsNotEmpty()
reviewerName: string;
@IsString()
@IsOptional()
reviewerNotes?: string;
}
@@ -0,0 +1,54 @@
import { IsString, IsNumber, IsOptional, IsObject, IsNotEmpty, Min, Max, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export class SubmitContributionDto {
@IsString()
@IsNotEmpty()
userId: string;
@IsString()
@IsOptional()
displayName?: string;
@IsString()
@IsNotEmpty()
@IsIn(['CONFIRM_GATE', 'ADD_PARKING', 'CORRECT_STREET', 'ADD_PLACE', 'ORAL_HISTORY'])
contributionType: string;
@IsString()
@IsOptional()
targetTable?: string;
@IsNumber()
@IsOptional()
@Type(() => Number)
targetId?: number;
@IsString()
@IsOptional()
placeName?: string;
@IsNumber()
@Min(-90)
@Max(90)
@Type(() => Number)
lat: number;
@IsNumber()
@Min(-180)
@Max(180)
@Type(() => Number)
lng: number;
@IsObject()
@IsOptional()
suggestedData?: Record<string, any>;
@IsString()
@IsOptional()
proofImageUrl?: string;
@IsString()
@IsOptional()
notes?: string;
}
@@ -0,0 +1,26 @@
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
@Entity({ name: 'badges', schema: 'community' })
export class Badge {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
@Index()
slug: string;
@Column()
name_ar: string;
@Column({ nullable: true })
name_en: string;
@Column({ type: 'text', nullable: true })
description_ar: string;
@Column()
required_points: number;
@Column({ default: 'award' })
icon_name: string;
}
@@ -0,0 +1,60 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
import { GuideProfile } from './guide-profile.entity';
@Entity({ name: 'contributions', schema: 'community' })
export class Contribution {
@PrimaryGeneratedColumn()
id: number;
@Column({ nullable: true })
guide_id: number;
@ManyToOne(() => GuideProfile, (g) => g.contributions, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'guide_id' })
guide: GuideProfile;
@Column()
@Index()
contribution_type: string; // 'CONFIRM_GATE', 'ADD_PLACE', 'CORRECT_STREET', 'ADD_PARKING', 'ORAL_HISTORY'
@Column({ default: 'heritage.landmarks' })
target_table: string;
@Column({ nullable: true })
target_id: number;
@Column({ nullable: true })
place_name: string;
@Column({ type: 'jsonb' })
suggested_data: Record<string, any>;
@Column({ type: 'geometry', spatialFeatureType: 'Geometry', srid: 4326 })
@Index({ spatial: true })
suggested_geom: any;
@Column({ nullable: true })
proof_image_url: string;
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ default: 'PENDING' })
@Index()
status: string; // 'PENDING', 'APPROVED', 'REJECTED'
@Column({ nullable: true })
reviewed_by: string;
@Column({ type: 'text', nullable: true })
reviewer_notes: string;
@Column({ default: 0 })
points_awarded: number;
@CreateDateColumn()
created_at: Date;
@Column({ type: 'timestamptz', nullable: true })
reviewed_at: Date;
}
@@ -0,0 +1,52 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, OneToMany } from 'typeorm';
import { Contribution } from './contribution.entity';
@Entity({ name: 'guides_profiles', schema: 'community' })
export class GuideProfile {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
@Index()
user_id: string;
@Column()
display_name: string;
@Column({ nullable: true })
phone_number: string;
@Column({ nullable: true })
email: string;
@Column({ nullable: true })
avatar_url: string;
@Column({ default: 10 })
reputation_score: number;
@Column({ default: 0 })
@Index()
total_points: number;
@Column({ default: 1 })
guide_level: number;
@Column({ default: 'مرشد مبتدئ' })
badge_title: string;
@Column({ default: 0 })
approved_contributions_count: number;
@Column({ default: 0 })
rejected_contributions_count: number;
@OneToMany(() => Contribution, (c) => c.guide)
contributions: Contribution[];
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
export interface LevelInfo {
level: number;
badgeTitle: string;
nextLevelPoints: number;
}
@Injectable()
export class GamificationService {
private readonly POINTS_MAP: Record<string, number> = {
CONFIRM_GATE: 50,
ADD_PARKING: 50,
CORRECT_STREET: 75,
ADD_PLACE: 100,
ORAL_HISTORY: 150,
};
/**
* Calculate awarded points for a verified contribution
*/
getPointsForContribution(type: string): number {
return this.POINTS_MAP[type] || 25;
}
/**
* Determine guide level and rank title based on accumulated points
*/
computeLevelInfo(totalPoints: number): LevelInfo {
if (totalPoints >= 1500) {
return { level: 5, badgeTitle: 'مرشد سيادي رائد', nextLevelPoints: 3000 };
}
if (totalPoints >= 700) {
return { level: 4, badgeTitle: 'مرشد معتمد', nextLevelPoints: 1500 };
}
if (totalPoints >= 300) {
return { level: 3, badgeTitle: 'خبير محلي', nextLevelPoints: 700 };
}
if (totalPoints >= 100) {
return { level: 2, badgeTitle: 'مستكشف نشط', nextLevelPoints: 300 };
}
return { level: 1, badgeTitle: 'مرشد مبتدئ', nextLevelPoints: 100 };
}
}
@@ -0,0 +1,20 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
import { CommunityService } from './community.service';
import { ReviewContributionDto } from './dto/review-contribution.dto';
import { ApiKeyGuard } from '../common/guards/api-key.guard';
@Controller('v1/admin/moderation')
@UseGuards(ApiKeyGuard)
export class ModerationController {
constructor(private readonly communityService: CommunityService) {}
@Get('pending')
async getPending(@Query('limit') limit?: string) {
return this.communityService.getPendingContributions(limit ? parseInt(limit, 10) : 50);
}
@Post('review')
async review(@Body() dto: ReviewContributionDto) {
return this.communityService.reviewContribution(dto);
}
}
@@ -0,0 +1,68 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
@Entity({ name: 'landmarks', schema: 'heritage' })
export class HeritageLandmark {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
@Index()
slug: string;
@Column()
@Index()
name_ar: string;
@Column({ nullable: true })
name_en: string;
@Column()
@Index()
category: string;
@Column({ nullable: true })
era: string;
@Column({ type: 'text', nullable: true })
narrative_ar: string;
@Column({ type: 'text', nullable: true })
narrative_en: string;
@Column({ nullable: true })
audio_url: string;
@Column({ nullable: true })
image_url: string;
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 })
@Index({ spatial: true })
centroid_geom: any;
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
@Index({ spatial: true })
access_gate_geom: any;
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
@Index({ spatial: true })
parking_geom: any;
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
ticket_office_geom: any;
@Column({ nullable: true })
@Index()
governorate: string;
@Column({ default: true })
is_verified: boolean;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,40 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
@Entity({ name: 'trails', schema: 'heritage' })
export class HeritageTrail {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
@Index()
slug: string;
@Column()
name_ar: string;
@Column({ nullable: true })
name_en: string;
@Column()
@Index()
region: string;
@Column({ type: 'text', nullable: true })
description_ar: string;
@Column({ type: 'geometry', spatialFeatureType: 'MultiLineString', srid: 4326, nullable: true })
@Index({ spatial: true })
trail_geom: any;
@Column({ type: 'decimal', precision: 6, scale: 2, default: 0.0 })
total_distance_km: number;
@Column({ type: 'int', default: 0 })
stops_count: number;
@Column({ default: true })
is_active: boolean;
@CreateDateColumn()
created_at: Date;
}
@@ -0,0 +1,218 @@
import { Controller, Get, Param, Query, Header, UseGuards } from '@nestjs/common';
import { HeritageService } from './heritage.service';
import { ApiKeyGuard } from '../common/guards/api-key.guard';
@Controller('v1/heritage')
@UseGuards(ApiKeyGuard)
export class HeritageController {
constructor(private readonly heritageService: HeritageService) {}
@Get('landmarks')
async getLandmarks(
@Query('category') category?: string,
@Query('governorate') governorate?: string,
@Query('lat') lat?: string,
@Query('lng') lng?: string,
@Query('radius') radius?: string,
) {
return this.heritageService.getLandmarks({
category,
governorate,
lat: lat ? parseFloat(lat) : undefined,
lng: lng ? parseFloat(lng) : undefined,
radiusMeters: radius ? parseFloat(radius) : undefined,
});
}
@Get('landmarks/:slug')
async getLandmarkBySlug(@Param('slug') slug: string) {
return this.heritageService.getLandmarkBySlug(slug);
}
@Get('trails')
async getTrails(@Query('region') region?: string) {
return this.heritageService.getTrails(region);
}
@Get('offline-pack')
async getOfflinePack(
@Query('governorate') governorate?: string,
@Query('region') region?: string,
) {
return this.heritageService.getOfflinePack(governorate, region);
}
@Get('postman-collection')
@Header('Content-Type', 'application/json')
@Header('Content-Disposition', 'attachment; filename="siro-maps-postman-collection.json"')
getPostmanCollection() {
return {
info: {
name: "Siro Maps - Sovereign Heritage & Community Guides API",
description: "Production Endpoints for National Heritage Landmarks, Precision Gate Navigation, and Moderated Local Guides Gamification.",
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
variable: [
{ key: "baseUrl", value: "https://map-saas.intaleqapp.com/api", type: "string" },
{ key: "apiKey", value: "zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX", type: "string" }
],
item: [
{
name: "1. Heritage - List All Landmarks",
request: {
method: "GET",
header: [{ key: "x-api-key", value: "{{apiKey}}" }],
url: {
raw: "{{baseUrl}}/v1/heritage/landmarks",
host: ["{{baseUrl}}"],
path: ["v1", "heritage", "landmarks"]
}
}
},
{
name: "2. Heritage - Nearby Landmarks (Spatial Query)",
request: {
method: "GET",
header: [{ key: "x-api-key", value: "{{apiKey}}" }],
url: {
raw: "{{baseUrl}}/v1/heritage/landmarks?lat=31.9539&lng=35.9350&radius=10000",
host: ["{{baseUrl}}"],
path: ["v1", "heritage", "landmarks"],
query: [
{ key: "lat", value: "31.9539" },
{ key: "lng", value: "35.9350" },
{ key: "radius", value: "10000" }
]
}
}
},
{
name: "3. Heritage - Single Landmark by Slug",
request: {
method: "GET",
header: [{ key: "x-api-key", value: "{{apiKey}}" }],
url: {
raw: "{{baseUrl}}/v1/heritage/landmarks/amman-citadel",
host: ["{{baseUrl}}"],
path: ["v1", "heritage", "landmarks", "amman-citadel"]
}
}
},
{
name: "4. Heritage - Offline Pack for Mobile",
request: {
method: "GET",
header: [{ key: "x-api-key", value: "{{apiKey}}" }],
url: {
raw: "{{baseUrl}}/v1/heritage/offline-pack",
host: ["{{baseUrl}}"],
path: ["v1", "heritage", "offline-pack"]
}
}
},
{
name: "5. Community - List Badges",
request: {
method: "GET",
header: [{ key: "x-api-key", value: "{{apiKey}}" }],
url: {
raw: "{{baseUrl}}/v1/community/badges",
host: ["{{baseUrl}}"],
path: ["v1", "community", "badges"]
}
}
},
{
name: "6. Community - Submit Contribution (Suggest Gate)",
request: {
method: "POST",
header: [
{ key: "Content-Type", value: "application/json" },
{ key: "x-api-key", value: "{{apiKey}}" }
],
body: {
mode: "raw",
raw: JSON.stringify({
userId: "guide_hamza_001",
displayName: "حمزة عايد",
contributionType: "CONFIRM_GATE",
targetTable: "heritage.landmarks",
targetId: 3,
placeName: "مدينة جرش - البوابة الجنوبية السياحية",
lat: 32.2731,
lng: 35.8924,
suggestedData: { gate_name: "South Main Gate" },
notes: "تم تأكيد موقع بوابة التذاكر الميدانية"
}, null, 2)
},
url: {
raw: "{{baseUrl}}/v1/community/contribute",
host: ["{{baseUrl}}"],
path: ["v1", "community", "contribute"]
}
}
},
{
name: "7. Community - Guide Profile & Points",
request: {
method: "GET",
header: [{ key: "x-api-key", value: "{{apiKey}}" }],
url: {
raw: "{{baseUrl}}/v1/community/profile/guide_hamza_001",
host: ["{{baseUrl}}"],
path: ["v1", "community", "profile", "guide_hamza_001"]
}
}
},
{
name: "8. Community - Leaderboard",
request: {
method: "GET",
header: [{ key: "x-api-key", value: "{{apiKey}}" }],
url: {
raw: "{{baseUrl}}/v1/community/leaderboard",
host: ["{{baseUrl}}"],
path: ["v1", "community", "leaderboard"]
}
}
},
{
name: "9. Admin - Moderation Pending Queue",
request: {
method: "GET",
header: [{ key: "x-api-key", value: "{{apiKey}}" }],
url: {
raw: "{{baseUrl}}/v1/admin/moderation/pending",
host: ["{{baseUrl}}"],
path: ["v1", "admin", "moderation", "pending"]
}
}
},
{
name: "10. Admin - Review Contribution (Approve)",
request: {
method: "POST",
header: [
{ key: "Content-Type", value: "application/json" },
{ key: "x-api-key", value: "{{apiKey}}" }
],
body: {
mode: "raw",
raw: JSON.stringify({
contributionId: 1,
action: "APPROVE",
reviewerName: "Hamza Ayed (Admin)",
reviewerNotes: "معتمدة ومطابقة لصور الأقمار الصناعية"
}, null, 2)
},
url: {
raw: "{{baseUrl}}/v1/admin/moderation/review",
host: ["{{baseUrl}}"],
path: ["v1", "admin", "moderation", "review"]
}
}
}
]
};
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HeritageLandmark } from './entities/heritage-landmark.entity';
import { HeritageTrail } from './entities/heritage-trail.entity';
import { HeritageService } from './heritage.service';
import { HeritageController } from './heritage.controller';
@Module({
imports: [
TypeOrmModule.forFeature([HeritageLandmark, HeritageTrail]),
],
controllers: [HeritageController],
providers: [HeritageService],
exports: [HeritageService],
})
export class HeritageModule {}
+206
View File
@@ -0,0 +1,206 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { HeritageLandmark } from './entities/heritage-landmark.entity';
import { HeritageTrail } from './entities/heritage-trail.entity';
@Injectable()
export class HeritageService {
constructor(
@InjectRepository(HeritageLandmark)
private readonly landmarkRepo: Repository<HeritageLandmark>,
@InjectRepository(HeritageTrail)
private readonly trailRepo: Repository<HeritageTrail>,
) {}
/**
* Find landmarks with optional category, governorate, or spatial proximity filters.
* Returns clean GeoJSON-compatible structure with precision gate & parking coordinates.
*/
async getLandmarks(options?: {
category?: string;
governorate?: string;
lat?: number;
lng?: number;
radiusMeters?: number;
}) {
const qb = this.landmarkRepo
.createQueryBuilder('l')
.select([
'l.id AS id',
'l.slug AS slug',
'l.name_ar AS name_ar',
'l.name_en AS name_en',
'l.category AS category',
'l.era AS era',
'l.narrative_ar AS narrative_ar',
'l.audio_url AS audio_url',
'l.image_url AS image_url',
'l.governorate AS governorate',
'l.is_verified AS is_verified',
'l.metadata AS metadata',
'ST_X(l.centroid_geom) AS centroid_lng',
'ST_Y(l.centroid_geom) AS centroid_lat',
'ST_X(l.access_gate_geom) AS gate_lng',
'ST_Y(l.access_gate_geom) AS gate_lat',
'ST_X(l.parking_geom) AS parking_lng',
'ST_Y(l.parking_geom) AS parking_lat',
'ST_X(l.ticket_office_geom) AS ticket_lng',
'ST_Y(l.ticket_office_geom) AS ticket_lat',
]);
if (options?.category) {
qb.andWhere('l.category = :category', { category: options.category });
}
if (options?.governorate) {
qb.andWhere('l.governorate = :gov', { gov: options.governorate });
}
if (options?.lat != null && options?.lng != null) {
const radius = options.radiusMeters || 25000;
qb.andWhere(
'ST_DWithin(l.centroid_geom::geography, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography, :radius)',
{ lng: options.lng, lat: options.lat, radius },
);
qb.addSelect(
'ST_Distance(l.centroid_geom::geography, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography)',
'distance_meters',
);
qb.orderBy('distance_meters', 'ASC');
} else {
qb.orderBy('l.id', 'ASC');
}
const rawResults = await qb.getRawMany();
return rawResults.map((r) => ({
id: r.id,
slug: r.slug,
name_ar: r.name_ar,
name_en: r.name_en,
category: r.category,
era: r.era,
narrative_ar: r.narrative_ar,
audio_url: r.audio_url,
image_url: r.image_url,
governorate: r.governorate,
is_verified: r.is_verified,
metadata: r.metadata,
coordinates: {
centroid: { lat: parseFloat(r.centroid_lat), lng: parseFloat(r.centroid_lng) },
access_gate: r.gate_lat ? { lat: parseFloat(r.gate_lat), lng: parseFloat(r.gate_lng) } : null,
parking: r.parking_lat ? { lat: parseFloat(r.parking_lat), lng: parseFloat(r.parking_lng) } : null,
ticket_office: r.ticket_lat ? { lat: parseFloat(r.ticket_lat), lng: parseFloat(r.ticket_lng) } : null,
},
distance_meters: r.distance_meters ? Math.round(parseFloat(r.distance_meters)) : undefined,
}));
}
/**
* Find a specific landmark by unique slug (direct query, no full-table scan)
*/
async getLandmarkBySlug(slug: string) {
const qb = this.landmarkRepo
.createQueryBuilder('l')
.select([
'l.id AS id',
'l.slug AS slug',
'l.name_ar AS name_ar',
'l.name_en AS name_en',
'l.category AS category',
'l.era AS era',
'l.narrative_ar AS narrative_ar',
'l.audio_url AS audio_url',
'l.image_url AS image_url',
'l.governorate AS governorate',
'l.is_verified AS is_verified',
'l.metadata AS metadata',
'ST_X(l.centroid_geom) AS centroid_lng',
'ST_Y(l.centroid_geom) AS centroid_lat',
'ST_X(l.access_gate_geom) AS gate_lng',
'ST_Y(l.access_gate_geom) AS gate_lat',
'ST_X(l.parking_geom) AS parking_lng',
'ST_Y(l.parking_geom) AS parking_lat',
'ST_X(l.ticket_office_geom) AS ticket_lng',
'ST_Y(l.ticket_office_geom) AS ticket_lat',
])
.where('l.slug = :slug', { slug });
const r = await qb.getRawOne();
if (!r) {
throw new NotFoundException(`Heritage landmark with slug '${slug}' not found.`);
}
return {
id: r.id,
slug: r.slug,
name_ar: r.name_ar,
name_en: r.name_en,
category: r.category,
era: r.era,
narrative_ar: r.narrative_ar,
audio_url: r.audio_url,
image_url: r.image_url,
governorate: r.governorate,
is_verified: r.is_verified,
metadata: r.metadata,
coordinates: {
centroid: { lat: parseFloat(r.centroid_lat), lng: parseFloat(r.centroid_lng) },
access_gate: r.gate_lat ? { lat: parseFloat(r.gate_lat), lng: parseFloat(r.gate_lng) } : null,
parking: r.parking_lat ? { lat: parseFloat(r.parking_lat), lng: parseFloat(r.parking_lng) } : null,
ticket_office: r.ticket_lat ? { lat: parseFloat(r.ticket_lat), lng: parseFloat(r.ticket_lng) } : null,
},
};
}
/**
* Fetch all active national cultural trails
*/
async getTrails(region?: string) {
const qb = this.trailRepo
.createQueryBuilder('t')
.select([
't.id AS id',
't.slug AS slug',
't.name_ar AS name_ar',
't.name_en AS name_en',
't.region AS region',
't.description_ar AS description_ar',
't.total_distance_km AS total_distance_km',
't.stops_count AS stops_count',
'ST_AsGeoJSON(t.trail_geom) AS geometry_geojson',
])
.where('t.is_active = true');
if (region) {
qb.andWhere('t.region = :region', { region });
}
const results = await qb.getRawMany();
return results.map((t) => ({
...t,
geometry: t.geometry_geojson ? JSON.parse(t.geometry_geojson) : null,
}));
}
/**
* Export offline pack for Siro Maps mobile client (Cache-First)
* Note: landmarks filter by `governorate`, trails filter by `region` — they differ.
*/
async getOfflinePack(governorate?: string, region?: string) {
const landmarks = await this.getLandmarks({ governorate });
const trails = await this.getTrails(region || governorate);
return {
version: '1.0.0',
timestamp: new Date().toISOString(),
governorate: governorate || 'all',
region: region || governorate || 'all',
total_landmarks: landmarks.length,
total_trails: trails.length,
landmarks,
trails,
};
}
}