diff --git a/src/database/entities/hotel-schedule.entity.ts b/src/database/entities/hotel-schedule.entity.ts new file mode 100644 index 0000000..692ebdb --- /dev/null +++ b/src/database/entities/hotel-schedule.entity.ts @@ -0,0 +1,36 @@ +import { Hotel } from "@/database/entities/hotel.entity"; +import { + Entity, + ManyToOne, + PrimaryKey, + Property, + type Rel, +} from "@mikro-orm/core"; + +@Entity() +export class HotelSchedule { + @PrimaryKey({ type: "varchar", length: 30 }) + id!: string; + + @ManyToOne(() => Hotel) + hotel!: Rel; + + @Property({ type: "date" }) + checkIn!: Date; + + @Property({ type: "date" }) + checkOut!: Date; + + @Property({ + type: "timestamp", + onCreate: () => new Date(), + }) + createdAt!: Date; + + @Property({ + type: "timestamp", + onCreate: () => new Date(), + onUpdate: () => new Date(), + }) + updatedAt!: Date; +} diff --git a/src/database/entities/package-detail.entity.ts b/src/database/entities/package-detail.entity.ts index 2e1e12e..d24673b 100644 --- a/src/database/entities/package-detail.entity.ts +++ b/src/database/entities/package-detail.entity.ts @@ -1,5 +1,5 @@ import { FlightSchedule } from "@/database/entities/flight-schedule.entity"; -import { Hotel } from "@/database/entities/hotel.entity"; +import { HotelSchedule } from "@/database/entities/hotel-schedule.entity"; import { PackageItinerary } from "@/database/entities/package-itinerary.entity"; import { Package } from "@/database/entities/package.entity"; import { TransportationClass } from "@/database/entities/transportation-class.entity"; @@ -43,21 +43,21 @@ export class PackageDetail { }) inboundFlight!: Rel; - @ManyToMany(() => Hotel, undefined, { + @ManyToMany(() => HotelSchedule, undefined, { owner: true, cascade: [Cascade.REMOVE], }) - tourHotels = new Collection(this); + tourHotels = new Collection(this); - @ManyToOne(() => Hotel, { + @ManyToOne(() => HotelSchedule, { cascade: [Cascade.REMOVE], }) - makkahHotel!: Rel; + makkahHotel!: Rel; - @ManyToOne(() => Hotel, { + @ManyToOne(() => HotelSchedule, { cascade: [Cascade.REMOVE], }) - madinahHotel!: Rel; + madinahHotel!: Rel; @ManyToOne(() => TransportationClass) transportation!: Rel; diff --git a/src/database/migrations/.snapshot-goumrah.json b/src/database/migrations/.snapshot-goumrah.json index 46bcbbf..45a72db 100644 --- a/src/database/migrations/.snapshot-goumrah.json +++ b/src/database/migrations/.snapshot-goumrah.json @@ -1318,6 +1318,100 @@ }, "nativeEnums": {} }, + { + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 30, + "mappedType": "string" + }, + "hotel_id": { + "name": "hotel_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 30, + "mappedType": "string" + }, + "check_in": { + "name": "check_in", + "type": "date", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "mappedType": "date" + }, + "check_out": { + "name": "check_out", + "type": "date", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "mappedType": "date" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "hotel_schedule", + "schema": "public", + "indexes": [ + { + "keyName": "hotel_schedule_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "hotel_schedule_hotel_id_foreign": { + "constraintName": "hotel_schedule_hotel_id_foreign", + "columnNames": [ + "hotel_id" + ], + "localTableName": "public.hotel_schedule", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.hotel", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, { "columns": { "id": { @@ -2852,7 +2946,7 @@ "referencedColumnNames": [ "id" ], - "referencedTableName": "public.hotel", + "referencedTableName": "public.hotel_schedule", "deleteRule": "cascade" }, "package_detail_madinah_hotel_id_foreign": { @@ -2864,7 +2958,7 @@ "referencedColumnNames": [ "id" ], - "referencedTableName": "public.hotel", + "referencedTableName": "public.hotel_schedule", "deleteRule": "cascade" }, "package_detail_transportation_id_foreign": { @@ -2906,8 +3000,8 @@ "length": 30, "mappedType": "string" }, - "hotel_id": { - "name": "hotel_id", + "hotel_schedule_id": { + "name": "hotel_schedule_id", "type": "varchar(30)", "unsigned": false, "autoincrement": false, @@ -2924,7 +3018,7 @@ "keyName": "package_detail_tour_hotels_pkey", "columnNames": [ "package_detail_id", - "hotel_id" + "hotel_schedule_id" ], "composite": true, "constraint": true, @@ -2947,16 +3041,16 @@ "deleteRule": "cascade", "updateRule": "cascade" }, - "package_detail_tour_hotels_hotel_id_foreign": { - "constraintName": "package_detail_tour_hotels_hotel_id_foreign", + "package_detail_tour_hotels_hotel_schedule_id_foreign": { + "constraintName": "package_detail_tour_hotels_hotel_schedule_id_foreign", "columnNames": [ - "hotel_id" + "hotel_schedule_id" ], "localTableName": "public.package_detail_tour_hotels", "referencedColumnNames": [ "id" ], - "referencedTableName": "public.hotel", + "referencedTableName": "public.hotel_schedule", "deleteRule": "cascade", "updateRule": "cascade" } diff --git a/src/database/migrations/Migration20251205022442.ts b/src/database/migrations/Migration20251205022442.ts new file mode 100644 index 0000000..32dfccc --- /dev/null +++ b/src/database/migrations/Migration20251205022442.ts @@ -0,0 +1,107 @@ +import { Migration } from "@mikro-orm/migrations"; + +export class Migration20251205022442 extends Migration { + override async up(): Promise { + const knex = this.getKnex(); + + await knex.schema.alterTable("package_detail", (table) => { + table.dropColumns("makkah_hotel_id", "madinah_hotel_id"); + }); + + await knex.schema.alterTable("package_detail_tour_hotels", (table) => { + table.dropColumn("hotel_id"); + }); + + await knex.schema.createTable("hotel_schedule", (table) => { + // Columns + table.string("id", 30).notNullable(); + table.string("hotel_id", 30).notNullable(); + table.date("check_in").notNullable(); + table.date("check_out").notNullable(); + table.timestamp("created_at", { useTz: true }).notNullable(); + table.timestamp("updated_at", { useTz: true }).notNullable(); + // Constraints + table.primary(["id"], { constraintName: "hotel_schedule_pkey" }); + table + .foreign("hotel_id", "hotel_schedule_hotel_id_foreign") + .references("hotel.id") + .onUpdate("NO ACTION") + .onDelete("CASCADE"); + }); + + await knex.schema.alterTable("package_detail", (table) => { + table.string("makkah_hotel_id", 30).notNullable(); + table.string("madinah_hotel_id", 30).notNullable(); + table + .foreign("makkah_hotel_id", "package_detail_makkah_hotel_id_foreign") + .references("hotel_schedule.id") + .onUpdate("NO ACTION") + .onDelete("CASCADE"); + table + .foreign("madinah_hotel_id", "package_detail_madinah_hotel_id_foreign") + .references("hotel_schedule.id") + .onUpdate("NO ACTION") + .onDelete("CASCADE"); + }); + + await knex.schema.alterTable("package_detail_tour_hotels", (table) => { + table.string("hotel_id", 30).notNullable(); + table + .foreign("hotel_id", "package_detail_tour_hotels_hotel_id_foreign") + .references("hotel_schedule.id") + .onUpdate("NO ACTION") + .onDelete("CASCADE"); + }); + } + + override async down(): Promise { + const knex = this.getKnex(); + + await knex.schema.alterTable("package_detail_tour_hotels", (table) => { + table.dropForeign( + "hotel_id", + "package_detail_tour_hotels_hotel_id_foreign", + ); + table.dropColumn("hotel_id"); + }); + + await knex.schema.alterTable("package_detail", (table) => { + table.dropForeign( + "madinah_hotel_id", + "package_detail_madinah_hotel_id_foreign", + ); + table.dropForeign( + "makkah_hotel_id", + "package_detail_makkah_hotel_id_foreign", + ); + table.dropColumn("madinah_hotel_id"); + table.dropColumn("makkah_hotel_id"); + }); + + await knex.schema.dropTable("hotel_schedule"); + + await knex.schema.alterTable("package_detail_tour_hotels", (table) => { + table.string("hotel_id", 30).notNullable(); + table + .foreign("hotel_id", "package_detail_tour_hotels_hotel_id_foreign") + .references("hotel.id") + .onUpdate("NO ACTION") + .onDelete("CASCADE"); + }); + + await knex.schema.alterTable("package_detail", (table) => { + table.string("makkah_hotel_id", 30).notNullable(); + table.string("madinah_hotel_id", 30).notNullable(); + table + .foreign("makkah_hotel_id", "package_detail_makkah_hotel_id_foreign") + .references("hotel.id") + .onUpdate("NO ACTION") + .onDelete("CASCADE"); + table + .foreign("madinah_hotel_id", "package_detail_madinah_hotel_id_foreign") + .references("hotel.id") + .onUpdate("NO ACTION") + .onDelete("CASCADE"); + }); + } +} diff --git a/src/modules/package/package.controller.ts b/src/modules/package/package.controller.ts index 0d57f85..1571593 100644 --- a/src/modules/package/package.controller.ts +++ b/src/modules/package/package.controller.ts @@ -465,7 +465,7 @@ export class PackageController extends Controller { const makkahHotel = await orm.em.findOne( Hotel, { - id: body.makkah_hotel_id, + id: body.makkah_hotel.hotel_id, }, { populate: ["*"], @@ -487,7 +487,7 @@ export class PackageController extends Controller { const madinahHotel = await orm.em.findOne( Hotel, { - id: body.madinah_hotel_id, + id: body.madinah_hotel.hotel_id, }, { populate: ["*"], @@ -671,8 +671,22 @@ export class PackageController extends Controller { tourFlight: tourFlightSchedule, outboundFlight: outboundFlightSchedule, inboundFlight: inboundFlightSchedule, - makkahHotel: makkahHotel, - madinahHotel: madinahHotel, + makkahHotel: { + id: ulid(), + hotel: makkahHotel, + checkIn: body.makkah_hotel.check_in, + checkOut: body.makkah_hotel.check_out, + createdAt: new Date(), + updatedAt: new Date(), + }, + madinahHotel: { + id: ulid(), + hotel: madinahHotel, + checkIn: body.madinah_hotel.check_in, + checkOut: body.madinah_hotel.check_out, + createdAt: new Date(), + updatedAt: new Date(), + }, transportation: transportationClass, quadPrice: body.quad_price, quadDiscount: body.quad_discount, @@ -687,22 +701,22 @@ export class PackageController extends Controller { updatedAt: new Date(), }); - for (const [index, tourHotelId] of (body.tour_hotel_ids ?? []).entries()) { - const tourHotel = await orm.em.findOne( + for (const [index, tourHotel] of (body.tour_hotels ?? []).entries()) { + const tourHotelEntity = await orm.em.findOne( Hotel, { - id: tourHotelId, + id: tourHotel.hotel_id, }, { populate: ["*"], }, ); - if (!tourHotel) { + if (!tourHotelEntity) { return res.status(404).json({ data: null, errors: [ { - path: `tour_hotel_ids.${index}`, + path: `tour_hotels.${index}.hotel_id`, location: "body", message: "Hotel not found.", }, @@ -710,7 +724,14 @@ export class PackageController extends Controller { }); } - packageDetail.tourHotels.add(tourHotel); + packageDetail.tourHotels.add({ + id: ulid(), + hotel: tourHotelEntity, + checkIn: tourHotel.check_in, + checkOut: tourHotel.check_out, + createdAt: new Date(), + updatedAt: new Date(), + }); } await orm.em.flush(); @@ -1029,7 +1050,7 @@ export class PackageController extends Controller { const makkahHotel = await orm.em.findOne( Hotel, { - id: body.makkah_hotel_id, + id: body.makkah_hotel.hotel_id, }, { populate: ["*"], @@ -1051,7 +1072,7 @@ export class PackageController extends Controller { const madinahHotel = await orm.em.findOne( Hotel, { - id: body.madinah_hotel_id, + id: body.madinah_hotel.hotel_id, }, { populate: ["*"], @@ -1258,8 +1279,18 @@ export class PackageController extends Controller { tourFlight: tourFlightSchedule, outboundFlight: outboundFlightSchedule, inboundFlight: inboundFlightSchedule, - makkahHotel: makkahHotel, - madinahHotel: madinahHotel, + makkahHotel: { + hotel: makkahHotel, + checkIn: body.makkah_hotel.check_in, + checkOut: body.makkah_hotel.check_out, + updatedAt: new Date(), + }, + madinahHotel: { + hotel: madinahHotel, + checkIn: body.madinah_hotel.check_in, + checkOut: body.madinah_hotel.check_out, + updatedAt: new Date(), + }, transportation: transportationClass, quadPrice: body.quad_price, quadDiscount: body.quad_discount, @@ -1274,22 +1305,22 @@ export class PackageController extends Controller { }); packageDetail.tourHotels.set([]); - for (const [index, tourHotelId] of (body.tour_hotel_ids ?? []).entries()) { - const tourHotel = await orm.em.findOne( + for (const [index, tourHotel] of (body.tour_hotels ?? []).entries()) { + const tourHotelEntity = await orm.em.findOne( Hotel, { - id: tourHotelId, + id: tourHotel.hotel_id, }, { populate: ["*"], }, ); - if (!tourHotel) { + if (!tourHotelEntity) { return res.status(404).json({ data: null, errors: [ { - path: `tour_hotel_ids.${index}`, + path: `tour_hotels.${index}.hotel_id`, location: "body", message: "Hotel not found.", }, @@ -1297,7 +1328,14 @@ export class PackageController extends Controller { }); } - packageDetail.tourHotels.add(tourHotel); + packageDetail.tourHotels.add({ + id: ulid(), + hotel: tourHotelEntity, + checkIn: tourHotel.check_in, + checkOut: tourHotel.check_out, + createdAt: new Date(), + updatedAt: new Date(), + }); } await orm.em.flush(); diff --git a/src/modules/package/package.mapper.ts b/src/modules/package/package.mapper.ts index 085ab06..23de2f2 100644 --- a/src/modules/package/package.mapper.ts +++ b/src/modules/package/package.mapper.ts @@ -1,4 +1,5 @@ import type { FlightSchedule } from "@/database/entities/flight-schedule.entity"; +import type { HotelSchedule } from "@/database/entities/hotel-schedule.entity"; import type { PackageDetail } from "@/database/entities/package-detail.entity"; import type { PackageItineraryDay } from "@/database/entities/package-itinerary-day.entity"; import type { @@ -15,6 +16,7 @@ import type { FlightClassResponse } from "@/modules/flight/flight.types"; import type { HotelMapper } from "@/modules/hotel/hotel.mapper"; import type { PackageDetailResponse, + PackageHotelResponse, PackageItineraryDayResponse, PackageItineraryResponse, PackageItineraryWidgetResponse, @@ -65,6 +67,14 @@ export class PackageMapper { return flightClassResponses; } + private mapHotelSchedule(hotelSchedule: HotelSchedule): PackageHotelResponse { + return { + hotel: this.hotelMapper.mapEntityToResponse(hotelSchedule.hotel), + check_in: dateFns.format(hotelSchedule.checkIn, "yyyy-MM-dd"), + check_out: dateFns.format(hotelSchedule.checkOut, "yyyy-MM-dd"), + }; + } + private mapItineraryWidget( packageItineraryWidget: PackageItineraryWidget, ): PackageItineraryWidgetResponse { @@ -161,14 +171,10 @@ export class PackageMapper { outbound_flights: this.mapFlightSchedule(packageDetail.outboundFlight), inbound_flights: this.mapFlightSchedule(packageDetail.inboundFlight), tour_hotels: packageDetail.tourHotels.map( - this.hotelMapper.mapEntityToResponse, - ), - makkah_hotel: this.hotelMapper.mapEntityToResponse( - packageDetail.makkahHotel, - ), - madinah_hotel: this.hotelMapper.mapEntityToResponse( - packageDetail.madinahHotel, + this.mapHotelSchedule.bind(this), ), + makkah_hotel: this.mapHotelSchedule(packageDetail.makkahHotel), + madinah_hotel: this.mapHotelSchedule(packageDetail.madinahHotel), transportation: this.transportationMapper.mapClassEntityToResponse( packageDetail.transportation, ), diff --git a/src/modules/package/package.schemas.ts b/src/modules/package/package.schemas.ts index f5d3aaf..a7792e9 100644 --- a/src/modules/package/package.schemas.ts +++ b/src/modules/package/package.schemas.ts @@ -51,24 +51,45 @@ export const packageDetailRequestSchema = z.object({ "Must be array.", ) .nonempty("Must not empty."), - tour_hotel_ids: z + tour_hotels: z .array( - z - .ulid("Must be ulid string.") - .nonempty("Must not empty.") - .max(30, "Max 30 characters."), + z.object( + { + hotel_id: z + .ulid("Must be ulid string.") + .nonempty("Must not empty.") + .max(30, "Max 30 characters."), + check_in: dateSchema, + check_out: dateSchema, + }, + "Must be object.", + ), "Must be array.", ) .nonempty("Must not empty.") .nullable(), - makkah_hotel_id: z - .ulid("Must be ulid string.") - .nonempty("Must not empty.") - .max(30, "Max 30 characters."), - madinah_hotel_id: z - .ulid("Must be ulid string.") - .nonempty("Must not empty.") - .max(30, "Max 30 characters."), + makkah_hotel: z.object( + { + hotel_id: z + .ulid("Must be ulid string.") + .nonempty("Must not empty.") + .max(30, "Max 30 characters."), + check_in: dateSchema, + check_out: dateSchema, + }, + "Must be object.", + ), + madinah_hotel: z.object( + { + hotel_id: z + .ulid("Must be ulid string.") + .nonempty("Must not empty.") + .max(30, "Max 30 characters."), + check_in: dateSchema, + check_out: dateSchema, + }, + "Must be object.", + ), transportation_id: z .ulid("Must be ulid string.") .nonempty("Must not empty.") diff --git a/src/modules/package/package.types.ts b/src/modules/package/package.types.ts index dc2f145..a844170 100644 --- a/src/modules/package/package.types.ts +++ b/src/modules/package/package.types.ts @@ -37,6 +37,12 @@ export type PackageConsultResponse = { session_code: string; }; +export type PackageHotelResponse = { + hotel: HotelResponse; + check_in: string; + check_out: string; +}; + export type PackageItineraryWidgetResponse = | { type: "transport"; @@ -73,9 +79,9 @@ export type PackageDetailResponse = { tour_flights: FlightClassResponse[] | null; outbound_flights: FlightClassResponse[]; inbound_flights: FlightClassResponse[]; - tour_hotels: HotelResponse[] | null; - makkah_hotel: HotelResponse; - madinah_hotel: HotelResponse; + tour_hotels: PackageHotelResponse[] | null; + makkah_hotel: PackageHotelResponse; + madinah_hotel: PackageHotelResponse; transportation: TransportationClassResponse; quad_price: number; quad_discount: number;