diff --git a/docs/roadmap/BOARD.md b/docs/roadmap/BOARD.md index 5c6349cb..b5ee9328 100644 --- a/docs/roadmap/BOARD.md +++ b/docs/roadmap/BOARD.md @@ -40,7 +40,7 @@ - [X] **T05** Identity: Auth Core · M · ← T01 - [X] **T06** Market: Core CRUD · M · ← T01, T03 -- [ ] **T07** Guide-Booking: Attractions · M · ← T01, T04 +- [x] **T07** Guide-Booking: Attractions · M · ← T01, T04 - [ ] **T08** RAG: Ingestion Pipeline · M · ← T02, T02a - [ ] **F01** Frontend Foundation · M · ← F00 - [ ] **F12** Error Handling + Loading States · S · ← F01 diff --git a/knip.ts b/knip.ts index 143777f4..eb1e5a34 100644 --- a/knip.ts +++ b/knip.ts @@ -31,8 +31,8 @@ const config: KnipConfig = { 'services/guide-booking': { entry: ['src/main.ts', 'src/db/migrate.ts'], project: ['src/**/*.ts'], - // @hena-wadeena/types will be used as guide-booking features are built out - ignoreDependencies: ['@hena-wadeena/types', '@nestjs/testing'], + // drizzle-zod: planned for DTO generation; @hena-wadeena/types will be used as features are built out + ignoreDependencies: ['drizzle-zod', '@hena-wadeena/types', '@nestjs/testing'], }, 'services/map': { entry: ['src/main.ts', 'src/db/migrate.ts'], diff --git a/package.json b/package.json index 1182b4fb..bf82824e 100644 --- a/package.json +++ b/package.json @@ -40,5 +40,10 @@ "typescript-eslint": "^8.20.0", "unplugin-swc": "^1.5.9", "vitest": "^3.0.0" + }, + "pnpm": { + "overrides": { + "fast-xml-parser": ">=5.5.6" + } } } diff --git a/packages/nest-common/src/modules/logger/logger.module.ts b/packages/nest-common/src/modules/logger/logger.module.ts index f7e0e318..d5aa56ca 100644 --- a/packages/nest-common/src/modules/logger/logger.module.ts +++ b/packages/nest-common/src/modules/logger/logger.module.ts @@ -17,7 +17,8 @@ export class LoggerModule { : undefined, genReqId: (req: IncomingMessage) => { const headerId = req.headers['x-request-id']; - return (typeof headerId === 'string' ? headerId : null) ?? generateId(); + const normalizedId = typeof headerId === 'string' ? headerId.trim() : ''; + return normalizedId.length > 0 ? normalizedId : generateId(); }, customProps: () => ({ service: serviceName, diff --git a/packages/nest-common/src/modules/s3/index.ts b/packages/nest-common/src/modules/s3/index.ts index bd7d2f67..770b946a 100644 --- a/packages/nest-common/src/modules/s3/index.ts +++ b/packages/nest-common/src/modules/s3/index.ts @@ -1,3 +1,4 @@ +export * from './s3.tokens'; export * from './s3.module'; export * from './s3.service'; export * from './s3.tokens'; diff --git a/packages/nest-common/src/modules/s3/s3.module.ts b/packages/nest-common/src/modules/s3/s3.module.ts index 725eedb9..36f2a045 100644 --- a/packages/nest-common/src/modules/s3/s3.module.ts +++ b/packages/nest-common/src/modules/s3/s3.module.ts @@ -1,7 +1,8 @@ import { DynamicModule, Global, Module } from '@nestjs/common'; import { S3Service } from './s3.service'; -import { S3_CONFIG, S3ModuleOptions } from './s3.tokens'; +import { S3_CONFIG } from './s3.tokens'; +import type { S3ModuleOptions } from './s3.tokens'; export { S3_CONFIG }; export type { S3ModuleOptions }; diff --git a/packages/nest-common/src/modules/s3/s3.service.ts b/packages/nest-common/src/modules/s3/s3.service.ts index 92c8622a..79031da9 100644 --- a/packages/nest-common/src/modules/s3/s3.service.ts +++ b/packages/nest-common/src/modules/s3/s3.service.ts @@ -2,7 +2,8 @@ import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3 import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Inject, Injectable, Logger } from '@nestjs/common'; -import { S3_CONFIG, S3ModuleOptions } from './s3.tokens'; +import { S3_CONFIG } from './s3.tokens'; +import type { S3ModuleOptions } from './s3.tokens'; export interface PresignedUploadOptions { key: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d04af25..6b897ee5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -367,15 +367,39 @@ importers: '@nestjs/common': specifier: ^11.0.0 version: 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.3 + version: 4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.0 version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^11.0.0 + version: 11.0.2(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^11.0.0 + version: 11.0.5(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-express': specifier: ^11.0.0 version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/throttler': + specifier: ^6.4.0 + version: 6.5.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2) drizzle-orm: specifier: ^0.38.0 version: 0.38.4(@types/react@18.3.28)(postgres@3.4.8)(react@18.3.1) + drizzle-zod: + specifier: ^0.8.3 + version: 0.8.3(drizzle-orm@0.38.4(@types/react@18.3.28)(postgres@3.4.8)(react@18.3.1))(zod@4.3.6) + nestjs-zod: + specifier: ^5.1.1 + version: 5.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)(zod@4.3.6) + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 postgres: specifier: ^3.4.5 version: 3.4.8 @@ -385,13 +409,25 @@ importers: rxjs: specifier: ^7.8.0 version: 7.8.2 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@nestjs/testing': specifier: ^11.0.0 version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16) + '@types/ms': + specifier: ^2.1.0 + version: 2.1.0 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 drizzle-kit: specifier: 0.30.4 version: 0.30.4 + ms: + specifier: ^2.1.3 + version: 2.1.3 tsx: specifier: ^4.19.0 version: 4.21.0 diff --git a/services/guide-booking/drizzle/20260316212720_robust_madame_masque.sql b/services/guide-booking/drizzle/20260316212720_robust_madame_masque.sql new file mode 100644 index 00000000..48553c6c --- /dev/null +++ b/services/guide-booking/drizzle/20260316212720_robust_madame_masque.sql @@ -0,0 +1,45 @@ +CREATE TYPE "guide_booking"."attraction_area" AS ENUM('kharga', 'dakhla', 'farafra', 'baris', 'balat');--> statement-breakpoint +CREATE TYPE "guide_booking"."attraction_type" AS ENUM('attraction', 'historical', 'natural', 'festival', 'adventure');--> statement-breakpoint +CREATE TYPE "guide_booking"."best_season" AS ENUM('winter', 'summer', 'spring', 'all_year');--> statement-breakpoint +CREATE TYPE "guide_booking"."best_time_of_day" AS ENUM('morning', 'evening', 'any');--> statement-breakpoint +CREATE TYPE "guide_booking"."difficulty" AS ENUM('easy', 'moderate', 'hard');--> statement-breakpoint +CREATE TABLE "guide_booking"."attractions" ( + "id" uuid PRIMARY KEY NOT NULL, + "name_ar" text NOT NULL, + "name_en" text, + "slug" text NOT NULL, + "type" "guide_booking"."attraction_type" NOT NULL, + "area" "guide_booking"."attraction_area" NOT NULL, + "description_ar" text, + "description_en" text, + "history_ar" text, + "best_season" "guide_booking"."best_season", + "best_time_of_day" "guide_booking"."best_time_of_day", + "entry_fee" jsonb, + "opening_hours" text, + "duration_hours" real, + "difficulty" "guide_booking"."difficulty", + "tips" text[], + "nearby_slugs" text[], + "location" geometry(point), + "images" text[], + "thumbnail" text, + "is_active" boolean DEFAULT true NOT NULL, + "is_featured" boolean DEFAULT false NOT NULL, + "rating_avg" real, + "review_count" integer DEFAULT 0, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + CONSTRAINT "chk_attractions_duration_positive" CHECK ("guide_booking"."attractions"."duration_hours" > 0), + CONSTRAINT "chk_attractions_review_count_non_neg" CHECK ("guide_booking"."attractions"."review_count" >= 0), + CONSTRAINT "chk_attractions_rating_avg_range" CHECK ("guide_booking"."attractions"."rating_avg" IS NULL OR ("guide_booking"."attractions"."rating_avg" >= 0 AND "guide_booking"."attractions"."rating_avg" <= 5)) +); +--> statement-breakpoint +CREATE UNIQUE INDEX "attractions_slug_active_unique" ON "guide_booking"."attractions" USING btree ("slug") WHERE "guide_booking"."attractions"."deleted_at" IS NULL;--> statement-breakpoint +CREATE INDEX "idx_attractions_location" ON "guide_booking"."attractions" USING gist ("location");--> statement-breakpoint +CREATE INDEX "idx_attractions_type" ON "guide_booking"."attractions" USING btree ("type");--> statement-breakpoint +CREATE INDEX "idx_attractions_area" ON "guide_booking"."attractions" USING btree ("area");--> statement-breakpoint +CREATE INDEX "idx_attractions_is_active" ON "guide_booking"."attractions" USING btree ("is_active");--> statement-breakpoint +CREATE INDEX "idx_attractions_is_featured" ON "guide_booking"."attractions" USING btree ("is_featured");--> statement-breakpoint +CREATE INDEX "idx_attractions_created_at" ON "guide_booking"."attractions" USING btree ("created_at" DESC NULLS LAST); \ No newline at end of file diff --git a/services/guide-booking/drizzle/20260317140321_white_diamondback.sql b/services/guide-booking/drizzle/20260317140321_white_diamondback.sql new file mode 100644 index 00000000..fbdf3367 --- /dev/null +++ b/services/guide-booking/drizzle/20260317140321_white_diamondback.sql @@ -0,0 +1,3 @@ +DROP INDEX "guide_booking"."attractions_slug_active_unique";--> statement-breakpoint +ALTER TABLE "guide_booking"."attractions" ALTER COLUMN "review_count" SET NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "attractions_slug_unique" ON "guide_booking"."attractions" USING btree ("slug"); \ No newline at end of file diff --git a/services/guide-booking/drizzle/meta/20260316212720_snapshot.json b/services/guide-booking/drizzle/meta/20260316212720_snapshot.json new file mode 100644 index 00000000..66edb3e4 --- /dev/null +++ b/services/guide-booking/drizzle/meta/20260316212720_snapshot.json @@ -0,0 +1,1347 @@ +{ + "id": "b1de7b01-08cc-4b9b-966f-bb61f1693fe5", + "prevId": "d7e0311f-e118-4a45-bb91-4614ccdc9f1b", + "version": "7", + "dialect": "postgresql", + "tables": { + "guide_booking.attractions": { + "name": "attractions", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name_ar": { + "name": "name_ar", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_en": { + "name": "name_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "attraction_type", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": true + }, + "area": { + "name": "area", + "type": "attraction_area", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": true + }, + "description_ar": { + "name": "description_ar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "history_ar": { + "name": "history_ar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "best_season": { + "name": "best_season", + "type": "best_season", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": false + }, + "best_time_of_day": { + "name": "best_time_of_day", + "type": "best_time_of_day", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": false + }, + "entry_fee": { + "name": "entry_fee", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "opening_hours": { + "name": "opening_hours", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_hours": { + "name": "duration_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "difficulty", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": false + }, + "tips": { + "name": "tips", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "nearby_slugs": { + "name": "nearby_slugs", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rating_avg": { + "name": "rating_avg", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attractions_slug_active_unique": { + "name": "attractions_slug_active_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"guide_booking\".\"attractions\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attractions_location": { + "name": "idx_attractions_location", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "idx_attractions_type": { + "name": "idx_attractions_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attractions_area": { + "name": "idx_attractions_area", + "columns": [ + { + "expression": "area", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attractions_is_active": { + "name": "idx_attractions_is_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attractions_is_featured": { + "name": "idx_attractions_is_featured", + "columns": [ + { + "expression": "is_featured", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attractions_created_at": { + "name": "idx_attractions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_attractions_duration_positive": { + "name": "chk_attractions_duration_positive", + "value": "\"guide_booking\".\"attractions\".\"duration_hours\" > 0" + }, + "chk_attractions_review_count_non_neg": { + "name": "chk_attractions_review_count_non_neg", + "value": "\"guide_booking\".\"attractions\".\"review_count\" >= 0" + }, + "chk_attractions_rating_avg_range": { + "name": "chk_attractions_rating_avg_range", + "value": "\"guide_booking\".\"attractions\".\"rating_avg\" IS NULL OR (\"guide_booking\".\"attractions\".\"rating_avg\" >= 0 AND \"guide_booking\".\"attractions\".\"rating_avg\" <= 5)" + } + }, + "isRLSEnabled": false + }, + "guide_booking.bookings": { + "name": "bookings", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tourist_id": { + "name": "tourist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "booking_date": { + "name": "booking_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "people_count": { + "name": "people_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_price": { + "name": "total_price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "booking_status", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_bookings_package_id": { + "name": "idx_bookings_package_id", + "columns": [ + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bookings_guide_id": { + "name": "idx_bookings_guide_id", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bookings_tourist_id": { + "name": "idx_bookings_tourist_id", + "columns": [ + { + "expression": "tourist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bookings_status": { + "name": "idx_bookings_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bookings_booking_date": { + "name": "idx_bookings_booking_date", + "columns": [ + { + "expression": "booking_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bookings_created_at": { + "name": "idx_bookings_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_bookings_id_guide_id": { + "name": "uq_bookings_id_guide_id", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookings_package_id_tour_packages_id_fk": { + "name": "bookings_package_id_tour_packages_id_fk", + "tableFrom": "bookings", + "tableTo": "tour_packages", + "schemaTo": "guide_booking", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bookings_guide_id_guides_id_fk": { + "name": "bookings_guide_id_guides_id_fk", + "tableFrom": "bookings", + "tableTo": "guides", + "schemaTo": "guide_booking", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_bookings_total_price_non_neg": { + "name": "chk_bookings_total_price_non_neg", + "value": "\"guide_booking\".\"bookings\".\"total_price\" >= 0" + }, + "chk_bookings_people_count_positive": { + "name": "chk_bookings_people_count_positive", + "value": "\"guide_booking\".\"bookings\".\"people_count\" >= 1" + } + }, + "isRLSEnabled": false + }, + "guide_booking.guide_availability": { + "name": "guide_availability", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "is_blocked": { + "name": "is_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_guide_availability_guide_date": { + "name": "uq_guide_availability_guide_date", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_availability_guide_id": { + "name": "idx_guide_availability_guide_id", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_availability_date": { + "name": "idx_guide_availability_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "guide_availability_guide_id_guides_id_fk": { + "name": "guide_availability_guide_id_guides_id_fk", + "tableFrom": "guide_availability", + "tableTo": "guides", + "schemaTo": "guide_booking", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guide_booking.guide_reviews": { + "name": "guide_reviews", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "booking_id": { + "name": "booking_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_id": { + "name": "reviewer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "guide_reply": { + "name": "guide_reply", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "helpful_count": { + "name": "helpful_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "images": { + "name": "images", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_guide_reviews_booking_id": { + "name": "uq_guide_reviews_booking_id", + "columns": [ + { + "expression": "booking_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_reviews_guide_id": { + "name": "idx_guide_reviews_guide_id", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_reviews_reviewer_id": { + "name": "idx_guide_reviews_reviewer_id", + "columns": [ + { + "expression": "reviewer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_reviews_rating": { + "name": "idx_guide_reviews_rating", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_reviews_created_at": { + "name": "idx_guide_reviews_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fk_guide_reviews_booking_guide": { + "name": "fk_guide_reviews_booking_guide", + "tableFrom": "guide_reviews", + "tableTo": "bookings", + "schemaTo": "guide_booking", + "columnsFrom": [ + "booking_id", + "guide_id" + ], + "columnsTo": [ + "id", + "guide_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_guide_reviews_helpful_count_non_neg": { + "name": "chk_guide_reviews_helpful_count_non_neg", + "value": "\"guide_booking\".\"guide_reviews\".\"helpful_count\" >= 0" + }, + "chk_guide_reviews_rating_range": { + "name": "chk_guide_reviews_rating_range", + "value": "\"guide_booking\".\"guide_reviews\".\"rating\" >= 1 AND \"guide_booking\".\"guide_reviews\".\"rating\" <= 5" + } + }, + "isRLSEnabled": false + }, + "guide_booking.guides": { + "name": "guides", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bio_ar": { + "name": "bio_ar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio_en": { + "name": "bio_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "languages": { + "name": "languages", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "specialties": { + "name": "specialties", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "license_number": { + "name": "license_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "license_verified": { + "name": "license_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "base_price": { + "name": "base_price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rating_avg": { + "name": "rating_avg", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_guides_user_id": { + "name": "uq_guides_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"guide_booking\".\"guides\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_guides_license_number": { + "name": "uq_guides_license_number", + "columns": [ + { + "expression": "license_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"guide_booking\".\"guides\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guides_active": { + "name": "idx_guides_active", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guides_created_at": { + "name": "idx_guides_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guides_languages": { + "name": "idx_guides_languages", + "columns": [ + { + "expression": "languages", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_guides_specialties": { + "name": "idx_guides_specialties", + "columns": [ + { + "expression": "specialties", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_guides_base_price_non_neg": { + "name": "chk_guides_base_price_non_neg", + "value": "\"guide_booking\".\"guides\".\"base_price\" >= 0" + }, + "chk_guides_rating_count_non_neg": { + "name": "chk_guides_rating_count_non_neg", + "value": "\"guide_booking\".\"guides\".\"rating_count\" >= 0" + }, + "chk_guides_rating_range": { + "name": "chk_guides_rating_range", + "value": "\"guide_booking\".\"guides\".\"rating_avg\" IS NULL OR (\"guide_booking\".\"guides\".\"rating_avg\" >= 0 AND \"guide_booking\".\"guides\".\"rating_avg\" <= 5)" + } + }, + "isRLSEnabled": false + }, + "guide_booking.tour_packages": { + "name": "tour_packages", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title_ar": { + "name": "title_ar", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_en": { + "name": "title_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_hours": { + "name": "duration_hours", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "max_people": { + "name": "max_people", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "includes": { + "name": "includes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "package_status", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_tour_packages_guide_id": { + "name": "idx_tour_packages_guide_id", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tour_packages_status": { + "name": "idx_tour_packages_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tour_packages_created_at": { + "name": "idx_tour_packages_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tour_packages_guide_id_guides_id_fk": { + "name": "tour_packages_guide_id_guides_id_fk", + "tableFrom": "tour_packages", + "tableTo": "guides", + "schemaTo": "guide_booking", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_tour_packages_price_positive": { + "name": "chk_tour_packages_price_positive", + "value": "\"guide_booking\".\"tour_packages\".\"price\" > 0" + }, + "chk_tour_packages_max_people_positive": { + "name": "chk_tour_packages_max_people_positive", + "value": "\"guide_booking\".\"tour_packages\".\"max_people\" >= 1" + }, + "chk_tour_packages_duration_positive": { + "name": "chk_tour_packages_duration_positive", + "value": "\"guide_booking\".\"tour_packages\".\"duration_hours\" > 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "guide_booking.attraction_area": { + "name": "attraction_area", + "schema": "guide_booking", + "values": [ + "kharga", + "dakhla", + "farafra", + "baris", + "balat" + ] + }, + "guide_booking.attraction_type": { + "name": "attraction_type", + "schema": "guide_booking", + "values": [ + "attraction", + "historical", + "natural", + "festival", + "adventure" + ] + }, + "guide_booking.best_season": { + "name": "best_season", + "schema": "guide_booking", + "values": [ + "winter", + "summer", + "spring", + "all_year" + ] + }, + "guide_booking.best_time_of_day": { + "name": "best_time_of_day", + "schema": "guide_booking", + "values": [ + "morning", + "evening", + "any" + ] + }, + "guide_booking.booking_status": { + "name": "booking_status", + "schema": "guide_booking", + "values": [ + "pending", + "confirmed", + "in_progress", + "completed", + "cancelled" + ] + }, + "guide_booking.difficulty": { + "name": "difficulty", + "schema": "guide_booking", + "values": [ + "easy", + "moderate", + "hard" + ] + }, + "guide_booking.package_status": { + "name": "package_status", + "schema": "guide_booking", + "values": [ + "active", + "inactive" + ] + } + }, + "schemas": { + "guide_booking": "guide_booking" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/services/guide-booking/drizzle/meta/20260317140321_snapshot.json b/services/guide-booking/drizzle/meta/20260317140321_snapshot.json new file mode 100644 index 00000000..1a61fa4d --- /dev/null +++ b/services/guide-booking/drizzle/meta/20260317140321_snapshot.json @@ -0,0 +1,1346 @@ +{ + "id": "783646a9-cebd-4fec-a39e-68d9a5d4571a", + "prevId": "b1de7b01-08cc-4b9b-966f-bb61f1693fe5", + "version": "7", + "dialect": "postgresql", + "tables": { + "guide_booking.attractions": { + "name": "attractions", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name_ar": { + "name": "name_ar", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_en": { + "name": "name_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "attraction_type", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": true + }, + "area": { + "name": "area", + "type": "attraction_area", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": true + }, + "description_ar": { + "name": "description_ar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "history_ar": { + "name": "history_ar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "best_season": { + "name": "best_season", + "type": "best_season", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": false + }, + "best_time_of_day": { + "name": "best_time_of_day", + "type": "best_time_of_day", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": false + }, + "entry_fee": { + "name": "entry_fee", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "opening_hours": { + "name": "opening_hours", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_hours": { + "name": "duration_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "difficulty", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": false + }, + "tips": { + "name": "tips", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "nearby_slugs": { + "name": "nearby_slugs", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "geometry(point)", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rating_avg": { + "name": "rating_avg", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attractions_slug_unique": { + "name": "attractions_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attractions_location": { + "name": "idx_attractions_location", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "idx_attractions_type": { + "name": "idx_attractions_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attractions_area": { + "name": "idx_attractions_area", + "columns": [ + { + "expression": "area", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attractions_is_active": { + "name": "idx_attractions_is_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attractions_is_featured": { + "name": "idx_attractions_is_featured", + "columns": [ + { + "expression": "is_featured", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attractions_created_at": { + "name": "idx_attractions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_attractions_duration_positive": { + "name": "chk_attractions_duration_positive", + "value": "\"guide_booking\".\"attractions\".\"duration_hours\" > 0" + }, + "chk_attractions_review_count_non_neg": { + "name": "chk_attractions_review_count_non_neg", + "value": "\"guide_booking\".\"attractions\".\"review_count\" >= 0" + }, + "chk_attractions_rating_avg_range": { + "name": "chk_attractions_rating_avg_range", + "value": "\"guide_booking\".\"attractions\".\"rating_avg\" IS NULL OR (\"guide_booking\".\"attractions\".\"rating_avg\" >= 0 AND \"guide_booking\".\"attractions\".\"rating_avg\" <= 5)" + } + }, + "isRLSEnabled": false + }, + "guide_booking.bookings": { + "name": "bookings", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tourist_id": { + "name": "tourist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "booking_date": { + "name": "booking_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "people_count": { + "name": "people_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_price": { + "name": "total_price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "booking_status", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_bookings_package_id": { + "name": "idx_bookings_package_id", + "columns": [ + { + "expression": "package_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bookings_guide_id": { + "name": "idx_bookings_guide_id", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bookings_tourist_id": { + "name": "idx_bookings_tourist_id", + "columns": [ + { + "expression": "tourist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bookings_status": { + "name": "idx_bookings_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bookings_booking_date": { + "name": "idx_bookings_booking_date", + "columns": [ + { + "expression": "booking_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bookings_created_at": { + "name": "idx_bookings_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_bookings_id_guide_id": { + "name": "uq_bookings_id_guide_id", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookings_package_id_tour_packages_id_fk": { + "name": "bookings_package_id_tour_packages_id_fk", + "tableFrom": "bookings", + "tableTo": "tour_packages", + "schemaTo": "guide_booking", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bookings_guide_id_guides_id_fk": { + "name": "bookings_guide_id_guides_id_fk", + "tableFrom": "bookings", + "tableTo": "guides", + "schemaTo": "guide_booking", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_bookings_total_price_non_neg": { + "name": "chk_bookings_total_price_non_neg", + "value": "\"guide_booking\".\"bookings\".\"total_price\" >= 0" + }, + "chk_bookings_people_count_positive": { + "name": "chk_bookings_people_count_positive", + "value": "\"guide_booking\".\"bookings\".\"people_count\" >= 1" + } + }, + "isRLSEnabled": false + }, + "guide_booking.guide_availability": { + "name": "guide_availability", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "is_blocked": { + "name": "is_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_guide_availability_guide_date": { + "name": "uq_guide_availability_guide_date", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_availability_guide_id": { + "name": "idx_guide_availability_guide_id", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_availability_date": { + "name": "idx_guide_availability_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "guide_availability_guide_id_guides_id_fk": { + "name": "guide_availability_guide_id_guides_id_fk", + "tableFrom": "guide_availability", + "tableTo": "guides", + "schemaTo": "guide_booking", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guide_booking.guide_reviews": { + "name": "guide_reviews", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "booking_id": { + "name": "booking_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_id": { + "name": "reviewer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "guide_reply": { + "name": "guide_reply", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "helpful_count": { + "name": "helpful_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "images": { + "name": "images", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_guide_reviews_booking_id": { + "name": "uq_guide_reviews_booking_id", + "columns": [ + { + "expression": "booking_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_reviews_guide_id": { + "name": "idx_guide_reviews_guide_id", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_reviews_reviewer_id": { + "name": "idx_guide_reviews_reviewer_id", + "columns": [ + { + "expression": "reviewer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_reviews_rating": { + "name": "idx_guide_reviews_rating", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guide_reviews_created_at": { + "name": "idx_guide_reviews_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fk_guide_reviews_booking_guide": { + "name": "fk_guide_reviews_booking_guide", + "tableFrom": "guide_reviews", + "tableTo": "bookings", + "schemaTo": "guide_booking", + "columnsFrom": [ + "booking_id", + "guide_id" + ], + "columnsTo": [ + "id", + "guide_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_guide_reviews_helpful_count_non_neg": { + "name": "chk_guide_reviews_helpful_count_non_neg", + "value": "\"guide_booking\".\"guide_reviews\".\"helpful_count\" >= 0" + }, + "chk_guide_reviews_rating_range": { + "name": "chk_guide_reviews_rating_range", + "value": "\"guide_booking\".\"guide_reviews\".\"rating\" >= 1 AND \"guide_booking\".\"guide_reviews\".\"rating\" <= 5" + } + }, + "isRLSEnabled": false + }, + "guide_booking.guides": { + "name": "guides", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bio_ar": { + "name": "bio_ar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio_en": { + "name": "bio_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "languages": { + "name": "languages", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "specialties": { + "name": "specialties", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "license_number": { + "name": "license_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "license_verified": { + "name": "license_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "base_price": { + "name": "base_price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rating_avg": { + "name": "rating_avg", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_guides_user_id": { + "name": "uq_guides_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"guide_booking\".\"guides\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_guides_license_number": { + "name": "uq_guides_license_number", + "columns": [ + { + "expression": "license_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"guide_booking\".\"guides\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guides_active": { + "name": "idx_guides_active", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guides_created_at": { + "name": "idx_guides_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_guides_languages": { + "name": "idx_guides_languages", + "columns": [ + { + "expression": "languages", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_guides_specialties": { + "name": "idx_guides_specialties", + "columns": [ + { + "expression": "specialties", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_guides_base_price_non_neg": { + "name": "chk_guides_base_price_non_neg", + "value": "\"guide_booking\".\"guides\".\"base_price\" >= 0" + }, + "chk_guides_rating_count_non_neg": { + "name": "chk_guides_rating_count_non_neg", + "value": "\"guide_booking\".\"guides\".\"rating_count\" >= 0" + }, + "chk_guides_rating_range": { + "name": "chk_guides_rating_range", + "value": "\"guide_booking\".\"guides\".\"rating_avg\" IS NULL OR (\"guide_booking\".\"guides\".\"rating_avg\" >= 0 AND \"guide_booking\".\"guides\".\"rating_avg\" <= 5)" + } + }, + "isRLSEnabled": false + }, + "guide_booking.tour_packages": { + "name": "tour_packages", + "schema": "guide_booking", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title_ar": { + "name": "title_ar", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_en": { + "name": "title_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_hours": { + "name": "duration_hours", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "max_people": { + "name": "max_people", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "includes": { + "name": "includes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "package_status", + "typeSchema": "guide_booking", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_tour_packages_guide_id": { + "name": "idx_tour_packages_guide_id", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tour_packages_status": { + "name": "idx_tour_packages_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tour_packages_created_at": { + "name": "idx_tour_packages_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tour_packages_guide_id_guides_id_fk": { + "name": "tour_packages_guide_id_guides_id_fk", + "tableFrom": "tour_packages", + "tableTo": "guides", + "schemaTo": "guide_booking", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_tour_packages_price_positive": { + "name": "chk_tour_packages_price_positive", + "value": "\"guide_booking\".\"tour_packages\".\"price\" > 0" + }, + "chk_tour_packages_max_people_positive": { + "name": "chk_tour_packages_max_people_positive", + "value": "\"guide_booking\".\"tour_packages\".\"max_people\" >= 1" + }, + "chk_tour_packages_duration_positive": { + "name": "chk_tour_packages_duration_positive", + "value": "\"guide_booking\".\"tour_packages\".\"duration_hours\" > 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "guide_booking.attraction_area": { + "name": "attraction_area", + "schema": "guide_booking", + "values": [ + "kharga", + "dakhla", + "farafra", + "baris", + "balat" + ] + }, + "guide_booking.attraction_type": { + "name": "attraction_type", + "schema": "guide_booking", + "values": [ + "attraction", + "historical", + "natural", + "festival", + "adventure" + ] + }, + "guide_booking.best_season": { + "name": "best_season", + "schema": "guide_booking", + "values": [ + "winter", + "summer", + "spring", + "all_year" + ] + }, + "guide_booking.best_time_of_day": { + "name": "best_time_of_day", + "schema": "guide_booking", + "values": [ + "morning", + "evening", + "any" + ] + }, + "guide_booking.booking_status": { + "name": "booking_status", + "schema": "guide_booking", + "values": [ + "pending", + "confirmed", + "in_progress", + "completed", + "cancelled" + ] + }, + "guide_booking.difficulty": { + "name": "difficulty", + "schema": "guide_booking", + "values": [ + "easy", + "moderate", + "hard" + ] + }, + "guide_booking.package_status": { + "name": "package_status", + "schema": "guide_booking", + "values": [ + "active", + "inactive" + ] + } + }, + "schemas": { + "guide_booking": "guide_booking" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/services/guide-booking/drizzle/meta/_journal.json b/services/guide-booking/drizzle/meta/_journal.json index df60f2b2..2831e318 100644 --- a/services/guide-booking/drizzle/meta/_journal.json +++ b/services/guide-booking/drizzle/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1773523996913, "tag": "20260314213316_guide-rating-trigger", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1773696440613, + "tag": "20260316212720_robust_madame_masque", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1773756201699, + "tag": "20260317140321_white_diamondback", + "breakpoints": true } ] } \ No newline at end of file diff --git a/services/guide-booking/package.json b/services/guide-booking/package.json index 50d879aa..f22050dd 100644 --- a/services/guide-booking/package.json +++ b/services/guide-booking/package.json @@ -19,16 +19,28 @@ "@hena-wadeena/nest-common": "workspace:*", "@hena-wadeena/types": "workspace:*", "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.0", + "@nestjs/jwt": "^11.0.0", + "@nestjs/passport": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/throttler": "^6.4.0", "drizzle-orm": "^0.38.0", + "drizzle-zod": "^0.8.3", + "nestjs-zod": "^5.1.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "postgres": "^3.4.5", "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.0" + "rxjs": "^7.8.0", + "zod": "^4.3.6" }, "devDependencies": { "@nestjs/testing": "^11.0.0", + "@types/ms": "^2.1.0", + "@types/passport-jwt": "^4.0.1", "drizzle-kit": "0.30.4", + "ms": "^2.1.3", "tsx": "^4.19.0", "typescript": "^5.7.3" } diff --git a/services/guide-booking/src/app.module.ts b/services/guide-booking/src/app.module.ts index a367dd40..a81ff0d4 100644 --- a/services/guide-booking/src/app.module.ts +++ b/services/guide-booking/src/app.module.ts @@ -1,7 +1,85 @@ -import { HealthModule } from '@hena-wadeena/nest-common'; +import { + DrizzleModule, + getJwtConfig, + HealthModule, + JwtAuthGuard, + LoggerModule, + REDIS_PREFIX, + RedisModule, + RolesGuard, + S3Module, + validateEnv, +} from '@hena-wadeena/nest-common'; import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { APP_GUARD, APP_PIPE } from '@nestjs/core'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import type { StringValue } from 'ms'; +import { ZodValidationPipe } from 'nestjs-zod'; + +import { AttractionsModule } from './attractions/attractions.module'; +import { JwtStrategy } from './auth/jwt.strategy'; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + if (process.env.NODE_ENV === 'production') { + throw new Error(`${name} environment variable is required`); + } + console.warn(`⚠️ ${name} is not set — S3 uploads will fail at runtime`); + return ''; + } + return value; +} @Module({ - imports: [HealthModule], + imports: [ + ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }), + LoggerModule.forRoot('GuideBooking'), + DrizzleModule.forRoot({ + connectionString: requireEnv('DATABASE_URL'), + schema: process.env.DB_SCHEMA ?? 'guide_booking, public', + }), + PassportModule, + JwtModule.registerAsync({ + useFactory: (config: ConfigService) => + getJwtConfig( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- validated by validateEnv + config.get('JWT_ACCESS_SECRET')!, + config.get('JWT_ACCESS_EXPIRES_IN', '15m') as StringValue, + ), + inject: [ConfigService], + }), + RedisModule.forRoot({ + url: process.env.REDIS_URL ?? 'redis://localhost:6379', + password: process.env.REDIS_PASSWORD, + keyPrefix: REDIS_PREFIX.GUIDE_BOOKING, + }), + S3Module.forRoot({ + region: process.env.AWS_REGION ?? 'me-south-1', + accessKeyId: requireEnv('AWS_ACCESS_KEY_ID'), + secretAccessKey: requireEnv('AWS_SECRET_ACCESS_KEY'), + bucket: requireEnv('AWS_S3_BUCKET'), + defaultExpiry: Number(process.env.AWS_S3_PRESIGNED_URL_EXPIRES ?? 3600), + }), + ThrottlerModule.forRoot([ + { + name: 'default', + ttl: Number(process.env.THROTTLE_TTL_MS ?? 60000), + limit: Number(process.env.THROTTLE_LIMIT ?? 100), + }, + ]), + HealthModule, + AttractionsModule, + ], + providers: [ + JwtStrategy, + { provide: APP_PIPE, useClass: ZodValidationPipe }, + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + { provide: APP_GUARD, useClass: ThrottlerGuard }, + ], }) export class AppModule {} diff --git a/services/guide-booking/src/attractions/admin-attractions.controller.ts b/services/guide-booking/src/attractions/admin-attractions.controller.ts new file mode 100644 index 00000000..5e210a7f --- /dev/null +++ b/services/guide-booking/src/attractions/admin-attractions.controller.ts @@ -0,0 +1,59 @@ +import { Roles } from '@hena-wadeena/nest-common'; +import { UserRole } from '@hena-wadeena/types'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; + +import { AttractionsService } from './attractions.service'; +import { + AdminAttractionFiltersDto, + AttractionFiltersDto, + CreateAttractionDto, + UpdateAttractionDto, + UploadUrlDto, +} from './dto'; + +@Roles(UserRole.ADMIN) +@Controller('admin/attractions') +export class AdminAttractionsController { + constructor(private readonly attractionsService: AttractionsService) {} + + @Get() + adminFindAll( + @Query() filters: AttractionFiltersDto, + @Query() adminFilters: AdminAttractionFiltersDto, + ) { + return this.attractionsService.adminFindAll(filters, adminFilters.status); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + create(@Body() dto: CreateAttractionDto) { + return this.attractionsService.create(dto); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() dto: UpdateAttractionDto) { + return this.attractionsService.update(id, dto); + } + + @Delete(':id') + softDelete(@Param('id') id: string) { + return this.attractionsService.softDelete(id); + } + + @Post(':id/upload-url') + @HttpCode(HttpStatus.OK) + getUploadUrl(@Param('id') id: string, @Body() dto: UploadUrlDto) { + return this.attractionsService.getUploadUrl(id, dto); + } +} diff --git a/services/guide-booking/src/attractions/attractions.controller.ts b/services/guide-booking/src/attractions/attractions.controller.ts new file mode 100644 index 00000000..3ac7cf8b --- /dev/null +++ b/services/guide-booking/src/attractions/attractions.controller.ts @@ -0,0 +1,28 @@ +import { Public } from '@hena-wadeena/nest-common'; +import { Controller, Get, Param, Query } from '@nestjs/common'; + +import { AttractionsService } from './attractions.service'; +import { AttractionFiltersDto, NearbyQueryDto } from './dto'; + +@Controller('attractions') +export class AttractionsController { + constructor(private readonly attractionsService: AttractionsService) {} + + @Public() + @Get() + findAll(@Query() filters: AttractionFiltersDto) { + return this.attractionsService.findAll(filters); + } + + @Public() + @Get(':slug') + findBySlug(@Param('slug') slug: string) { + return this.attractionsService.findBySlug(slug); + } + + @Public() + @Get(':slug/nearby') + findNearby(@Param('slug') slug: string, @Query() query: NearbyQueryDto) { + return this.attractionsService.findNearby(slug, query.limit, query.radiusKm); + } +} diff --git a/services/guide-booking/src/attractions/attractions.module.ts b/services/guide-booking/src/attractions/attractions.module.ts new file mode 100644 index 00000000..4158d741 --- /dev/null +++ b/services/guide-booking/src/attractions/attractions.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { AdminAttractionsController } from './admin-attractions.controller'; +import { AttractionsController } from './attractions.controller'; +import { AttractionsService } from './attractions.service'; + +@Module({ + controllers: [AttractionsController, AdminAttractionsController], + providers: [AttractionsService], + exports: [AttractionsService], +}) +export class AttractionsModule {} diff --git a/services/guide-booking/src/attractions/attractions.service.spec.ts b/services/guide-booking/src/attractions/attractions.service.spec.ts new file mode 100644 index 00000000..db0debdd --- /dev/null +++ b/services/guide-booking/src/attractions/attractions.service.spec.ts @@ -0,0 +1,327 @@ +import type { S3Service } from '@hena-wadeena/nest-common'; +import { NotFoundException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AttractionsService } from './attractions.service'; +import type { CreateAttractionDto, UpdateAttractionDto, UploadUrlDto } from './dto'; + +/** + * Chain mock for Drizzle query builder. All methods return the same chain so + * queries can be chained arbitrarily. The chain is thenable so `await chain` + * resolves via `chain.then()` — use mockImplementationOnce to control each + * successive awaited query result. + */ +type MockChain = Record> & { + then: ReturnType; +}; + +function createMockDb(): MockChain { + const chain = {} as MockChain; + + for (const method of [ + 'select', + 'from', + 'where', + 'orderBy', + 'limit', + 'offset', + 'insert', + 'values', + 'returning', + 'update', + 'set', + ]) { + chain[method] = vi.fn().mockReturnValue(chain); + } + + // Make chain thenable so `await chain` works + chain.then = vi + .fn() + .mockImplementation((onFulfilled: (v: unknown[]) => unknown) => + Promise.resolve([]).then(onFulfilled), + ); + + return chain; +} + +const mockAttraction = { + id: 'attraction-uuid-1', + nameAr: 'واحة الخارجة', + nameEn: 'Kharga Oasis', + slug: 'kharga-oasis', + type: 'natural' as const, + area: 'kharga' as const, + descriptionAr: null, + descriptionEn: null, + historyAr: null, + bestSeason: null, + bestTimeOfDay: null, + entryFee: null, + openingHours: null, + durationHours: null, + difficulty: null, + tips: null, + nearbySlugs: null, + location: { x: 30.55, y: 25.44 }, + images: null, + thumbnail: null, + isActive: true, + isFeatured: false, + ratingAvg: null, + reviewCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, +}; + +const baseFilters = { page: 1, limit: 20, radiusKm: 25 }; + +describe('AttractionsService', () => { + let service: AttractionsService; + let mockDb: ReturnType; + let mockS3: S3Service; + let mockGetPresignedUploadUrl: ReturnType; + + beforeEach(() => { + mockDb = createMockDb(); + mockGetPresignedUploadUrl = vi.fn().mockResolvedValue({ + uploadUrl: 'https://s3.example.com/signed', + key: 'attractions/attraction-uuid-1/uuid-photo.jpg', + expiresAt: '2026-03-16T00:00:00.000Z', + }); + mockS3 = { + getPresignedUploadUrl: mockGetPresignedUploadUrl, + } as unknown as S3Service; + + service = new AttractionsService(mockDb as any, mockS3); + }); + + // ------------------------------------------------------------------ create + describe('create', () => { + it('success: insert called with generated slug and correct values', async () => { + // generateUniqueSlug query: no existing slugs + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([]).then(resolve), + ); + // insert .returning(): new attraction + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([mockAttraction]).then(resolve), + ); + + const dto: CreateAttractionDto = { + nameAr: 'واحة الخارجة', + nameEn: 'Kharga Oasis', + type: 'natural', + area: 'kharga', + isActive: true, + isFeatured: false, + }; + + const result = await service.create(dto); + expect(result).toEqual(mockAttraction); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalledWith(expect.objectContaining({ slug: 'kharga-oasis' })); + }); + + it('slug collision: appends -2 when slug exists', async () => { + // generateUniqueSlug query: base slug already taken + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([{ slug: 'kharga-oasis' }]).then(resolve), + ); + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([{ ...mockAttraction, slug: 'kharga-oasis-2' }]).then(resolve), + ); + + const dto: CreateAttractionDto = { + nameAr: 'واحة الخارجة', + nameEn: 'Kharga Oasis', + type: 'natural', + area: 'kharga', + isActive: true, + isFeatured: false, + }; + + await service.create(dto); + expect(mockDb.values).toHaveBeenCalledWith( + expect.objectContaining({ slug: 'kharga-oasis-2' }), + ); + }); + }); + + // ------------------------------------------------------------------ findAll + describe('findAll', () => { + it('no filters: returns paginated active attractions', async () => { + mockDb.then + .mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([mockAttraction]).then(resolve), + ) + .mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([{ count: 1 }]).then(resolve), + ); + + const result = await service.findAll(baseFilters); + expect(result.data).toEqual([mockAttraction]); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + expect(result.hasMore).toBe(false); + }); + + it('with type/area filter: WHERE clause built and passed to query', async () => { + mockDb.then + .mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([]).then(resolve), + ) + .mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([{ count: 0 }]).then(resolve), + ); + + await service.findAll({ ...baseFilters, type: 'historical', area: 'kharga' }); + expect(mockDb.where).toHaveBeenCalled(); + }); + + it('with geo filter: where called with ST_DWithin conditions', async () => { + mockDb.then + .mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([mockAttraction]).then(resolve), + ) + .mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([{ count: 1 }]).then(resolve), + ); + + const result = await service.findAll({ ...baseFilters, nearLat: 25.44, nearLng: 30.55 }); + expect(mockDb.where).toHaveBeenCalled(); + expect(result.data).toEqual([mockAttraction]); + }); + }); + + // --------------------------------------------------------------- findBySlug + describe('findBySlug', () => { + it('found: returns attraction', async () => { + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([mockAttraction]).then(resolve), + ); + const result = await service.findBySlug('kharga-oasis'); + expect(result).toEqual(mockAttraction); + }); + + it('not found: throws NotFoundException', async () => { + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([]).then(resolve), + ); + await expect(service.findBySlug('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + // --------------------------------------------------------------- findNearby + describe('findNearby', () => { + it('with coordinates: queries nearby with ST_DWithin + ST_Distance ordering', async () => { + // findBySlug + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([mockAttraction]).then(resolve), + ); + // nearby results + const nearbyAttraction = { ...mockAttraction, id: 'nearby-uuid' }; + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([nearbyAttraction]).then(resolve), + ); + + const result = await service.findNearby('kharga-oasis', 5, 50); + expect(result).toEqual([nearbyAttraction]); + expect(mockDb.orderBy).toHaveBeenCalled(); + }); + + it('no coordinates: returns empty array without second DB call', async () => { + const noLocAttraction = { ...mockAttraction, location: null }; + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([noLocAttraction]).then(resolve), + ); + + const result = await service.findNearby('kharga-oasis'); + expect(result).toEqual([]); + // Only one DB call (findBySlug), no second query + expect(mockDb.then).toHaveBeenCalledTimes(1); + }); + }); + + // ------------------------------------------------------------------ update + describe('update', () => { + it('success: partial update + updated_at set', async () => { + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([{ ...mockAttraction, nameEn: 'Updated Oasis' }]).then(resolve), + ); + + const dto: UpdateAttractionDto = { nameEn: 'Updated Oasis' }; + const result = await service.update(mockAttraction.id, dto); + expect(result).toMatchObject({ nameEn: 'Updated Oasis' }); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ updatedAt: expect.any(Date) }), + ); + }); + + it('not found: throws NotFoundException', async () => { + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([]).then(resolve), + ); + await expect(service.update('nonexistent', {})).rejects.toThrow(NotFoundException); + }); + }); + + // --------------------------------------------------------------- softDelete + describe('softDelete', () => { + it('success: sets deleted_at via update', async () => { + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([{ id: mockAttraction.id }]).then(resolve), + ); + + const result = await service.softDelete(mockAttraction.id); + expect(result).toEqual({ message: 'Attraction deleted' }); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: expect.any(Date), updatedAt: expect.any(Date) }), + ); + }); + + it('already deleted: throws NotFoundException', async () => { + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([]).then(resolve), + ); + await expect(service.softDelete('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + // -------------------------------------------------------------- getUploadUrl + describe('getUploadUrl', () => { + it('S3Service called with correct key pattern attractions/{id}/{uuid}-{filename}', async () => { + mockDb.then.mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([{ id: mockAttraction.id }]).then(resolve), + ); + + const dto: UploadUrlDto = { contentType: 'image/jpeg', filename: 'photo.jpg' }; + await service.getUploadUrl(mockAttraction.id, dto); + + expect(mockGetPresignedUploadUrl).toHaveBeenCalledWith( + expect.objectContaining({ + contentType: 'image/jpeg', + key: expect.stringMatching(/^attractions\/attraction-uuid-1\/.+-photo\.jpg$/), + }), + ); + }); + }); + + // ------------------------------------------------------------ adminFindAll + describe('adminFindAll', () => { + it('includes inactive/deleted: returns paginated results without forced active filter', async () => { + mockDb.then + .mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([mockAttraction]).then(resolve), + ) + .mockImplementationOnce((resolve: (v: unknown[]) => unknown) => + Promise.resolve([{ count: 1 }]).then(resolve), + ); + + const result = await service.adminFindAll(baseFilters); + expect(result.data).toEqual([mockAttraction]); + expect(result.total).toBe(1); + }); + }); +}); diff --git a/services/guide-booking/src/attractions/attractions.service.ts b/services/guide-booking/src/attractions/attractions.service.ts new file mode 100644 index 00000000..35d12bea --- /dev/null +++ b/services/guide-booking/src/attractions/attractions.service.ts @@ -0,0 +1,249 @@ +import { DRIZZLE_CLIENT, S3Service, generateId } from '@hena-wadeena/nest-common'; +import type { PaginatedResponse } from '@hena-wadeena/types'; +import { slugify } from '@hena-wadeena/types'; +import { + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { and, count, desc, eq, ilike, isNotNull, isNull, not, or, sql } from 'drizzle-orm'; +import type { Column, SQL } from 'drizzle-orm'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; + +import { attractions } from '../db/schema/index'; + +import type { + AttractionFiltersDto, + CreateAttractionDto, + UpdateAttractionDto, + UploadUrlDto, +} from './dto'; +import { createAttractionSchema } from './dto'; + +type Attraction = typeof attractions.$inferSelect; + +function escapeLike(value: string): string { + return value.replace(/[%_\\]/g, '\\$&'); +} + +function makePoint(lng: number, lat: number) { + return sql`public.ST_SetSRID(public.ST_MakePoint(${lng}, ${lat}), 4326)`; +} + +function withinRadius(column: Column | SQL, point: SQL, meters: number) { + return sql`public.ST_DWithin(${column}::public.geography, ${point}::public.geography, ${meters})`; +} + +function distanceTo(column: Column | SQL, point: SQL) { + return sql`public.ST_Distance(${column}::public.geography, ${point}::public.geography)`; +} + +function pickDefined(obj: T): Partial { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial; +} + +@Injectable() +export class AttractionsService { + constructor( + @Inject(DRIZZLE_CLIENT) private readonly db: PostgresJsDatabase, + private readonly s3: S3Service, + ) {} + + private buildWhereClause( + filters: AttractionFiltersDto, + includeInactive = false, + ): SQL | undefined { + const conditions: SQL[] = []; + + if (!includeInactive) { + conditions.push(eq(attractions.isActive, true)); + conditions.push(isNull(attractions.deletedAt)); + } + + if (filters.type) { + conditions.push(eq(attractions.type, filters.type)); + } + + if (filters.area) { + conditions.push(eq(attractions.area, filters.area)); + } + + if (filters.featured === true) { + conditions.push(eq(attractions.isFeatured, true)); + } + + if (filters.search) { + const escaped = escapeLike(filters.search); + const searchCond = or( + ilike(attractions.nameAr, `%${escaped}%`), + ilike(attractions.nameEn, `%${escaped}%`), + ); + if (searchCond) { + conditions.push(searchCond); + } + } + + if (filters.nearLat != null && filters.nearLng != null) { + const point = makePoint(filters.nearLng, filters.nearLat); + conditions.push(withinRadius(attractions.location, point, filters.radiusKm * 1000)); + } + + return conditions.length > 0 ? and(...conditions) : undefined; + } + + private async generateUniqueSlug(name: string): Promise { + const base = slugify(name); + + const rows = await this.db + .select({ slug: attractions.slug }) + .from(attractions) + .where(or(eq(attractions.slug, base), ilike(attractions.slug, `${base}-%`))); + + const existingSlugs = new Set(rows.map(({ slug }) => slug)); + if (!existingSlugs.has(base)) return base; + + let i = 2; + while (existingSlugs.has(`${base}-${i}`)) i++; + return `${base}-${i}`; + } + + private async paginate( + where: SQL | undefined, + orderBy: SQL, + page: number, + limit: number, + ): Promise> { + const offset = (page - 1) * limit; + + const [data, countRows] = await Promise.all([ + this.db.select().from(attractions).where(where).orderBy(orderBy).limit(limit).offset(offset), + this.db.select({ count: count() }).from(attractions).where(where), + ]); + + const total = countRows[0]?.count ?? 0; + + return { data, total, page, limit, hasMore: offset + limit < total }; + } + + async findAll(filters: AttractionFiltersDto): Promise> { + const where = this.buildWhereClause(filters); + + const orderBy = + filters.nearLat != null && filters.nearLng != null + ? distanceTo(attractions.location, makePoint(filters.nearLng, filters.nearLat)) + : desc(attractions.createdAt); + + return this.paginate(where, orderBy, filters.page, filters.limit); + } + + async findBySlug(slug: string): Promise { + const [row] = await this.db + .select() + .from(attractions) + .where( + and( + eq(attractions.slug, slug), + eq(attractions.isActive, true), + isNull(attractions.deletedAt), + ), + ) + .limit(1); + + if (!row) throw new NotFoundException(`Attraction not found: ${slug}`); + return row; + } + + async findNearby(slug: string, limit = 5, radiusKm = 50): Promise { + const target = await this.findBySlug(slug); + if (!target.location) return []; + + const point = makePoint(target.location.x, target.location.y); + + return this.db + .select() + .from(attractions) + .where( + and( + eq(attractions.isActive, true), + isNull(attractions.deletedAt), + withinRadius(attractions.location, point, radiusKm * 1000), + not(eq(attractions.id, target.id)), + ), + ) + .orderBy(distanceTo(attractions.location, point)) + .limit(limit); + } + + async adminFindAll( + filters: AttractionFiltersDto, + status?: 'active' | 'inactive' | 'deleted', + ): Promise> { + const baseWhere = this.buildWhereClause(filters, true); + + let statusCondition: SQL | undefined; + if (status === 'active') { + statusCondition = and(eq(attractions.isActive, true), isNull(attractions.deletedAt)); + } else if (status === 'inactive') { + statusCondition = and(eq(attractions.isActive, false), isNull(attractions.deletedAt)); + } else if (status === 'deleted') { + statusCondition = isNotNull(attractions.deletedAt); + } + + return this.paginate( + and(baseWhere, statusCondition), + desc(attractions.createdAt), + filters.page, + filters.limit, + ); + } + + async create(dto: CreateAttractionDto): Promise { + const slug = await this.generateUniqueSlug(dto.nameEn ?? dto.nameAr); + + const [row] = await this.db + .insert(attractions) + .values({ ...createAttractionSchema.parse(dto), id: generateId(), slug }) + .returning(); + + if (!row) throw new InternalServerErrorException('Insert did not return a row'); + return row; + } + + async update(id: string, dto: UpdateAttractionDto): Promise { + const [row] = await this.db + .update(attractions) + .set({ ...pickDefined(dto), updatedAt: new Date() }) + .where(and(eq(attractions.id, id), isNull(attractions.deletedAt))) + .returning(); + + if (!row) throw new NotFoundException(`Attraction not found: ${id}`); + return row; + } + + async softDelete(id: string): Promise<{ message: string }> { + const now = new Date(); + const [deleted] = await this.db + .update(attractions) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(attractions.id, id), isNull(attractions.deletedAt))) + .returning({ id: attractions.id }); + + if (!deleted) throw new NotFoundException(`Attraction not found: ${id}`); + return { message: 'Attraction deleted' }; + } + + async getUploadUrl(id: string, dto: UploadUrlDto) { + const [existing] = await this.db + .select({ id: attractions.id }) + .from(attractions) + .where(and(eq(attractions.id, id), isNull(attractions.deletedAt))) + .limit(1); + + if (!existing) throw new NotFoundException(`Attraction not found: ${id}`); + + const key = `attractions/${id}/${generateId()}-${dto.filename}`; + + return this.s3.getPresignedUploadUrl({ key, contentType: dto.contentType }); + } +} diff --git a/services/guide-booking/src/attractions/dto/attraction-filters.dto.ts b/services/guide-booking/src/attractions/dto/attraction-filters.dto.ts new file mode 100644 index 00000000..bab30b3b --- /dev/null +++ b/services/guide-booking/src/attractions/dto/attraction-filters.dto.ts @@ -0,0 +1,38 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +import { attractionAreaEnum, attractionTypeEnum } from '../../db/enums'; + +const attractionFiltersSchema = z + .object({ + type: z.enum(attractionTypeEnum.enumValues).optional(), + area: z.enum(attractionAreaEnum.enumValues).optional(), + featured: z + .union([z.boolean(), z.enum(['true', 'false', '1', '0'])]) + .transform((v) => (typeof v === 'boolean' ? v : v === 'true' || v === '1')) + .optional(), + nearLat: z.coerce.number().min(-90).max(90).optional(), + nearLng: z.coerce.number().min(-180).max(180).optional(), + radiusKm: z.coerce.number().positive().max(100).default(25), + search: z.string().min(1).max(100).optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), + }) + .refine((d) => (d.nearLat == null) === (d.nearLng == null), { + message: 'nearLat and nearLng must both be provided or both omitted', + }); + +export class AttractionFiltersDto extends createZodDto(attractionFiltersSchema) {} + +const adminAttractionFiltersSchema = z.object({ + status: z.enum(['active', 'inactive', 'deleted']).optional(), +}); + +export class AdminAttractionFiltersDto extends createZodDto(adminAttractionFiltersSchema) {} + +const nearbyQuerySchema = z.object({ + limit: z.coerce.number().int().positive().max(50).default(5), + radiusKm: z.coerce.number().positive().max(100).default(50), +}); + +export class NearbyQueryDto extends createZodDto(nearbyQuerySchema) {} diff --git a/services/guide-booking/src/attractions/dto/create-attraction.dto.ts b/services/guide-booking/src/attractions/dto/create-attraction.dto.ts new file mode 100644 index 00000000..13ad456c --- /dev/null +++ b/services/guide-booking/src/attractions/dto/create-attraction.dto.ts @@ -0,0 +1,46 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +import { + attractionAreaEnum, + attractionTypeEnum, + bestSeasonEnum, + bestTimeOfDayEnum, + difficultyEnum, +} from '../../db/enums'; + +export const createAttractionSchema = z.object({ + nameAr: z.string().min(1).max(200), + nameEn: z.string().max(200).optional(), + type: z.enum(attractionTypeEnum.enumValues), + area: z.enum(attractionAreaEnum.enumValues), + descriptionAr: z.string().optional(), + descriptionEn: z.string().optional(), + historyAr: z.string().optional(), + bestSeason: z.enum(bestSeasonEnum.enumValues).optional(), + bestTimeOfDay: z.enum(bestTimeOfDayEnum.enumValues).optional(), + entryFee: z + .object({ + adultsPiasters: z.number().int().min(0), + childrenPiasters: z.number().int().min(0).optional(), + foreignersPiasters: z.number().int().min(0).optional(), + }) + .optional(), + openingHours: z.string().optional(), + durationHours: z.number().positive().optional(), + difficulty: z.enum(difficultyEnum.enumValues).optional(), + tips: z.array(z.string()).optional(), + nearbySlugs: z.array(z.string()).optional(), + location: z + .object({ + x: z.number().min(-180).max(180), // longitude + y: z.number().min(-90).max(90), // latitude + }) + .optional(), + images: z.array(z.string().min(1)).optional(), + thumbnail: z.string().min(1).optional(), + isActive: z.boolean().default(true), + isFeatured: z.boolean().default(false), +}); + +export class CreateAttractionDto extends createZodDto(createAttractionSchema) {} diff --git a/services/guide-booking/src/attractions/dto/index.ts b/services/guide-booking/src/attractions/dto/index.ts new file mode 100644 index 00000000..83bee1de --- /dev/null +++ b/services/guide-booking/src/attractions/dto/index.ts @@ -0,0 +1,4 @@ +export * from './attraction-filters.dto'; +export * from './create-attraction.dto'; +export * from './update-attraction.dto'; +export * from './upload-url.dto'; diff --git a/services/guide-booking/src/attractions/dto/update-attraction.dto.ts b/services/guide-booking/src/attractions/dto/update-attraction.dto.ts new file mode 100644 index 00000000..e869aa02 --- /dev/null +++ b/services/guide-booking/src/attractions/dto/update-attraction.dto.ts @@ -0,0 +1,11 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +import { createAttractionSchema } from './create-attraction.dto'; + +const updateAttractionSchema = createAttractionSchema.partial().extend({ + isActive: z.boolean().optional(), + isFeatured: z.boolean().optional(), +}); + +export class UpdateAttractionDto extends createZodDto(updateAttractionSchema) {} diff --git a/services/guide-booking/src/attractions/dto/upload-url.dto.ts b/services/guide-booking/src/attractions/dto/upload-url.dto.ts new file mode 100644 index 00000000..adb3c00e --- /dev/null +++ b/services/guide-booking/src/attractions/dto/upload-url.dto.ts @@ -0,0 +1,9 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +const uploadUrlSchema = z.object({ + contentType: z.string().regex(/^image\/(jpeg|png|webp|avif)$/), + filename: z.string().min(1).max(255), +}); + +export class UploadUrlDto extends createZodDto(uploadUrlSchema) {} diff --git a/services/guide-booking/src/auth/jwt.strategy.ts b/services/guide-booking/src/auth/jwt.strategy.ts new file mode 100644 index 00000000..3fef8fae --- /dev/null +++ b/services/guide-booking/src/auth/jwt.strategy.ts @@ -0,0 +1,28 @@ +import type { JwtPayload } from '@hena-wadeena/nest-common'; +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(@Inject(ConfigService) configService: ConfigService) { + const accessSecret = configService.get('JWT_ACCESS_SECRET'); + if (!accessSecret) { + throw new Error('JWT_ACCESS_SECRET is required'); + } + + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: accessSecret, + }); + } + + validate(payload: JwtPayload): JwtPayload { + if (!payload.sub || !payload.email || !payload.role) { + throw new UnauthorizedException('Invalid token payload'); + } + return payload; + } +} diff --git a/services/guide-booking/src/db/enums.ts b/services/guide-booking/src/db/enums.ts index 6c2d5453..ebb49f83 100644 --- a/services/guide-booking/src/db/enums.ts +++ b/services/guide-booking/src/db/enums.ts @@ -9,3 +9,34 @@ export const bookingStatusEnum = guideBookingSchema.enum('booking_status', [ ]); export const packageStatusEnum = guideBookingSchema.enum('package_status', ['active', 'inactive']); + +export const attractionTypeEnum = guideBookingSchema.enum('attraction_type', [ + 'attraction', + 'historical', + 'natural', + 'festival', + 'adventure', +]); + +export const attractionAreaEnum = guideBookingSchema.enum('attraction_area', [ + 'kharga', + 'dakhla', + 'farafra', + 'baris', + 'balat', +]); + +export const bestSeasonEnum = guideBookingSchema.enum('best_season', [ + 'winter', + 'summer', + 'spring', + 'all_year', +]); + +export const bestTimeOfDayEnum = guideBookingSchema.enum('best_time_of_day', [ + 'morning', + 'evening', + 'any', +]); + +export const difficultyEnum = guideBookingSchema.enum('difficulty', ['easy', 'moderate', 'hard']); diff --git a/services/guide-booking/src/db/migrate.ts b/services/guide-booking/src/db/migrate.ts index 0eb2d202..3cb0fbb7 100644 --- a/services/guide-booking/src/db/migrate.ts +++ b/services/guide-booking/src/db/migrate.ts @@ -7,7 +7,7 @@ if (!connectionString) throw new Error('DATABASE_URL is required'); const sql = postgres(connectionString, { max: 1, - connection: { search_path: 'guide_booking' }, + connection: { search_path: 'guide_booking, public' }, }); const db = drizzle(sql); diff --git a/services/guide-booking/src/db/schema/attractions.ts b/services/guide-booking/src/db/schema/attractions.ts new file mode 100644 index 00000000..cf61ab22 --- /dev/null +++ b/services/guide-booking/src/db/schema/attractions.ts @@ -0,0 +1,72 @@ +import { generateId } from '@hena-wadeena/nest-common'; +import { sql } from 'drizzle-orm'; +import { + boolean, + check, + geometry, + index, + integer, + jsonb, + real, + text, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; + +import { + attractionAreaEnum, + attractionTypeEnum, + bestSeasonEnum, + bestTimeOfDayEnum, + difficultyEnum, +} from '../enums'; +import { guideBookingSchema } from '../schema'; + +export const attractions = guideBookingSchema.table( + 'attractions', + { + id: uuid().primaryKey().$defaultFn(generateId), + nameAr: text('name_ar').notNull(), + nameEn: text('name_en'), + slug: text().notNull(), + type: attractionTypeEnum('type').notNull(), + area: attractionAreaEnum('area').notNull(), + descriptionAr: text('description_ar'), + descriptionEn: text('description_en'), + historyAr: text('history_ar'), + bestSeason: bestSeasonEnum('best_season'), + bestTimeOfDay: bestTimeOfDayEnum('best_time_of_day'), + entryFee: jsonb('entry_fee'), + openingHours: text('opening_hours'), + durationHours: real('duration_hours'), + difficulty: difficultyEnum('difficulty'), + tips: text().array(), + nearbySlugs: text('nearby_slugs').array(), + location: geometry('location', { type: 'point', mode: 'xy', srid: 4326 }), + images: text().array(), + thumbnail: text(), + isActive: boolean('is_active').notNull().default(true), + isFeatured: boolean('is_featured').notNull().default(false), + ratingAvg: real('rating_avg'), + reviewCount: integer('review_count').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + }, + (t) => [ + uniqueIndex('attractions_slug_unique').on(t.slug), + index('idx_attractions_location').using('gist', t.location), + index('idx_attractions_type').on(t.type), + index('idx_attractions_area').on(t.area), + index('idx_attractions_is_active').on(t.isActive), + index('idx_attractions_is_featured').on(t.isFeatured), + index('idx_attractions_created_at').on(t.createdAt.desc()), + check('chk_attractions_duration_positive', sql`${t.durationHours} > 0`), + check('chk_attractions_review_count_non_neg', sql`${t.reviewCount} >= 0`), + check( + 'chk_attractions_rating_avg_range', + sql`${t.ratingAvg} IS NULL OR (${t.ratingAvg} >= 0 AND ${t.ratingAvg} <= 5)`, + ), + ], +); diff --git a/services/guide-booking/src/db/schema/index.ts b/services/guide-booking/src/db/schema/index.ts index dea3658d..25af2c6d 100644 --- a/services/guide-booking/src/db/schema/index.ts +++ b/services/guide-booking/src/db/schema/index.ts @@ -3,6 +3,7 @@ import { relations } from 'drizzle-orm'; export { guideBookingSchema } from '../schema'; export * from '../enums'; +export { attractions } from './attractions'; export { guideAvailability } from './guide-availability'; export { bookings } from './bookings'; export { guides } from './guides';