feat: add Jordan 3D topographic platform, Siro Map cinematic orbit studio, sun-shadow simulation, and sovereign intelligence security gating
This commit is contained in:
@@ -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 {}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user