diff --git a/.gitignore b/.gitignore index 3c3629e..02f3661 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ node_modules +storage +.env diff --git a/bun.lock b/bun.lock index 8654f63..0b9ea35 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,8 @@ "file-type": "21.0.0", "helmet": "8.1.0", "reflect-metadata": "0.2.2", + "slugify": "1.6.6", + "ulid": "3.0.1", "zod": "4.1.12", }, "devDependencies": { @@ -401,6 +403,8 @@ "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], @@ -443,6 +447,8 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="], + "umzug": ["umzug@3.8.2", "", { "dependencies": { "@rushstack/ts-command-line": "^4.12.2", "emittery": "^0.13.0", "fast-glob": "^3.3.2", "pony-cause": "^2.1.4", "type-fest": "^4.0.0" } }, "sha512-BEWEF8OJjTYVC56GjELeHl/1XjFejrD7aHzn+HldRJTx+pL1siBrKHZC8n4K/xL3bEzVA9o++qD1tK2CpZu4KA=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/package.json b/package.json index eb610c9..981d0f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "scripts": { - "dev": "bun run src/server.ts" + "dev": "bun run src/server.ts", + "dev:watch": "bun run --watch src/server.ts" }, "mikro-orm": { "configPaths": [ @@ -19,6 +20,8 @@ "file-type": "21.0.0", "helmet": "8.1.0", "reflect-metadata": "0.2.2", + "slugify": "1.6.6", + "ulid": "3.0.1", "zod": "4.1.12" }, "devDependencies": { diff --git a/src/application.ts b/src/application.ts index e43dc43..239319e 100644 --- a/src/application.ts +++ b/src/application.ts @@ -1,4 +1,24 @@ +import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage"; +import { LocalFileStorage } from "@/common/services/file-storage/local.file-storage"; import { serverConfig } from "@/configs/server.config"; +import { AirlineController } from "@/modules/airline/airline.controller"; +import { AirlineMapper } from "@/modules/airline/airline.mapper"; +import { AirportController } from "@/modules/airport/airport.controller"; +import { AirportMapper } from "@/modules/airport/airport.mapper"; +import { CityController } from "@/modules/city/city.controller"; +import { CityMapper } from "@/modules/city/city.mapper"; +import { CountryController } from "@/modules/country/country.controller"; +import { CountryMapper } from "@/modules/country/country.mapper"; +import { FlightController } from "@/modules/flight/flight.controller"; +import { FlightMapper } from "@/modules/flight/flight.mapper"; +import { HotelFacilityController } from "@/modules/hotel-facility/hotel-facility.controller"; +import { HotelFacilityMapper } from "@/modules/hotel-facility/hotel-facility.mapper"; +import { HotelController } from "@/modules/hotel/hotel.controller"; +import { HotelMapper } from "@/modules/hotel/hotel.mapper"; +import { PackageController } from "@/modules/package/package.controller"; +import { PackageMapper } from "@/modules/package/package.mapper"; +import { TransportationController } from "@/modules/transportation/transportation.controller"; +import { TransportationMapper } from "@/modules/transportation/transportation.mapper"; import compression from "compression"; import cors from "cors"; import express from "express"; @@ -7,28 +27,73 @@ import helmet from "helmet"; export class Application { private readonly _app: express.Application; + // Services + private _fileStorage!: AbstractFileStorage; + public constructor() { this._app = express(); } + public initializeServices() { + this._fileStorage = new LocalFileStorage(); + } + public initializeMiddlewares() { this._app.use(helmet()); this._app.use(cors()); this._app.use(compression()); - this._app.use(express.json()); + this._app.use(express.json({ limit: "1mb" })); this._app.use(express.urlencoded()); } public initializeRouters() { - // this._app.use("/countries"); - // this._app.use("/cities"); - // this._app.use("/airlines"); - // this._app.use("/airports"); - // this._app.use("/flights"); - // this._app.use("/hotel-facilities"); - // this._app.use("/hotels"); - // this._app.use("/transportations"); - // this._app.use("/packages"); + const countryMapper = new CountryMapper(); + const cityMapper = new CityMapper(countryMapper); + const airlineMapper = new AirlineMapper(); + const airportMapper = new AirportMapper(cityMapper); + const flightMapper = new FlightMapper(airlineMapper, airportMapper); + const hotelFacilityMapper = new HotelFacilityMapper(); + const hotelMapper = new HotelMapper(cityMapper, hotelFacilityMapper); + const transportationMapper = new TransportationMapper(); + const packageMapper = new PackageMapper( + flightMapper, + hotelMapper, + transportationMapper, + ); + + const countryRouter = new CountryController(countryMapper).buildRouter(); + const cityRouter = new CityController(cityMapper).buildRouter(); + const airlineRouter = new AirlineController( + airlineMapper, + this._fileStorage, + ).buildRouter(); + const airportRouter = new AirportController(airportMapper).buildRouter(); + const flightRouter = new FlightController(flightMapper).buildRouter(); + const hotelFacilityRouter = new HotelFacilityController( + hotelFacilityMapper, + ).buildRouter(); + const hotelRouter = new HotelController( + hotelMapper, + this._fileStorage, + ).buildRouter(); + const transportationRouter = new TransportationController( + transportationMapper, + this._fileStorage, + ).buildRouter(); + const packageRouter = new PackageController( + packageMapper, + this._fileStorage, + ).buildRouter(); + + this._app.use("/countries", countryRouter); + this._app.use("/cities", cityRouter); + this._app.use("/airlines", airlineRouter); + this._app.use("/airports", airportRouter); + this._app.use("/flights", flightRouter); + this._app.use("/hotel-facilities", hotelFacilityRouter); + this._app.use("/hotels", hotelRouter); + this._app.use("/transportations", transportationRouter); + this._app.use("/packages", packageRouter); } public initializeErrorHandlers() {} diff --git a/src/common/controller.ts b/src/common/controller.ts new file mode 100644 index 0000000..4b8ffd0 --- /dev/null +++ b/src/common/controller.ts @@ -0,0 +1,22 @@ +import type { ErrorResponse } from "@/common/types"; +import type { Response, Router } from "express"; +import type z from "zod"; + +export abstract class Controller { + protected handleZodError( + zodError: z.ZodError, + res: Response, + location: string = "body", + ) { + res.status(422).json({ + data: null, + errors: zodError.issues.map((issue) => ({ + path: issue.path.join("."), + location, + message: issue.message, + })), + } satisfies ErrorResponse); + } + + public abstract buildRouter(): Router; +} diff --git a/src/common/errors/file-not-found.error.ts b/src/common/errors/file-not-found.error.ts new file mode 100644 index 0000000..831893d --- /dev/null +++ b/src/common/errors/file-not-found.error.ts @@ -0,0 +1,5 @@ +export class FileNotFoundError extends Error { + public constructor(fileName: string) { + super(`File '${fileName}' not found.`); + } +} diff --git a/src/common/errors/invalid-file-buffer.error.ts b/src/common/errors/invalid-file-buffer.error.ts new file mode 100644 index 0000000..4ab34fc --- /dev/null +++ b/src/common/errors/invalid-file-buffer.error.ts @@ -0,0 +1,8 @@ +export class InvalidFileBufferError extends Error { + public constructor( + public readonly buffer: Buffer, + message: string, + ) { + super(message); + } +} diff --git a/src/common/middlewares/orm.middleware.ts b/src/common/middlewares/orm.middleware.ts new file mode 100644 index 0000000..a155a56 --- /dev/null +++ b/src/common/middlewares/orm.middleware.ts @@ -0,0 +1,11 @@ +import { orm } from "@/database/orm"; +import { RequestContext } from "@mikro-orm/core"; +import type { NextFunction, Request, Response } from "express"; + +export function ormMiddleware( + _req: Request, + _res: Response, + next: NextFunction, +) { + RequestContext.create(orm.em, next); +} diff --git a/src/common/schemas.ts b/src/common/schemas.ts index cbafccd..384c5b5 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -1,6 +1,19 @@ import { isValid, parse } from "date-fns"; import z from "zod"; +export const paginationQuerySchema = z.object({ + page: z.coerce + .number("Must be number.") + .int("Must be integer.") + .min(1, "Minimum 1.") + .default(1), + per_page: z.coerce + .number("Must be number.") + .int("Must be integer.") + .min(1, "Minimum 1.") + .default(100), +}); + export const timeSchema = z .string("Must be string.") .nonempty("Must not be empty.") @@ -14,21 +27,21 @@ export const timeSchema = z }); return z.NEVER; } - return val; + return parsedDate; }); export const dateSchema = z .string("Must be string.") .nonempty("Must not be empty.") .transform((val, ctx) => { - const parsedDate = parse(val, "YYYY-MM-DD", new Date()); + const parsedDate = parse(val, "yyyy-MM-dd", new Date()); if (!isValid(parsedDate)) { ctx.issues.push({ code: "custom", - message: "Must be in 'YYYY-MM-DD' format.", + message: "Must be in 'yyyy-MM-dd' format.", input: val, }); return z.NEVER; } - return val; + return parsedDate; }); diff --git a/src/common/services/file-storage/abstract.file-storage.ts b/src/common/services/file-storage/abstract.file-storage.ts new file mode 100644 index 0000000..d849f66 --- /dev/null +++ b/src/common/services/file-storage/abstract.file-storage.ts @@ -0,0 +1,14 @@ +export type FileResult = { + name: string; + mimeType: string; + extension: string; + buffer: Buffer; +}; + +export abstract class AbstractFileStorage { + public abstract storeFile(buffer: Buffer, name?: string): Promise; + + public abstract retrieveFile(name: string): Promise; + + public abstract removeFile(name: string): Promise; +} diff --git a/src/common/services/file-storage/local.file-storage.ts b/src/common/services/file-storage/local.file-storage.ts new file mode 100644 index 0000000..23d47e5 --- /dev/null +++ b/src/common/services/file-storage/local.file-storage.ts @@ -0,0 +1,59 @@ +import { InvalidFileBufferError } from "@/common/errors/invalid-file-buffer.error"; +import { + AbstractFileStorage, + type FileResult, +} from "@/common/services/file-storage/abstract.file-storage"; +import { fileTypeFromBuffer } from "file-type"; +import { join } from "node:path"; + +const STORAGE_DIRECTORY = join(process.cwd(), "storage"); + +export class LocalFileStorage extends AbstractFileStorage { + public constructor() { + super(); + } + + public async storeFile(buffer: Buffer, name?: string): Promise { + const fileType = await fileTypeFromBuffer(buffer); + if (!fileType) { + throw new InvalidFileBufferError( + buffer, + "Unable to determine file type from buffer.", + ); + } + + const fileName = name ?? `${Date.now()}.${fileType.ext}`; + + const filePath = join(STORAGE_DIRECTORY, fileName); + + await Bun.write(filePath, buffer, { createPath: true }); + + return { + name: fileName, + buffer, + mimeType: fileType.mime, + extension: fileType.ext, + }; + } + + public async retrieveFile(name: string): Promise { + const filePath = join(STORAGE_DIRECTORY, name); + + const file = Bun.file(filePath); + + const fileName = file.name ?? name; + + return { + name: fileName, + buffer: Buffer.from(await file.arrayBuffer()), + mimeType: file.type, + extension: fileName.split(".").pop() ?? "", + }; + } + + public async removeFile(name: string): Promise { + const filePath = join(STORAGE_DIRECTORY, name); + + await Bun.file(filePath).delete(); + } +} diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..0dbbf31 --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,29 @@ +import type { paginationQuerySchema } from "@/common/schemas"; +import type z from "zod"; + +export type PaginationQuery = z.infer; + +export type SingleResponse = { + data: T; + errors: null; +}; + +export type ListResponse = { + data: T[]; + errors: null; + meta: { + page: number; + per_page: number; + total_pages: number; + total_items: number; + }; +}; + +export type ErrorResponse = { + data: null; + errors: { + path?: string; + location?: string; + message: string; + }[]; +}; diff --git a/src/database/entities/airline.entity.ts b/src/database/entities/airline.entity.ts index c5bffc6..50633c0 100644 --- a/src/database/entities/airline.entity.ts +++ b/src/database/entities/airline.entity.ts @@ -1,24 +1,11 @@ -import { Flight } from "@/database/entities/flight.entity"; import { SkytraxType } from "@/database/enums/skytrax-type.enum"; -import { - Collection, - Entity, - Enum, - OneToMany, - PrimaryKey, - Property, - Unique, -} from "@mikro-orm/core"; +import { Entity, Enum, PrimaryKey, Property, Unique } from "@mikro-orm/core"; @Entity() export class Airline { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 200 }) - @Unique() - slug!: string; - @Property({ type: "varchar", length: 100 }) name!: string; @@ -30,7 +17,7 @@ export class Airline { @Unique() logo!: string; - @Property({ type: "int", nullable: true }) + @Property({ type: "int", unsigned: true }) skytraxRating!: number; @Enum(() => SkytraxType) @@ -48,9 +35,4 @@ export class Airline { onUpdate: () => new Date(), }) updatedAt!: Date; - - // Collections - - @OneToMany(() => Flight, (flight) => flight.airline) - flights = new Collection(this); } diff --git a/src/database/entities/airport.entity.ts b/src/database/entities/airport.entity.ts index 4110d07..2336e76 100644 --- a/src/database/entities/airport.entity.ts +++ b/src/database/entities/airport.entity.ts @@ -1,10 +1,7 @@ import { City } from "@/database/entities/city.entity"; -import { Flight } from "@/database/entities/flight.entity"; import { - Collection, Entity, ManyToOne, - OneToMany, PrimaryKey, Property, type Rel, @@ -16,10 +13,6 @@ export class Airport { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 200 }) - @Unique() - slug!: string; - @Property({ type: "varchar", length: 100 }) name!: string; @@ -42,12 +35,4 @@ export class Airport { onUpdate: () => new Date(), }) updatedAt!: Date; - - // Collections - - @OneToMany(() => Flight, (flight) => flight.departureAirport) - departureFlights = new Collection(this); - - @OneToMany(() => Flight, (flight) => flight.arrivalAirport) - arrivalFlights = new Collection(this); } diff --git a/src/database/entities/city.entity.ts b/src/database/entities/city.entity.ts index f9f842e..eb4d126 100644 --- a/src/database/entities/city.entity.ts +++ b/src/database/entities/city.entity.ts @@ -1,14 +1,9 @@ -import { Airport } from "@/database/entities/airport.entity"; import { Country } from "@/database/entities/country.entity"; -import { Hotel } from "@/database/entities/hotel.entity"; import { - Collection, Entity, ManyToOne, - OneToMany, PrimaryKey, Property, - Unique, type Rel, } from "@mikro-orm/core"; @@ -17,10 +12,6 @@ export class City { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 200 }) - @Unique() - slug!: string; - @Property({ type: "varchar", length: 100 }) name!: string; @@ -39,12 +30,4 @@ export class City { onUpdate: () => new Date(), }) updatedAt!: Date; - - // Collections - - @OneToMany(() => Airport, (airport) => airport.city) - cities = new Collection(this); - - @OneToMany(() => Hotel, (hotel) => hotel.city) - hotels = new Collection(this); } diff --git a/src/database/entities/country.entity.ts b/src/database/entities/country.entity.ts index 700789a..1392855 100644 --- a/src/database/entities/country.entity.ts +++ b/src/database/entities/country.entity.ts @@ -1,22 +1,10 @@ -import { City } from "@/database/entities/city.entity"; -import { - Collection, - Entity, - OneToMany, - PrimaryKey, - Property, - Unique, -} from "@mikro-orm/core"; +import { Entity, PrimaryKey, Property } from "@mikro-orm/core"; @Entity() export class Country { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 200 }) - @Unique() - slug!: string; - @Property({ type: "varchar", length: 100 }) name!: string; @@ -32,9 +20,4 @@ export class Country { onUpdate: () => new Date(), }) updatedAt!: Date; - - // Collections - - @OneToMany(() => City, (city) => city.country) - cities = new Collection(this); } diff --git a/src/database/entities/flight-class.entity.ts b/src/database/entities/flight-class.entity.ts index 9e30cd2..049e833 100644 --- a/src/database/entities/flight-class.entity.ts +++ b/src/database/entities/flight-class.entity.ts @@ -1,9 +1,6 @@ import { Flight } from "@/database/entities/flight.entity"; -import { PackageDetail } from "@/database/entities/package-detail.entity"; import { - Collection, Entity, - ManyToMany, ManyToOne, PrimaryKey, Property, @@ -17,10 +14,6 @@ export class FlightClass { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 200 }) - @Unique() - slug!: string; - @ManyToOne(() => Flight) flight!: Rel; @@ -48,24 +41,4 @@ export class FlightClass { onUpdate: () => new Date(), }) updatedAt!: Date; - - // Collections - - @ManyToMany( - () => PackageDetail, - (packageDetail) => packageDetail.tourFlightClasses, - ) - tourPackageDetails = new Collection(this); - - @ManyToMany( - () => PackageDetail, - (packageDetail) => packageDetail.outboundFlightClasses, - ) - outboundPackageDetails = new Collection(this); - - @ManyToMany( - () => PackageDetail, - (packageDetail) => packageDetail.inboundFlightClasses, - ) - inboundPackageDetails = new Collection(this); } diff --git a/src/database/entities/flight-schedule.entity.ts b/src/database/entities/flight-schedule.entity.ts new file mode 100644 index 0000000..5791732 --- /dev/null +++ b/src/database/entities/flight-schedule.entity.ts @@ -0,0 +1,37 @@ +import { FlightClass } from "@/database/entities/flight-class.entity"; +import { + Cascade, + Entity, + ManyToOne, + PrimaryKey, + Property, + type Rel, +} from "@mikro-orm/core"; + +@Entity() +export class FlightSchedule { + @PrimaryKey({ type: "varchar", length: 30 }) + id!: string; + + @ManyToOne(() => FlightClass) + flight!: Rel; + + @ManyToOne(() => FlightSchedule, { + nullable: true, + cascade: [Cascade.REMOVE], + }) + next!: Rel | null; + + @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/flight.entity.ts b/src/database/entities/flight.entity.ts index 75b126c..01c70ef 100644 --- a/src/database/entities/flight.entity.ts +++ b/src/database/entities/flight.entity.ts @@ -2,6 +2,7 @@ import { Airline } from "@/database/entities/airline.entity"; import { Airport } from "@/database/entities/airport.entity"; import { FlightClass } from "@/database/entities/flight-class.entity"; import { + Cascade, Collection, Entity, ManyToOne, @@ -18,10 +19,6 @@ export class Flight { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 220 }) - @Unique() - slug!: string; - @ManyToOne(() => Airline) airline!: Rel; @@ -32,10 +29,10 @@ export class Flight { departureAirport!: Rel; @Property({ type: "varchar", length: 100, nullable: true }) - departureTerminal?: string; + departureTerminal!: string | null; @Property({ type: "varchar", length: 100, nullable: true }) - departureGate?: string; + departureGate!: string | null; @Property({ type: "time" }) departureTime!: string; @@ -44,10 +41,10 @@ export class Flight { arrivalAirport!: Rel; @Property({ type: "varchar", length: 100, nullable: true }) - arrivalTerminal?: string; + arrivalTerminal!: string | null; @Property({ type: "varchar", length: 100, nullable: true }) - arrivalGate?: string; + arrivalGate!: string | null; @Property({ type: "integer", unsigned: true }) duration!: number; @@ -70,6 +67,9 @@ export class Flight { // Collections - @OneToMany(() => FlightClass, (flightClass) => flightClass.flight) + @OneToMany(() => FlightClass, (flightClass) => flightClass.flight, { + orphanRemoval: true, + cascade: [Cascade.REMOVE], + }) classes = new Collection(this); } diff --git a/src/database/entities/hotel-facility.entity.ts b/src/database/entities/hotel-facility.entity.ts index afb39b4..eccda15 100644 --- a/src/database/entities/hotel-facility.entity.ts +++ b/src/database/entities/hotel-facility.entity.ts @@ -1,22 +1,10 @@ -import { Hotel } from "@/database/entities/hotel.entity"; -import { - Collection, - Entity, - ManyToMany, - PrimaryKey, - Property, - Unique, -} from "@mikro-orm/core"; +import { Entity, PrimaryKey, Property } from "@mikro-orm/core"; @Entity() export class HotelFacility { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 200 }) - @Unique() - slug!: string; - @Property({ type: "varchar", length: 100 }) name!: string; @@ -35,9 +23,4 @@ export class HotelFacility { onUpdate: () => new Date(), }) updatedAt!: Date; - - // Collections - - @ManyToMany(() => Hotel, (hotel) => hotel.facilities) - hotels = new Collection(this); } diff --git a/src/database/entities/hotel-schedule.entity.ts b/src/database/entities/hotel-schedule.entity.ts index c9b41c6..221d5b7 100644 --- a/src/database/entities/hotel-schedule.entity.ts +++ b/src/database/entities/hotel-schedule.entity.ts @@ -1,9 +1,6 @@ import { Hotel } from "@/database/entities/hotel.entity"; -import { PackageDetail } from "@/database/entities/package-detail.entity"; import { - Collection, Entity, - ManyToMany, ManyToOne, PrimaryKey, Property, @@ -36,9 +33,4 @@ export class HotelSchedule { onUpdate: () => new Date(), }) updatedAt!: Date; - - // Inverse side - - @ManyToMany(() => PackageDetail, (packageDetail) => packageDetail.tourHotels) - tourPackageDetails = new Collection(this); } diff --git a/src/database/entities/hotel.entity.ts b/src/database/entities/hotel.entity.ts index ef9257d..07cfa5c 100644 --- a/src/database/entities/hotel.entity.ts +++ b/src/database/entities/hotel.entity.ts @@ -2,6 +2,7 @@ import { City } from "@/database/entities/city.entity"; import { HotelFacility } from "@/database/entities/hotel-facility.entity"; import { HotelImage } from "@/database/entities/hotel-image.entity"; import { + Cascade, Collection, Entity, ManyToMany, @@ -9,7 +10,6 @@ import { OneToMany, PrimaryKey, Property, - Unique, type Rel, } from "@mikro-orm/core"; @@ -18,10 +18,6 @@ export class Hotel { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 200 }) - @Unique() - slug!: string; - @Property({ type: "varchar", length: 100 }) name!: string; @@ -31,18 +27,27 @@ export class Hotel { @Property({ type: "integer", unsigned: true }) star!: number; - @Property({ type: "varchar", length: 500 }) + @OneToMany(() => HotelImage, (hotelImage) => hotelImage.hotel, { + orphanRemoval: true, + cascade: [Cascade.REMOVE], + }) + images = new Collection(this); + + @Property({ type: "varchar", length: 1000 }) googleMapsLink!: string; - @Property({ type: "varchar", length: 500 }) + @Property({ type: "varchar", length: 1000 }) googleMapsEmbed!: string; - @Property({ type: "varchar", length: 500 }) + @Property({ type: "varchar", length: 1000 }) googleReviewsLink!: string; @Property({ type: "varchar", length: 1000 }) description!: string; + @ManyToMany(() => HotelFacility) + facilities = new Collection(this); + @Property({ type: "varchar", length: 100 }) address!: string; @@ -50,13 +55,13 @@ export class Hotel { landmark!: string; @Property({ type: "decimal" }) - distanceToLandmark!: string; + distanceToLandmark!: number; @Property({ type: "varchar", length: 100 }) foodType!: string; @Property({ type: "integer", unsigned: true }) - foodAmount!: string; + foodAmount!: number; @Property({ type: "varchar", length: 100 }) foodMenu!: string; @@ -73,14 +78,4 @@ export class Hotel { onUpdate: () => new Date(), }) updatedAt!: Date; - - // Collections - - @OneToMany(() => HotelImage, (hotelImage) => hotelImage.hotel) - images = new Collection(this); - - @ManyToMany(() => HotelFacility, (hotelFacility) => hotelFacility.hotels, { - owner: true, - }) - facilities = new Collection(this); } diff --git a/src/database/entities/package-detail.entity.ts b/src/database/entities/package-detail.entity.ts index f41afad..938daa8 100644 --- a/src/database/entities/package-detail.entity.ts +++ b/src/database/entities/package-detail.entity.ts @@ -1,8 +1,10 @@ -import { FlightClass } from "@/database/entities/flight-class.entity"; +import { FlightSchedule } from "@/database/entities/flight-schedule.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"; import { + Cascade, Collection, Entity, ManyToMany, @@ -19,60 +21,42 @@ export class PackageDetail { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 200 }) - @Unique() - slug!: string; - @ManyToOne(() => Package) package!: Rel; @Property({ type: "date" }) departureDate!: Date; - @ManyToMany( - () => FlightClass, - (flightClass) => flightClass.tourPackageDetails, - { - owner: true, - fixedOrder: true, - }, - ) - tourFlightClasses = new Collection(this); + @ManyToOne(() => FlightSchedule, { + cascade: [Cascade.REMOVE], + nullable: true, + }) + tourFlight!: Rel | null; - @ManyToMany( - () => FlightClass, - (flightClass) => flightClass.outboundPackageDetails, - { - owner: true, - fixedOrder: true, - }, - ) - outboundFlightClasses = new Collection(this); + @ManyToOne(() => FlightSchedule, { + cascade: [Cascade.REMOVE], + }) + outboundFlight!: Rel; - @ManyToMany( - () => FlightClass, - (flightClass) => flightClass.inboundPackageDetails, - { - owner: true, - fixedOrder: true, - }, - ) - inboundFlightClasses = new Collection(this); + @ManyToOne(() => FlightSchedule, { + cascade: [Cascade.REMOVE], + }) + inboundFlight!: Rel; - @ManyToMany( - () => HotelSchedule, - (hotelSchedule) => hotelSchedule.tourPackageDetails, - { - owner: true, - fixedOrder: true, - }, - ) + @ManyToMany(() => HotelSchedule, undefined, { + owner: true, + cascade: [Cascade.REMOVE], + }) tourHotels = new Collection(this); - @ManyToOne(() => HotelSchedule) + @ManyToOne(() => HotelSchedule, { + cascade: [Cascade.REMOVE], + }) makkahHotel!: Rel; - @ManyToOne(() => HotelSchedule) + @ManyToOne(() => HotelSchedule, { + cascade: [Cascade.REMOVE], + }) madinahHotel!: Rel; @ManyToOne(() => TransportationClass) @@ -88,7 +72,12 @@ export class PackageDetail { doublePrice!: number; @Property({ type: "decimal", nullable: true, unsigned: true }) - infantPrice?: number; + infantPrice!: number | null; + + @ManyToOne(() => PackageItinerary, { + cascade: [Cascade.REMOVE], + }) + itinerary!: Rel; @Property({ type: "timestamp", diff --git a/src/database/entities/package-itinerary-day.entity.ts b/src/database/entities/package-itinerary-day.entity.ts new file mode 100644 index 0000000..52a61c7 --- /dev/null +++ b/src/database/entities/package-itinerary-day.entity.ts @@ -0,0 +1,52 @@ +import { PackageItineraryWidget } from "@/database/entities/package-itinerary-widget.entity"; +import { + Cascade, + Collection, + Entity, + ManyToOne, + OneToMany, + PrimaryKey, + Property, + type Rel, +} from "@mikro-orm/core"; + +@Entity() +export class PackageItineraryDay { + @PrimaryKey({ type: "varchar", length: 30 }) + id!: string; + + @Property({ type: "varchar", length: 100 }) + title!: string; + + @Property({ type: "varchar", length: 1000 }) + description!: string; + + @OneToMany( + () => PackageItineraryWidget, + (widget) => widget.packageItineraryDay, + { + orphanRemoval: true, + cascade: [Cascade.REMOVE], + }, + ) + widgets = new Collection(this); + + @ManyToOne(() => PackageItineraryDay, { + nullable: true, + cascade: [Cascade.REMOVE], + }) + next!: Rel | null; + + @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-itinerary-image.entity.ts b/src/database/entities/package-itinerary-image.entity.ts new file mode 100644 index 0000000..4ca5cae --- /dev/null +++ b/src/database/entities/package-itinerary-image.entity.ts @@ -0,0 +1,34 @@ +import { PackageItinerary } from "@/database/entities/package-itinerary.entity"; +import { + Entity, + ManyToOne, + PrimaryKey, + Property, + type Rel, +} from "@mikro-orm/core"; + +@Entity() +export class PackageItineraryImage { + @PrimaryKey({ type: "varchar", length: 30 }) + id!: string; + + @ManyToOne(() => PackageItinerary) + packageItinerary!: Rel; + + @Property({ type: "varchar", length: 100 }) + src!: string; + + @Property({ type: "varchar", length: 100 }) + @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-itinerary-widget.entity.ts b/src/database/entities/package-itinerary-widget.entity.ts new file mode 100644 index 0000000..4b558d6 --- /dev/null +++ b/src/database/entities/package-itinerary-widget.entity.ts @@ -0,0 +1,63 @@ +import { Hotel } from "@/database/entities/hotel.entity"; +import { PackageItineraryDay } from "@/database/entities/package-itinerary-day.entity"; +import { PackageItineraryWidgetType } from "@/database/enums/package-itinerary-widget-type.enum"; +import { + Entity, + Enum, + ManyToOne, + PrimaryKey, + Property, + type Rel, +} from "@mikro-orm/core"; + +@Entity({ + abstract: true, + discriminatorColumn: "type", +}) +export abstract class PackageItineraryWidget { + @PrimaryKey({ type: "varchar", length: 30 }) + id!: string; + + @ManyToOne(() => PackageItineraryDay) + packageItineraryDay!: Rel; + + @Enum(() => PackageItineraryWidgetType) + type!: PackageItineraryWidgetType; + + @Property({ + type: "timestamp", + onCreate: () => new Date(), + }) + createdAt!: Date; + + @Property({ + type: "timestamp", + onCreate: () => new Date(), + onUpdate: () => new Date(), + }) + updatedAt!: Date; +} + +@Entity({ discriminatorValue: "transport" }) +export class PackageItineraryWidgetTransport extends PackageItineraryWidget { + @Property({ type: "varchar", length: 100 }) + transportation!: string; + + @Property({ type: "varchar", length: 100 }) + from!: string; + + @Property({ type: "varchar", length: 100 }) + to!: string; +} + +@Entity({ discriminatorValue: "hotel" }) +export class PackageItineraryWidgetHotel extends PackageItineraryWidget { + @ManyToOne(() => Hotel) + hotel!: Rel; +} + +@Entity({ discriminatorValue: "information" }) +export class PackageItineraryWidgetInformation extends PackageItineraryWidget { + @Property({ type: "varchar", length: 1000 }) + description!: string; +} diff --git a/src/database/entities/package-itinerary.entity.ts b/src/database/entities/package-itinerary.entity.ts new file mode 100644 index 0000000..891a2a5 --- /dev/null +++ b/src/database/entities/package-itinerary.entity.ts @@ -0,0 +1,55 @@ +import { PackageItineraryDay } from "@/database/entities/package-itinerary-day.entity"; +import { PackageItineraryImage } from "@/database/entities/package-itinerary-image.entity"; +import { + Cascade, + Collection, + Entity, + ManyToOne, + OneToMany, + PrimaryKey, + Property, + type Rel, +} from "@mikro-orm/core"; + +@Entity() +export class PackageItinerary { + @PrimaryKey({ type: "varchar", length: 30 }) + id!: string; + + @Property({ type: "varchar", length: 100 }) + location!: string; + + @OneToMany( + () => PackageItineraryImage, + (packageItineraryImage) => packageItineraryImage.packageItinerary, + { + orphanRemoval: true, + cascade: [Cascade.REMOVE], + }, + ) + images = new Collection(this); + + @ManyToOne(() => PackageItineraryDay, { + cascade: [Cascade.REMOVE], + }) + day!: Rel; + + @ManyToOne(() => PackageItinerary, { + nullable: true, + cascade: [Cascade.REMOVE], + }) + next!: Rel | null; + + @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.entity.ts b/src/database/entities/package.entity.ts index 11ec27f..a6c3034 100644 --- a/src/database/entities/package.entity.ts +++ b/src/database/entities/package.entity.ts @@ -2,6 +2,7 @@ import { PackageDetail } from "@/database/entities/package-detail.entity"; import { PackageClass } from "@/database/enums/package-class.enum"; import { PackageType } from "@/database/enums/package-type.enum"; import { + Cascade, Collection, Entity, Enum, @@ -16,10 +17,6 @@ export class Package { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 200 }) - @Unique() - slug!: string; - @Property({ type: "varchar", length: 100 }) name!: string; @@ -51,6 +48,9 @@ export class Package { // Collections - @OneToMany(() => PackageDetail, (packageDetail) => packageDetail.package) + @OneToMany(() => PackageDetail, (packageDetail) => packageDetail.package, { + orphanRemoval: true, + cascade: [Cascade.REMOVE], + }) details = new Collection(this); } diff --git a/src/database/entities/transportation-class.entity.ts b/src/database/entities/transportation-class.entity.ts index dc62755..1527c9c 100644 --- a/src/database/entities/transportation-class.entity.ts +++ b/src/database/entities/transportation-class.entity.ts @@ -1,7 +1,7 @@ -import { PackageDetail } from "@/database/entities/package-detail.entity"; -import { TransportationClassImage } from "@/database/entities/transportation-class-image.entity"; +import { TransportationImage } from "@/database/entities/transportation-image.entity"; import { Transportation } from "@/database/entities/transportation.entity"; import { + Cascade, Collection, Entity, ManyToOne, @@ -18,10 +18,6 @@ export class TransportationClass { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 200 }) - @Unique() - slug!: string; - @ManyToOne(() => Transportation) transportation!: Rel; @@ -31,6 +27,16 @@ export class TransportationClass { @Property({ type: "integer", unsigned: true }) totalSeats!: number; + @OneToMany( + () => TransportationImage, + (transportationClassImage) => transportationClassImage.transportation, + { + orphanRemoval: true, + cascade: [Cascade.REMOVE], + }, + ) + images = new Collection(this); + @Property({ type: "timestamp", onCreate: () => new Date(), @@ -43,18 +49,4 @@ export class TransportationClass { onUpdate: () => new Date(), }) updatedAt!: Date; - - // Collections - - @OneToMany( - () => TransportationClassImage, - (transportationClassImage) => transportationClassImage.transportationClass, - ) - images = new Collection(this); - - @OneToMany( - () => PackageDetail, - (packageDetail) => packageDetail.transportation, - ) - packageDetails = new Collection(this); } diff --git a/src/database/entities/transportation-class-image.entity.ts b/src/database/entities/transportation-image.entity.ts similarity index 87% rename from src/database/entities/transportation-class-image.entity.ts rename to src/database/entities/transportation-image.entity.ts index a0199ab..a4992f0 100644 --- a/src/database/entities/transportation-class-image.entity.ts +++ b/src/database/entities/transportation-image.entity.ts @@ -8,12 +8,12 @@ import { } from "@mikro-orm/core"; @Entity() -export class TransportationClassImage { +export class TransportationImage { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; @ManyToOne(() => TransportationClass) - transportationClass!: Rel; + transportation!: Rel; @Property({ type: "varchar", length: 100 }) src!: string; diff --git a/src/database/entities/transportation.entity.ts b/src/database/entities/transportation.entity.ts index 06b35bd..e03a9e2 100644 --- a/src/database/entities/transportation.entity.ts +++ b/src/database/entities/transportation.entity.ts @@ -1,11 +1,11 @@ import { TransportationClass } from "@/database/entities/transportation-class.entity"; import { + Cascade, Collection, Entity, OneToMany, PrimaryKey, Property, - Unique, } from "@mikro-orm/core"; @Entity() @@ -13,10 +13,6 @@ export class Transportation { @PrimaryKey({ type: "varchar", length: 30 }) id!: string; - @Property({ type: "varchar", length: 100 }) - @Unique() - slug!: string; - @Property({ type: "varchar", length: 100 }) name!: string; @@ -41,6 +37,10 @@ export class Transportation { @OneToMany( () => TransportationClass, (transportationClass) => transportationClass.transportation, + { + orphanRemoval: true, + cascade: [Cascade.REMOVE], + }, ) - transportationClasses = new Collection(this); + classes = new Collection(this); } diff --git a/src/database/enums/package-itinerary-widget-type.enum.ts b/src/database/enums/package-itinerary-widget-type.enum.ts new file mode 100644 index 0000000..3cff85a --- /dev/null +++ b/src/database/enums/package-itinerary-widget-type.enum.ts @@ -0,0 +1,5 @@ +export enum PackageItineraryWidgetType { + transport = "transport", + hotel = "hotel", + information = "information", +} diff --git a/src/database/migrations/.snapshot-goumrah.json b/src/database/migrations/.snapshot-goumrah.json index 7cbc149..b800745 100644 --- a/src/database/migrations/.snapshot-goumrah.json +++ b/src/database/migrations/.snapshot-goumrah.json @@ -16,16 +16,6 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", - "type": "varchar(200)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 200, - "mappedType": "string" - }, "name": { "name": "name", "type": "varchar(100)", @@ -59,10 +49,10 @@ "skytrax_rating": { "name": "skytrax_rating", "type": "int", - "unsigned": false, + "unsigned": true, "autoincrement": false, "primary": false, - "nullable": true, + "nullable": false, "mappedType": "integer" }, "skytrax_type": { @@ -102,16 +92,6 @@ "name": "airline", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "airline_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "columnNames": [ "code" @@ -159,16 +139,6 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", - "type": "varchar(200)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 200, - "mappedType": "string" - }, "name": { "name": "name", "type": "varchar(100)", @@ -203,16 +173,6 @@ "name": "country", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "country_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "keyName": "country_pkey", "columnNames": [ @@ -240,16 +200,6 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", - "type": "varchar(200)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 200, - "mappedType": "string" - }, "name": { "name": "name", "type": "varchar(100)", @@ -294,16 +244,6 @@ "name": "city", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "city_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "keyName": "city_pkey", "columnNames": [ @@ -344,16 +284,6 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", - "type": "varchar(200)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 200, - "mappedType": "string" - }, "name": { "name": "name", "type": "varchar(100)", @@ -408,16 +338,6 @@ "name": "airport", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "airport_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "columnNames": [ "code" @@ -468,16 +388,6 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", - "type": "varchar(220)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 220, - "mappedType": "string" - }, "airline_id": { "name": "airline_id", "type": "varchar(30)", @@ -610,16 +520,6 @@ "name": "flight", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "flight_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "keyName": "flight_airline_id_number_unique", "columnNames": [ @@ -695,16 +595,6 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", - "type": "varchar(200)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 200, - "mappedType": "string" - }, "flight_id": { "name": "flight_id", "type": "varchar(30)", @@ -777,16 +667,6 @@ "name": "flight_class", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "flight_class_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "keyName": "flight_class_flight_id_class_unique", "columnNames": [ @@ -838,14 +718,100 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", - "type": "varchar(200)", + "flight_id": { + "name": "flight_id", + "type": "varchar(30)", "unsigned": false, "autoincrement": false, "primary": false, "nullable": false, - "length": 200, + "length": 30, + "mappedType": "string" + }, + "next_id": { + "name": "next_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 30, + "mappedType": "string" + }, + "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": "flight_schedule", + "schema": "public", + "indexes": [ + { + "keyName": "flight_schedule_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "flight_schedule_flight_id_foreign": { + "constraintName": "flight_schedule_flight_id_foreign", + "columnNames": [ + "flight_id" + ], + "localTableName": "public.flight_schedule", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.flight_class", + "updateRule": "cascade" + }, + "flight_schedule_next_id_foreign": { + "constraintName": "flight_schedule_next_id_foreign", + "columnNames": [ + "next_id" + ], + "localTableName": "public.flight_schedule", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.flight_schedule", + "deleteRule": "cascade" + } + }, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 30, "mappedType": "string" }, "name": { @@ -879,32 +845,32 @@ }, "google_maps_link": { "name": "google_maps_link", - "type": "varchar(500)", + "type": "varchar(1000)", "unsigned": false, "autoincrement": false, "primary": false, "nullable": false, - "length": 500, + "length": 1000, "mappedType": "string" }, "google_maps_embed": { "name": "google_maps_embed", - "type": "varchar(500)", + "type": "varchar(1000)", "unsigned": false, "autoincrement": false, "primary": false, "nullable": false, - "length": 500, + "length": 1000, "mappedType": "string" }, "google_reviews_link": { "name": "google_reviews_link", - "type": "varchar(500)", + "type": "varchar(1000)", "unsigned": false, "autoincrement": false, "primary": false, "nullable": false, - "length": 500, + "length": 1000, "mappedType": "string" }, "description": { @@ -1001,16 +967,6 @@ "name": "hotel", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "hotel_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "keyName": "hotel_pkey", "columnNames": [ @@ -1051,16 +1007,6 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", - "type": "varchar(200)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 200, - "mappedType": "string" - }, "name": { "name": "name", "type": "varchar(100)", @@ -1105,16 +1051,6 @@ "name": "hotel_facility", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "hotel_facility_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "keyName": "hotel_facility_pkey", "columnNames": [ @@ -1389,16 +1325,6 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", - "type": "varchar(200)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 200, - "mappedType": "string" - }, "name": { "name": "name", "type": "varchar(100)", @@ -1479,16 +1405,6 @@ "name": "package", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "package_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "columnNames": [ "thumbnail" @@ -1526,8 +1442,8 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", + "title": { + "name": "title", "type": "varchar(100)", "unsigned": false, "autoincrement": false, @@ -1536,6 +1452,441 @@ "length": 100, "mappedType": "string" }, + "description": { + "name": "description", + "type": "varchar(1000)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 1000, + "mappedType": "string" + }, + "next_id": { + "name": "next_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 30, + "mappedType": "string" + }, + "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": "package_itinerary_day", + "schema": "public", + "indexes": [ + { + "keyName": "package_itinerary_day_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "package_itinerary_day_next_id_foreign": { + "constraintName": "package_itinerary_day_next_id_foreign", + "columnNames": [ + "next_id" + ], + "localTableName": "public.package_itinerary_day", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.package_itinerary_day", + "deleteRule": "cascade" + } + }, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 30, + "mappedType": "string" + }, + "location": { + "name": "location", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 100, + "mappedType": "string" + }, + "day_id": { + "name": "day_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 30, + "mappedType": "string" + }, + "next_id": { + "name": "next_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 30, + "mappedType": "string" + }, + "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": "package_itinerary", + "schema": "public", + "indexes": [ + { + "keyName": "package_itinerary_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "package_itinerary_day_id_foreign": { + "constraintName": "package_itinerary_day_id_foreign", + "columnNames": [ + "day_id" + ], + "localTableName": "public.package_itinerary", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.package_itinerary_day", + "deleteRule": "cascade" + }, + "package_itinerary_next_id_foreign": { + "constraintName": "package_itinerary_next_id_foreign", + "columnNames": [ + "next_id" + ], + "localTableName": "public.package_itinerary", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.package_itinerary", + "deleteRule": "cascade" + } + }, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 30, + "mappedType": "string" + }, + "package_itinerary_id": { + "name": "package_itinerary_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 30, + "mappedType": "string" + }, + "src": { + "name": "src", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 100, + "mappedType": "string" + }, + "created_at": { + "name": "created_at", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 100, + "mappedType": "string" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "package_itinerary_image", + "schema": "public", + "indexes": [ + { + "keyName": "package_itinerary_image_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "package_itinerary_image_package_itinerary_id_foreign": { + "constraintName": "package_itinerary_image_package_itinerary_id_foreign", + "columnNames": [ + "package_itinerary_id" + ], + "localTableName": "public.package_itinerary_image", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.package_itinerary", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 30, + "mappedType": "string" + }, + "package_itinerary_day_id": { + "name": "package_itinerary_day_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 30, + "mappedType": "string" + }, + "type": { + "name": "type", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "enumItems": [ + "transport", + "hotel", + "information" + ], + "mappedType": "enum" + }, + "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" + }, + "hotel_id": { + "name": "hotel_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 30, + "mappedType": "string" + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 1000, + "mappedType": "string" + }, + "transportation": { + "name": "transportation", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 100, + "mappedType": "string" + }, + "from": { + "name": "from", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 100, + "mappedType": "string" + }, + "to": { + "name": "to", + "type": "varchar(100)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 100, + "mappedType": "string" + } + }, + "name": "package_itinerary_widget", + "schema": "public", + "indexes": [ + { + "columnNames": [ + "type" + ], + "composite": false, + "keyName": "package_itinerary_widget_type_index", + "constraint": false, + "primary": false, + "unique": false + }, + { + "keyName": "package_itinerary_widget_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "package_itinerary_widget_package_itinerary_day_id_foreign": { + "constraintName": "package_itinerary_widget_package_itinerary_day_id_foreign", + "columnNames": [ + "package_itinerary_day_id" + ], + "localTableName": "public.package_itinerary_widget", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.package_itinerary_day", + "updateRule": "cascade" + }, + "package_itinerary_widget_hotel_id_foreign": { + "constraintName": "package_itinerary_widget_hotel_id_foreign", + "columnNames": [ + "hotel_id" + ], + "localTableName": "public.package_itinerary_widget", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.hotel", + "deleteRule": "set null", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 30, + "mappedType": "string" + }, "name": { "name": "name", "type": "varchar(100)", @@ -1580,16 +1931,6 @@ "name": "transportation", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "transportation_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "keyName": "transportation_pkey", "columnNames": [ @@ -1617,16 +1958,6 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", - "type": "varchar(200)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 200, - "mappedType": "string" - }, "transportation_id": { "name": "transportation_id", "type": "varchar(30)", @@ -1680,16 +2011,6 @@ "name": "transportation_class", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "transportation_class_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "keyName": "transportation_class_transportation_id_class_unique", "columnNames": [ @@ -1741,16 +2062,6 @@ "length": 30, "mappedType": "string" }, - "slug": { - "name": "slug", - "type": "varchar(200)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 200, - "mappedType": "string" - }, "package_id": { "name": "package_id", "type": "varchar(30)", @@ -1771,13 +2082,43 @@ "length": 0, "mappedType": "date" }, + "tour_flight_id": { + "name": "tour_flight_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 30, + "mappedType": "string" + }, + "outbound_flight_id": { + "name": "outbound_flight_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 30, + "mappedType": "string" + }, + "inbound_flight_id": { + "name": "inbound_flight_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 30, + "mappedType": "string" + }, "makkah_hotel_id": { "name": "makkah_hotel_id", "type": "varchar(30)", "unsigned": false, "autoincrement": false, "primary": false, - "nullable": false, + "nullable": true, "length": 30, "mappedType": "string" }, @@ -1787,7 +2128,7 @@ "unsigned": false, "autoincrement": false, "primary": false, - "nullable": false, + "nullable": true, "length": 30, "mappedType": "string" }, @@ -1845,6 +2186,16 @@ "scale": 0, "mappedType": "decimal" }, + "itinerary_id": { + "name": "itinerary_id", + "type": "varchar(30)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 30, + "mappedType": "string" + }, "created_at": { "name": "created_at", "type": "timestamptz", @@ -1869,16 +2220,6 @@ "name": "package_detail", "schema": "public", "indexes": [ - { - "columnNames": [ - "slug" - ], - "composite": false, - "keyName": "package_detail_slug_unique", - "constraint": true, - "primary": false, - "unique": true - }, { "keyName": "package_detail_package_id_departure_date_unique", "columnNames": [ @@ -1915,6 +2256,42 @@ "referencedTableName": "public.package", "updateRule": "cascade" }, + "package_detail_tour_flight_id_foreign": { + "constraintName": "package_detail_tour_flight_id_foreign", + "columnNames": [ + "tour_flight_id" + ], + "localTableName": "public.package_detail", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.flight_schedule", + "deleteRule": "cascade" + }, + "package_detail_outbound_flight_id_foreign": { + "constraintName": "package_detail_outbound_flight_id_foreign", + "columnNames": [ + "outbound_flight_id" + ], + "localTableName": "public.package_detail", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.flight_schedule", + "deleteRule": "cascade" + }, + "package_detail_inbound_flight_id_foreign": { + "constraintName": "package_detail_inbound_flight_id_foreign", + "columnNames": [ + "inbound_flight_id" + ], + "localTableName": "public.package_detail", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.flight_schedule", + "deleteRule": "cascade" + }, "package_detail_makkah_hotel_id_foreign": { "constraintName": "package_detail_makkah_hotel_id_foreign", "columnNames": [ @@ -1925,7 +2302,7 @@ "id" ], "referencedTableName": "public.hotel_schedule", - "updateRule": "cascade" + "deleteRule": "cascade" }, "package_detail_madinah_hotel_id_foreign": { "constraintName": "package_detail_madinah_hotel_id_foreign", @@ -1937,7 +2314,7 @@ "id" ], "referencedTableName": "public.hotel_schedule", - "updateRule": "cascade" + "deleteRule": "cascade" }, "package_detail_transportation_id_foreign": { "constraintName": "package_detail_transportation_id_foreign", @@ -1950,21 +2327,24 @@ ], "referencedTableName": "public.transportation_class", "updateRule": "cascade" + }, + "package_detail_itinerary_id_foreign": { + "constraintName": "package_detail_itinerary_id_foreign", + "columnNames": [ + "itinerary_id" + ], + "localTableName": "public.package_detail", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.package_itinerary", + "deleteRule": "cascade" } }, "nativeEnums": {} }, { "columns": { - "id": { - "name": "id", - "type": "serial", - "unsigned": false, - "autoincrement": true, - "primary": true, - "nullable": false, - "mappedType": "integer" - }, "package_detail_id": { "name": "package_detail_id", "type": "varchar(30)", @@ -1992,9 +2372,10 @@ { "keyName": "package_detail_tour_hotels_pkey", "columnNames": [ - "id" + "package_detail_id", + "hotel_schedule_id" ], - "composite": false, + "composite": true, "constraint": true, "primary": true, "unique": true @@ -2035,15 +2416,6 @@ "columns": { "id": { "name": "id", - "type": "serial", - "unsigned": false, - "autoincrement": true, - "primary": true, - "nullable": false, - "mappedType": "integer" - }, - "package_detail_id": { - "name": "package_detail_id", "type": "varchar(30)", "unsigned": false, "autoincrement": false, @@ -2052,230 +2424,8 @@ "length": 30, "mappedType": "string" }, - "flight_class_id": { - "name": "flight_class_id", - "type": "varchar(30)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 30, - "mappedType": "string" - } - }, - "name": "package_detail_tour_flight_classes", - "schema": "public", - "indexes": [ - { - "keyName": "package_detail_tour_flight_classes_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": { - "package_detail_tour_flight_classes_package_detail_id_foreign": { - "constraintName": "package_detail_tour_flight_classes_package_detail_id_foreign", - "columnNames": [ - "package_detail_id" - ], - "localTableName": "public.package_detail_tour_flight_classes", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.package_detail", - "deleteRule": "cascade", - "updateRule": "cascade" - }, - "package_detail_tour_flight_classes_flight_class_id_foreign": { - "constraintName": "package_detail_tour_flight_classes_flight_class_id_foreign", - "columnNames": [ - "flight_class_id" - ], - "localTableName": "public.package_detail_tour_flight_classes", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.flight_class", - "deleteRule": "cascade", - "updateRule": "cascade" - } - }, - "nativeEnums": {} - }, - { - "columns": { - "id": { - "name": "id", - "type": "serial", - "unsigned": false, - "autoincrement": true, - "primary": true, - "nullable": false, - "mappedType": "integer" - }, - "package_detail_id": { - "name": "package_detail_id", - "type": "varchar(30)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 30, - "mappedType": "string" - }, - "flight_class_id": { - "name": "flight_class_id", - "type": "varchar(30)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 30, - "mappedType": "string" - } - }, - "name": "package_detail_outbound_flight_classes", - "schema": "public", - "indexes": [ - { - "keyName": "package_detail_outbound_flight_classes_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": { - "package_detail_outbound_flight_classes_package_d_8b70d_foreign": { - "constraintName": "package_detail_outbound_flight_classes_package_d_8b70d_foreign", - "columnNames": [ - "package_detail_id" - ], - "localTableName": "public.package_detail_outbound_flight_classes", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.package_detail", - "deleteRule": "cascade", - "updateRule": "cascade" - }, - "package_detail_outbound_flight_classes_flight_class_id_foreign": { - "constraintName": "package_detail_outbound_flight_classes_flight_class_id_foreign", - "columnNames": [ - "flight_class_id" - ], - "localTableName": "public.package_detail_outbound_flight_classes", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.flight_class", - "deleteRule": "cascade", - "updateRule": "cascade" - } - }, - "nativeEnums": {} - }, - { - "columns": { - "id": { - "name": "id", - "type": "serial", - "unsigned": false, - "autoincrement": true, - "primary": true, - "nullable": false, - "mappedType": "integer" - }, - "package_detail_id": { - "name": "package_detail_id", - "type": "varchar(30)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 30, - "mappedType": "string" - }, - "flight_class_id": { - "name": "flight_class_id", - "type": "varchar(30)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 30, - "mappedType": "string" - } - }, - "name": "package_detail_inbound_flight_classes", - "schema": "public", - "indexes": [ - { - "keyName": "package_detail_inbound_flight_classes_pkey", - "columnNames": [ - "id" - ], - "composite": false, - "constraint": true, - "primary": true, - "unique": true - } - ], - "checks": [], - "foreignKeys": { - "package_detail_inbound_flight_classes_package_detail_id_foreign": { - "constraintName": "package_detail_inbound_flight_classes_package_detail_id_foreign", - "columnNames": [ - "package_detail_id" - ], - "localTableName": "public.package_detail_inbound_flight_classes", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.package_detail", - "deleteRule": "cascade", - "updateRule": "cascade" - }, - "package_detail_inbound_flight_classes_flight_class_id_foreign": { - "constraintName": "package_detail_inbound_flight_classes_flight_class_id_foreign", - "columnNames": [ - "flight_class_id" - ], - "localTableName": "public.package_detail_inbound_flight_classes", - "referencedColumnNames": [ - "id" - ], - "referencedTableName": "public.flight_class", - "deleteRule": "cascade", - "updateRule": "cascade" - } - }, - "nativeEnums": {} - }, - { - "columns": { - "id": { - "name": "id", - "type": "varchar(30)", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "length": 30, - "mappedType": "string" - }, - "transportation_class_id": { - "name": "transportation_class_id", + "transportation_id": { + "name": "transportation_id", "type": "varchar(30)", "unsigned": false, "autoincrement": false, @@ -2315,11 +2465,11 @@ "mappedType": "datetime" } }, - "name": "transportation_class_image", + "name": "transportation_image", "schema": "public", "indexes": [ { - "keyName": "transportation_class_image_pkey", + "keyName": "transportation_image_pkey", "columnNames": [ "id" ], @@ -2331,12 +2481,12 @@ ], "checks": [], "foreignKeys": { - "transportation_class_image_transportation_class_id_foreign": { - "constraintName": "transportation_class_image_transportation_class_id_foreign", + "transportation_image_transportation_id_foreign": { + "constraintName": "transportation_image_transportation_id_foreign", "columnNames": [ - "transportation_class_id" + "transportation_id" ], - "localTableName": "public.transportation_class_image", + "localTableName": "public.transportation_image", "referencedColumnNames": [ "id" ], diff --git a/src/database/migrations/Migration20251110080126.ts b/src/database/migrations/Migration20251110080126.ts new file mode 100644 index 0000000..01b9ab7 --- /dev/null +++ b/src/database/migrations/Migration20251110080126.ts @@ -0,0 +1,226 @@ +import { Migration } from "@mikro-orm/migrations"; + +export class Migration20251110080126 extends Migration { + override async up(): Promise { + this.addSql( + `create table "flight_schedule" ("id" varchar(30) not null, "flight_id" varchar(30) not null, "next_id" varchar(30) null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "flight_schedule_pkey" primary key ("id"));`, + ); + + this.addSql( + `create table "package_itinerary_day" ("id" varchar(30) not null, "title" varchar(100) not null, "description" varchar(1000) not null, "next_id" varchar(30) null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "package_itinerary_day_pkey" primary key ("id"));`, + ); + + this.addSql( + `create table "package_itinerary" ("id" varchar(30) not null, "location" varchar(100) not null, "day_id" varchar(30) not null, "next_id" varchar(30) null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "package_itinerary_pkey" primary key ("id"));`, + ); + + this.addSql( + `create table "package_itinerary_image" ("id" varchar(30) not null, "package_itinerary_id" varchar(30) not null, "src" varchar(100) not null, "created_at" varchar(100) not null, "updated_at" timestamptz not null, constraint "package_itinerary_image_pkey" primary key ("id"));`, + ); + + this.addSql( + `create table "package_itinerary_widget" ("id" varchar(30) not null, "package_itinerary_day_id" varchar(30) not null, "type" text check ("type" in ('transport', 'hotel', 'information')) not null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "hotel_id" varchar(30) null, "description" varchar(1000) null, "transportation" varchar(100) null, "from" varchar(100) null, "to" varchar(100) null, constraint "package_itinerary_widget_pkey" primary key ("id"));`, + ); + this.addSql( + `create index "package_itinerary_widget_type_index" on "package_itinerary_widget" ("type");`, + ); + + this.addSql( + `create table "transportation_image" ("id" varchar(30) not null, "transportation_id" varchar(30) not null, "src" varchar(100) not null, "created_at" varchar(100) not null, "updated_at" timestamptz not null, constraint "transportation_image_pkey" primary key ("id"));`, + ); + + this.addSql( + `alter table "flight_schedule" add constraint "flight_schedule_flight_id_foreign" foreign key ("flight_id") references "flight_class" ("id") on update cascade;`, + ); + this.addSql( + `alter table "flight_schedule" add constraint "flight_schedule_next_id_foreign" foreign key ("next_id") references "flight_schedule" ("id") on update cascade on delete set null;`, + ); + + this.addSql( + `alter table "package_itinerary_day" add constraint "package_itinerary_day_next_id_foreign" foreign key ("next_id") references "package_itinerary_day" ("id") on update cascade on delete set null;`, + ); + + this.addSql( + `alter table "package_itinerary" add constraint "package_itinerary_day_id_foreign" foreign key ("day_id") references "package_itinerary_day" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_itinerary" add constraint "package_itinerary_next_id_foreign" foreign key ("next_id") references "package_itinerary" ("id") on update cascade on delete set null;`, + ); + + this.addSql( + `alter table "package_itinerary_image" add constraint "package_itinerary_image_package_itinerary_id_foreign" foreign key ("package_itinerary_id") references "package_itinerary" ("id") on update cascade;`, + ); + + this.addSql( + `alter table "package_itinerary_widget" add constraint "package_itinerary_widget_package_itinerary_day_id_foreign" foreign key ("package_itinerary_day_id") references "package_itinerary_day" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_itinerary_widget" add constraint "package_itinerary_widget_hotel_id_foreign" foreign key ("hotel_id") references "hotel" ("id") on update cascade on delete set null;`, + ); + + this.addSql( + `alter table "transportation_image" add constraint "transportation_image_transportation_id_foreign" foreign key ("transportation_id") references "transportation_class" ("id") on update cascade;`, + ); + + this.addSql( + `drop table if exists "package_detail_tour_flight_classes" cascade;`, + ); + + this.addSql( + `drop table if exists "package_detail_outbound_flight_classes" cascade;`, + ); + + this.addSql( + `drop table if exists "package_detail_inbound_flight_classes" cascade;`, + ); + + this.addSql(`drop table if exists "transportation_class_image" cascade;`); + + this.addSql( + `alter table "airline" alter column "skytrax_rating" type int using ("skytrax_rating"::int);`, + ); + this.addSql( + `alter table "airline" alter column "skytrax_rating" set not null;`, + ); + + this.addSql( + `alter table "package_detail" add column "tour_flight_id" varchar(30) not null, add column "outbound_flight_id" varchar(30) not null, add column "inbound_flight_id" varchar(30) not null, add column "package_itinerary_id" varchar(30) not null;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_tour_flight_id_foreign" foreign key ("tour_flight_id") references "flight_class" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_outbound_flight_id_foreign" foreign key ("outbound_flight_id") references "flight_class" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_inbound_flight_id_foreign" foreign key ("inbound_flight_id") references "flight_class" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_package_itinerary_id_foreign" foreign key ("package_itinerary_id") references "package_itinerary" ("id") on update cascade;`, + ); + + this.addSql( + `alter table "package_detail_tour_hotels" drop constraint "package_detail_tour_hotels_pkey";`, + ); + this.addSql(`alter table "package_detail_tour_hotels" drop column "id";`); + + this.addSql( + `alter table "package_detail_tour_hotels" add constraint "package_detail_tour_hotels_pkey" primary key ("package_detail_id", "hotel_schedule_id");`, + ); + } + + override async down(): Promise { + this.addSql( + `alter table "flight_schedule" drop constraint "flight_schedule_next_id_foreign";`, + ); + + this.addSql( + `alter table "package_itinerary_day" drop constraint "package_itinerary_day_next_id_foreign";`, + ); + + this.addSql( + `alter table "package_itinerary" drop constraint "package_itinerary_day_id_foreign";`, + ); + + this.addSql( + `alter table "package_itinerary_widget" drop constraint "package_itinerary_widget_package_itinerary_day_id_foreign";`, + ); + + this.addSql( + `alter table "package_itinerary" drop constraint "package_itinerary_next_id_foreign";`, + ); + + this.addSql( + `alter table "package_itinerary_image" drop constraint "package_itinerary_image_package_itinerary_id_foreign";`, + ); + + this.addSql( + `alter table "package_detail" drop constraint "package_detail_package_itinerary_id_foreign";`, + ); + + this.addSql( + `create table "package_detail_tour_flight_classes" ("id" serial primary key, "package_detail_id" varchar(30) not null, "flight_class_id" varchar(30) not null);`, + ); + + this.addSql( + `create table "package_detail_outbound_flight_classes" ("id" serial primary key, "package_detail_id" varchar(30) not null, "flight_class_id" varchar(30) not null);`, + ); + + this.addSql( + `create table "package_detail_inbound_flight_classes" ("id" serial primary key, "package_detail_id" varchar(30) not null, "flight_class_id" varchar(30) not null);`, + ); + + this.addSql( + `create table "transportation_class_image" ("id" varchar(30) not null, "transportation_class_id" varchar(30) not null, "src" varchar(100) not null, "created_at" varchar(100) not null, "updated_at" timestamptz not null, constraint "transportation_class_image_pkey" primary key ("id"));`, + ); + + this.addSql( + `alter table "package_detail_tour_flight_classes" add constraint "package_detail_tour_flight_classes_package_detail_id_foreign" foreign key ("package_detail_id") references "package_detail" ("id") on update cascade on delete cascade;`, + ); + this.addSql( + `alter table "package_detail_tour_flight_classes" add constraint "package_detail_tour_flight_classes_flight_class_id_foreign" foreign key ("flight_class_id") references "flight_class" ("id") on update cascade on delete cascade;`, + ); + + this.addSql( + `alter table "package_detail_outbound_flight_classes" add constraint "package_detail_outbound_flight_classes_package_d_8b70d_foreign" foreign key ("package_detail_id") references "package_detail" ("id") on update cascade on delete cascade;`, + ); + this.addSql( + `alter table "package_detail_outbound_flight_classes" add constraint "package_detail_outbound_flight_classes_flight_class_id_foreign" foreign key ("flight_class_id") references "flight_class" ("id") on update cascade on delete cascade;`, + ); + + this.addSql( + `alter table "package_detail_inbound_flight_classes" add constraint "package_detail_inbound_flight_classes_package_detail_id_foreign" foreign key ("package_detail_id") references "package_detail" ("id") on update cascade on delete cascade;`, + ); + this.addSql( + `alter table "package_detail_inbound_flight_classes" add constraint "package_detail_inbound_flight_classes_flight_class_id_foreign" foreign key ("flight_class_id") references "flight_class" ("id") on update cascade on delete cascade;`, + ); + + this.addSql( + `alter table "transportation_class_image" add constraint "transportation_class_image_transportation_class_id_foreign" foreign key ("transportation_class_id") references "transportation_class" ("id") on update cascade;`, + ); + + this.addSql(`drop table if exists "flight_schedule" cascade;`); + + this.addSql(`drop table if exists "package_itinerary_day" cascade;`); + + this.addSql(`drop table if exists "package_itinerary" cascade;`); + + this.addSql(`drop table if exists "package_itinerary_image" cascade;`); + + this.addSql(`drop table if exists "package_itinerary_widget" cascade;`); + + this.addSql(`drop table if exists "transportation_image" cascade;`); + + this.addSql( + `alter table "package_detail" drop constraint "package_detail_tour_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_outbound_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_inbound_flight_id_foreign";`, + ); + + this.addSql( + `alter table "airline" alter column "skytrax_rating" type int using ("skytrax_rating"::int);`, + ); + this.addSql( + `alter table "airline" alter column "skytrax_rating" drop not null;`, + ); + + this.addSql( + `alter table "package_detail" drop column "tour_flight_id", drop column "outbound_flight_id", drop column "inbound_flight_id", drop column "package_itinerary_id";`, + ); + + this.addSql( + `alter table "package_detail_tour_hotels" drop constraint "package_detail_tour_hotels_pkey";`, + ); + + this.addSql( + `alter table "package_detail_tour_hotels" add column "id" serial not null;`, + ); + this.addSql( + `alter table "package_detail_tour_hotels" add constraint "package_detail_tour_hotels_pkey" primary key ("id");`, + ); + } +} diff --git a/src/database/migrations/Migration20251111010042.ts b/src/database/migrations/Migration20251111010042.ts new file mode 100644 index 0000000..6d75a37 --- /dev/null +++ b/src/database/migrations/Migration20251111010042.ts @@ -0,0 +1,133 @@ +import { Migration } from "@mikro-orm/migrations"; + +export class Migration20251111010042 extends Migration { + override async up(): Promise { + this.addSql(`alter table "airline" drop constraint "airline_slug_unique";`); + this.addSql(`alter table "airline" drop column "slug";`); + + this.addSql(`alter table "country" drop constraint "country_slug_unique";`); + this.addSql(`alter table "country" drop column "slug";`); + + this.addSql(`alter table "city" drop constraint "city_slug_unique";`); + this.addSql(`alter table "city" drop column "slug";`); + + this.addSql(`alter table "airport" drop constraint "airport_slug_unique";`); + this.addSql(`alter table "airport" drop column "slug";`); + + this.addSql(`alter table "flight" drop constraint "flight_slug_unique";`); + this.addSql(`alter table "flight" drop column "slug";`); + + this.addSql( + `alter table "flight_class" drop constraint "flight_class_slug_unique";`, + ); + this.addSql(`alter table "flight_class" drop column "slug";`); + + this.addSql(`alter table "hotel" drop constraint "hotel_slug_unique";`); + this.addSql(`alter table "hotel" drop column "slug";`); + + this.addSql( + `alter table "hotel_facility" drop constraint "hotel_facility_slug_unique";`, + ); + this.addSql(`alter table "hotel_facility" drop column "slug";`); + + this.addSql(`alter table "package" drop constraint "package_slug_unique";`); + this.addSql(`alter table "package" drop column "slug";`); + + this.addSql( + `alter table "transportation" drop constraint "transportation_slug_unique";`, + ); + this.addSql(`alter table "transportation" drop column "slug";`); + + this.addSql( + `alter table "transportation_class" drop constraint "transportation_class_slug_unique";`, + ); + this.addSql(`alter table "transportation_class" drop column "slug";`); + + this.addSql( + `alter table "package_detail" drop constraint "package_detail_slug_unique";`, + ); + this.addSql(`alter table "package_detail" drop column "slug";`); + } + + override async down(): Promise { + this.addSql( + `alter table "airline" add column "slug" varchar(200) not null;`, + ); + this.addSql( + `alter table "airline" add constraint "airline_slug_unique" unique ("slug");`, + ); + + this.addSql( + `alter table "country" add column "slug" varchar(200) not null;`, + ); + this.addSql( + `alter table "country" add constraint "country_slug_unique" unique ("slug");`, + ); + + this.addSql(`alter table "city" add column "slug" varchar(200) not null;`); + this.addSql( + `alter table "city" add constraint "city_slug_unique" unique ("slug");`, + ); + + this.addSql( + `alter table "airport" add column "slug" varchar(200) not null;`, + ); + this.addSql( + `alter table "airport" add constraint "airport_slug_unique" unique ("slug");`, + ); + + this.addSql( + `alter table "flight" add column "slug" varchar(220) not null;`, + ); + this.addSql( + `alter table "flight" add constraint "flight_slug_unique" unique ("slug");`, + ); + + this.addSql( + `alter table "flight_class" add column "slug" varchar(200) not null;`, + ); + this.addSql( + `alter table "flight_class" add constraint "flight_class_slug_unique" unique ("slug");`, + ); + + this.addSql(`alter table "hotel" add column "slug" varchar(200) not null;`); + this.addSql( + `alter table "hotel" add constraint "hotel_slug_unique" unique ("slug");`, + ); + + this.addSql( + `alter table "hotel_facility" add column "slug" varchar(200) not null;`, + ); + this.addSql( + `alter table "hotel_facility" add constraint "hotel_facility_slug_unique" unique ("slug");`, + ); + + this.addSql( + `alter table "package" add column "slug" varchar(200) not null;`, + ); + this.addSql( + `alter table "package" add constraint "package_slug_unique" unique ("slug");`, + ); + + this.addSql( + `alter table "transportation" add column "slug" varchar(100) not null;`, + ); + this.addSql( + `alter table "transportation" add constraint "transportation_slug_unique" unique ("slug");`, + ); + + this.addSql( + `alter table "transportation_class" add column "slug" varchar(200) not null;`, + ); + this.addSql( + `alter table "transportation_class" add constraint "transportation_class_slug_unique" unique ("slug");`, + ); + + this.addSql( + `alter table "package_detail" add column "slug" varchar(200) not null;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_slug_unique" unique ("slug");`, + ); + } +} diff --git a/src/database/migrations/Migration20251112104244.ts b/src/database/migrations/Migration20251112104244.ts new file mode 100644 index 0000000..624c3a7 --- /dev/null +++ b/src/database/migrations/Migration20251112104244.ts @@ -0,0 +1,27 @@ +import { Migration } from "@mikro-orm/migrations"; + +export class Migration20251112104244 extends Migration { + override async up(): Promise { + this.addSql( + `alter table "hotel" alter column "google_maps_link" type varchar(1000) using ("google_maps_link"::varchar(1000));`, + ); + this.addSql( + `alter table "hotel" alter column "google_maps_embed" type varchar(1000) using ("google_maps_embed"::varchar(1000));`, + ); + this.addSql( + `alter table "hotel" alter column "google_reviews_link" type varchar(1000) using ("google_reviews_link"::varchar(1000));`, + ); + } + + override async down(): Promise { + this.addSql( + `alter table "hotel" alter column "google_maps_link" type varchar(500) using ("google_maps_link"::varchar(500));`, + ); + this.addSql( + `alter table "hotel" alter column "google_maps_embed" type varchar(500) using ("google_maps_embed"::varchar(500));`, + ); + this.addSql( + `alter table "hotel" alter column "google_reviews_link" type varchar(500) using ("google_reviews_link"::varchar(500));`, + ); + } +} diff --git a/src/database/migrations/Migration20251112105017.ts b/src/database/migrations/Migration20251112105017.ts new file mode 100644 index 0000000..0567012 --- /dev/null +++ b/src/database/migrations/Migration20251112105017.ts @@ -0,0 +1,35 @@ +import { Migration } from "@mikro-orm/migrations"; + +export class Migration20251112105017 extends Migration { + override async up(): Promise { + this.addSql( + `alter table "hotel_image" drop constraint "hotel_image_hotel_id_foreign";`, + ); + + this.addSql( + `alter table "hotel_image" alter column "hotel_id" type varchar(30) using ("hotel_id"::varchar(30));`, + ); + this.addSql( + `alter table "hotel_image" alter column "hotel_id" drop not null;`, + ); + this.addSql( + `alter table "hotel_image" add constraint "hotel_image_hotel_id_foreign" foreign key ("hotel_id") references "hotel" ("id") on delete cascade;`, + ); + } + + override async down(): Promise { + this.addSql( + `alter table "hotel_image" drop constraint "hotel_image_hotel_id_foreign";`, + ); + + this.addSql( + `alter table "hotel_image" alter column "hotel_id" type varchar(30) using ("hotel_id"::varchar(30));`, + ); + this.addSql( + `alter table "hotel_image" alter column "hotel_id" set not null;`, + ); + this.addSql( + `alter table "hotel_image" add constraint "hotel_image_hotel_id_foreign" foreign key ("hotel_id") references "hotel" ("id") on update cascade;`, + ); + } +} diff --git a/src/database/migrations/Migration20251112105413.ts b/src/database/migrations/Migration20251112105413.ts new file mode 100644 index 0000000..15de56f --- /dev/null +++ b/src/database/migrations/Migration20251112105413.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251112105413 extends Migration { + + override async up(): Promise { + this.addSql(`alter table "transportation_image" drop constraint "transportation_image_transportation_id_foreign";`); + + this.addSql(`alter table "transportation_image" alter column "transportation_id" type varchar(30) using ("transportation_id"::varchar(30));`); + this.addSql(`alter table "transportation_image" alter column "transportation_id" drop not null;`); + this.addSql(`alter table "transportation_image" add constraint "transportation_image_transportation_id_foreign" foreign key ("transportation_id") references "transportation_class" ("id") on delete cascade;`); + } + + override async down(): Promise { + this.addSql(`alter table "transportation_image" drop constraint "transportation_image_transportation_id_foreign";`); + + this.addSql(`alter table "transportation_image" alter column "transportation_id" type varchar(30) using ("transportation_id"::varchar(30));`); + this.addSql(`alter table "transportation_image" alter column "transportation_id" set not null;`); + this.addSql(`alter table "transportation_image" add constraint "transportation_image_transportation_id_foreign" foreign key ("transportation_id") references "transportation_class" ("id") on update cascade;`); + } + +} diff --git a/src/database/migrations/Migration20251112134446.ts b/src/database/migrations/Migration20251112134446.ts new file mode 100644 index 0000000..9dec5bd --- /dev/null +++ b/src/database/migrations/Migration20251112134446.ts @@ -0,0 +1,47 @@ +import { Migration } from "@mikro-orm/migrations"; + +export class Migration20251112134446 extends Migration { + override async up(): Promise { + this.addSql( + `alter table "package_detail" drop constraint "package_detail_tour_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_outbound_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_inbound_flight_id_foreign";`, + ); + + this.addSql( + `alter table "package_detail" add constraint "package_detail_tour_flight_id_foreign" foreign key ("tour_flight_id") references "flight_schedule" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_outbound_flight_id_foreign" foreign key ("outbound_flight_id") references "flight_schedule" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_inbound_flight_id_foreign" foreign key ("inbound_flight_id") references "flight_schedule" ("id") on update cascade;`, + ); + } + + override async down(): Promise { + this.addSql( + `alter table "package_detail" drop constraint "package_detail_tour_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_outbound_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_inbound_flight_id_foreign";`, + ); + + this.addSql( + `alter table "package_detail" add constraint "package_detail_tour_flight_id_foreign" foreign key ("tour_flight_id") references "flight_class" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_outbound_flight_id_foreign" foreign key ("outbound_flight_id") references "flight_class" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_inbound_flight_id_foreign" foreign key ("inbound_flight_id") references "flight_class" ("id") on update cascade;`, + ); + } +} diff --git a/src/database/migrations/Migration20251112140208.ts b/src/database/migrations/Migration20251112140208.ts new file mode 100644 index 0000000..a3fea58 --- /dev/null +++ b/src/database/migrations/Migration20251112140208.ts @@ -0,0 +1,29 @@ +import { Migration } from "@mikro-orm/migrations"; + +export class Migration20251112140208 extends Migration { + override async up(): Promise { + this.addSql( + `alter table "package_detail" drop constraint "package_detail_package_itinerary_id_foreign";`, + ); + + this.addSql( + `alter table "package_detail" rename column "package_itinerary_id" to "itinerary_id";`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_itinerary_id_foreign" foreign key ("itinerary_id") references "package_itinerary" ("id") on update cascade;`, + ); + } + + override async down(): Promise { + this.addSql( + `alter table "package_detail" drop constraint "package_detail_itinerary_id_foreign";`, + ); + + this.addSql( + `alter table "package_detail" rename column "itinerary_id" to "package_itinerary_id";`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_package_itinerary_id_foreign" foreign key ("package_itinerary_id") references "package_itinerary" ("id") on update cascade;`, + ); + } +} diff --git a/src/database/migrations/Migration20251113230544.ts b/src/database/migrations/Migration20251113230544.ts new file mode 100644 index 0000000..1d35cf7 --- /dev/null +++ b/src/database/migrations/Migration20251113230544.ts @@ -0,0 +1,283 @@ +import { Migration } from "@mikro-orm/migrations"; + +export class Migration20251113230544 extends Migration { + override async up(): Promise { + this.addSql( + `alter table "flight_schedule" drop constraint "flight_schedule_next_id_foreign";`, + ); + + this.addSql( + `alter table "hotel_image" drop constraint "hotel_image_hotel_id_foreign";`, + ); + + this.addSql( + `alter table "package_itinerary_day" drop constraint "package_itinerary_day_next_id_foreign";`, + ); + + this.addSql( + `alter table "package_itinerary" drop constraint "package_itinerary_day_id_foreign";`, + ); + this.addSql( + `alter table "package_itinerary" drop constraint "package_itinerary_next_id_foreign";`, + ); + + this.addSql( + `alter table "package_detail" drop constraint "package_detail_tour_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_outbound_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_inbound_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_makkah_hotel_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_madinah_hotel_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_itinerary_id_foreign";`, + ); + + this.addSql( + `alter table "transportation_image" drop constraint "transportation_image_transportation_id_foreign";`, + ); + + this.addSql( + `alter table "flight_schedule" add constraint "flight_schedule_next_id_foreign" foreign key ("next_id") references "flight_schedule" ("id") on delete cascade;`, + ); + + this.addSql( + `alter table "hotel_image" alter column "hotel_id" type varchar(30) using ("hotel_id"::varchar(30));`, + ); + this.addSql( + `alter table "hotel_image" alter column "hotel_id" set not null;`, + ); + this.addSql( + `alter table "hotel_image" add constraint "hotel_image_hotel_id_foreign" foreign key ("hotel_id") references "hotel" ("id") on update cascade;`, + ); + + this.addSql( + `alter table "package_itinerary_day" add constraint "package_itinerary_day_next_id_foreign" foreign key ("next_id") references "package_itinerary_day" ("id") on delete cascade;`, + ); + + this.addSql( + `alter table "package_itinerary" alter column "day_id" type varchar(30) using ("day_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_itinerary" alter column "day_id" drop not null;`, + ); + this.addSql( + `alter table "package_itinerary" add constraint "package_itinerary_day_id_foreign" foreign key ("day_id") references "package_itinerary_day" ("id") on delete cascade;`, + ); + this.addSql( + `alter table "package_itinerary" add constraint "package_itinerary_next_id_foreign" foreign key ("next_id") references "package_itinerary" ("id") on delete cascade;`, + ); + + this.addSql( + `alter table "package_detail" alter column "tour_flight_id" type varchar(30) using ("tour_flight_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "tour_flight_id" drop not null;`, + ); + this.addSql( + `alter table "package_detail" alter column "outbound_flight_id" type varchar(30) using ("outbound_flight_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "outbound_flight_id" drop not null;`, + ); + this.addSql( + `alter table "package_detail" alter column "inbound_flight_id" type varchar(30) using ("inbound_flight_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "inbound_flight_id" drop not null;`, + ); + this.addSql( + `alter table "package_detail" alter column "makkah_hotel_id" type varchar(30) using ("makkah_hotel_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "makkah_hotel_id" drop not null;`, + ); + this.addSql( + `alter table "package_detail" alter column "madinah_hotel_id" type varchar(30) using ("madinah_hotel_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "madinah_hotel_id" drop not null;`, + ); + this.addSql( + `alter table "package_detail" alter column "itinerary_id" type varchar(30) using ("itinerary_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "itinerary_id" drop not null;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_tour_flight_id_foreign" foreign key ("tour_flight_id") references "flight_schedule" ("id") on delete cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_outbound_flight_id_foreign" foreign key ("outbound_flight_id") references "flight_schedule" ("id") on delete cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_inbound_flight_id_foreign" foreign key ("inbound_flight_id") references "flight_schedule" ("id") on delete cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_makkah_hotel_id_foreign" foreign key ("makkah_hotel_id") references "hotel_schedule" ("id") on delete cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_madinah_hotel_id_foreign" foreign key ("madinah_hotel_id") references "hotel_schedule" ("id") on delete cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_itinerary_id_foreign" foreign key ("itinerary_id") references "package_itinerary" ("id") on delete cascade;`, + ); + + this.addSql( + `alter table "transportation_image" alter column "transportation_id" type varchar(30) using ("transportation_id"::varchar(30));`, + ); + this.addSql( + `alter table "transportation_image" alter column "transportation_id" set not null;`, + ); + this.addSql( + `alter table "transportation_image" add constraint "transportation_image_transportation_id_foreign" foreign key ("transportation_id") references "transportation_class" ("id") on update cascade;`, + ); + } + + override async down(): Promise { + this.addSql( + `alter table "flight_schedule" drop constraint "flight_schedule_next_id_foreign";`, + ); + + this.addSql( + `alter table "hotel_image" drop constraint "hotel_image_hotel_id_foreign";`, + ); + + this.addSql( + `alter table "package_itinerary_day" drop constraint "package_itinerary_day_next_id_foreign";`, + ); + + this.addSql( + `alter table "package_itinerary" drop constraint "package_itinerary_day_id_foreign";`, + ); + this.addSql( + `alter table "package_itinerary" drop constraint "package_itinerary_next_id_foreign";`, + ); + + this.addSql( + `alter table "package_detail" drop constraint "package_detail_tour_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_outbound_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_inbound_flight_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_makkah_hotel_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_madinah_hotel_id_foreign";`, + ); + this.addSql( + `alter table "package_detail" drop constraint "package_detail_itinerary_id_foreign";`, + ); + + this.addSql( + `alter table "transportation_image" drop constraint "transportation_image_transportation_id_foreign";`, + ); + + this.addSql( + `alter table "flight_schedule" add constraint "flight_schedule_next_id_foreign" foreign key ("next_id") references "flight_schedule" ("id") on update cascade on delete set null;`, + ); + + this.addSql( + `alter table "hotel_image" alter column "hotel_id" type varchar(30) using ("hotel_id"::varchar(30));`, + ); + this.addSql( + `alter table "hotel_image" alter column "hotel_id" drop not null;`, + ); + this.addSql( + `alter table "hotel_image" add constraint "hotel_image_hotel_id_foreign" foreign key ("hotel_id") references "hotel" ("id") on delete cascade;`, + ); + + this.addSql( + `alter table "package_itinerary_day" add constraint "package_itinerary_day_next_id_foreign" foreign key ("next_id") references "package_itinerary_day" ("id") on update cascade on delete set null;`, + ); + + this.addSql( + `alter table "package_itinerary" alter column "day_id" type varchar(30) using ("day_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_itinerary" alter column "day_id" set not null;`, + ); + this.addSql( + `alter table "package_itinerary" add constraint "package_itinerary_day_id_foreign" foreign key ("day_id") references "package_itinerary_day" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_itinerary" add constraint "package_itinerary_next_id_foreign" foreign key ("next_id") references "package_itinerary" ("id") on update cascade on delete set null;`, + ); + + this.addSql( + `alter table "package_detail" alter column "tour_flight_id" type varchar(30) using ("tour_flight_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "tour_flight_id" set not null;`, + ); + this.addSql( + `alter table "package_detail" alter column "outbound_flight_id" type varchar(30) using ("outbound_flight_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "outbound_flight_id" set not null;`, + ); + this.addSql( + `alter table "package_detail" alter column "inbound_flight_id" type varchar(30) using ("inbound_flight_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "inbound_flight_id" set not null;`, + ); + this.addSql( + `alter table "package_detail" alter column "makkah_hotel_id" type varchar(30) using ("makkah_hotel_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "makkah_hotel_id" set not null;`, + ); + this.addSql( + `alter table "package_detail" alter column "madinah_hotel_id" type varchar(30) using ("madinah_hotel_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "madinah_hotel_id" set not null;`, + ); + this.addSql( + `alter table "package_detail" alter column "itinerary_id" type varchar(30) using ("itinerary_id"::varchar(30));`, + ); + this.addSql( + `alter table "package_detail" alter column "itinerary_id" set not null;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_tour_flight_id_foreign" foreign key ("tour_flight_id") references "flight_schedule" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_outbound_flight_id_foreign" foreign key ("outbound_flight_id") references "flight_schedule" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_inbound_flight_id_foreign" foreign key ("inbound_flight_id") references "flight_schedule" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_makkah_hotel_id_foreign" foreign key ("makkah_hotel_id") references "hotel_schedule" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_madinah_hotel_id_foreign" foreign key ("madinah_hotel_id") references "hotel_schedule" ("id") on update cascade;`, + ); + this.addSql( + `alter table "package_detail" add constraint "package_detail_itinerary_id_foreign" foreign key ("itinerary_id") references "package_itinerary" ("id") on update cascade;`, + ); + + this.addSql( + `alter table "transportation_image" alter column "transportation_id" type varchar(30) using ("transportation_id"::varchar(30));`, + ); + this.addSql( + `alter table "transportation_image" alter column "transportation_id" drop not null;`, + ); + this.addSql( + `alter table "transportation_image" add constraint "transportation_image_transportation_id_foreign" foreign key ("transportation_id") references "transportation_class" ("id") on delete cascade;`, + ); + } +} diff --git a/src/modules/airline/airline.controller.ts b/src/modules/airline/airline.controller.ts new file mode 100644 index 0000000..29e24fd --- /dev/null +++ b/src/modules/airline/airline.controller.ts @@ -0,0 +1,207 @@ +import { Controller } from "@/common/controller"; +import { ormMiddleware } from "@/common/middlewares/orm.middleware"; +import { paginationQuerySchema } from "@/common/schemas"; +import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage"; +import type { + ErrorResponse, + ListResponse, + SingleResponse, +} from "@/common/types"; +import { Airline } from "@/database/entities/airline.entity"; +import { orm } from "@/database/orm"; +import type { AirlineMapper } from "@/modules/airline/airline.mapper"; +import { + airlineParamsSchema, + airlineRequestSchema, +} from "@/modules/airline/airline.schemas"; +import type { AirlineResponse } from "@/modules/airline/airline.types"; +import { Router, type Request, type Response } from "express"; +import { ulid } from "ulid"; + +export class AirlineController extends Controller { + public constructor( + private readonly mapper: AirlineMapper, + private readonly fileStorage: AbstractFileStorage, + ) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = airlineRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const logoFile = await this.fileStorage.storeFile( + Buffer.from(body.logo, "base64"), + ); + + const airline = orm.em.create(Airline, { + id: ulid(), + name: body.name, + code: body.code, + logo: logoFile.name, + skytraxRating: body.skytrax_rating, + skytraxType: this.mapper.mapSkytraxType(body.skytrax_type), + createdAt: new Date(), + updatedAt: new Date(), + }); + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(airline), + errors: null, + } satisfies SingleResponse); + } + + async list(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const count = await orm.em.count(Airline); + + const airlines = await orm.em.find( + Airline, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + }, + ); + + return res.status(200).json({ + data: airlines.map(this.mapper.mapEntityToResponse.bind(this.mapper)), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async view(req: Request, res: Response) { + const parseParamsResult = airlineParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const airline = await orm.em.findOne(Airline, { id: params.id }); + if (!airline) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Airline not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(airline), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = airlineParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = airlineRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const airline = await orm.em.findOne(Airline, { id: params.id }); + if (!airline) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Airline not found.", + }, + ], + } satisfies ErrorResponse); + } + + await this.fileStorage.storeFile( + Buffer.from(body.logo, "base64"), + airline.logo, + ); + + airline.name = body.name; + airline.code = body.code; + airline.skytraxRating = body.skytrax_rating; + airline.skytraxType = this.mapper.mapSkytraxType(body.skytrax_type); + airline.updatedAt = new Date(); + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(airline), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = airlineParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const airline = await orm.em.findOne( + Airline, + { id: params.id }, + { + populate: ["*"], + }, + ); + if (!airline) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Airline not found.", + }, + ], + } satisfies ErrorResponse); + } + + await this.fileStorage.removeFile(airline.logo); + + await orm.em.removeAndFlush(airline); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post("/", ormMiddleware, this.create.bind(this)); + router.get("/", ormMiddleware, this.list.bind(this)); + router.get("/:id", ormMiddleware, this.view.bind(this)); + router.put("/:id", ormMiddleware, this.update.bind(this)); + router.delete("/:id", ormMiddleware, this.delete.bind(this)); + + return router; + } +} diff --git a/src/modules/airline/airline.mapper.ts b/src/modules/airline/airline.mapper.ts new file mode 100644 index 0000000..4810696 --- /dev/null +++ b/src/modules/airline/airline.mapper.ts @@ -0,0 +1,34 @@ +import type { Airline } from "@/database/entities/airline.entity"; +import { SkytraxType } from "@/database/enums/skytrax-type.enum"; +import type { + AirlineRequest, + AirlineResponse, +} from "@/modules/airline/airline.types"; + +export class AirlineMapper { + public constructor() {} + + public mapSkytraxType( + skytraxType: AirlineRequest["skytrax_type"], + ): SkytraxType { + switch (skytraxType) { + case "full_service": + return SkytraxType.fullService; + case "low_cost": + return SkytraxType.lowCost; + } + } + + public mapEntityToResponse(airline: Airline): AirlineResponse { + return { + id: airline.id, + name: airline.name, + code: airline.code, + logo: airline.logo, + skytrax_rating: airline.skytraxRating, + skytrax_type: airline.skytraxType, + created_at: airline.createdAt, + updated_at: airline.updatedAt, + }; + } +} diff --git a/src/modules/airline/airline.schemas.ts b/src/modules/airline/airline.schemas.ts index 013f9eb..78924c5 100644 --- a/src/modules/airline/airline.schemas.ts +++ b/src/modules/airline/airline.schemas.ts @@ -21,8 +21,8 @@ export const airlineRequestSchema = z.object({ ), }); -export const airlineQuerySchema = z.object({ - slug: z +export const airlineParamsSchema = z.object({ + id: z .string("Must be string.") .nonempty("Must not empty.") .max(200, "Max 200 characters."), diff --git a/src/modules/airline/airline.types.ts b/src/modules/airline/airline.types.ts index 7c5ec1b..3178b13 100644 --- a/src/modules/airline/airline.types.ts +++ b/src/modules/airline/airline.types.ts @@ -1,16 +1,15 @@ import type { - airlineQuerySchema, + airlineParamsSchema, airlineRequestSchema, } from "@/modules/airline/airline.schemas"; import z from "zod"; export type AirlineRequest = z.infer; -export type AirlineQuery = z.infer; +export type AirlineParams = z.infer; export type AirlineResponse = { id: string; - slug: string; name: string; code: string; logo: string; diff --git a/src/modules/airport/airport.controller.ts b/src/modules/airport/airport.controller.ts new file mode 100644 index 0000000..ec9bf7b --- /dev/null +++ b/src/modules/airport/airport.controller.ts @@ -0,0 +1,235 @@ +import { Controller } from "@/common/controller"; +import { ormMiddleware } from "@/common/middlewares/orm.middleware"; +import { paginationQuerySchema } from "@/common/schemas"; +import type { + ErrorResponse, + ListResponse, + SingleResponse, +} from "@/common/types"; +import { Airport } from "@/database/entities/airport.entity"; +import { City } from "@/database/entities/city.entity"; +import { orm } from "@/database/orm"; +import type { AirportMapper } from "@/modules/airport/airport.mapper"; +import { + airportParamsSchema, + airportRequestSchema, +} from "@/modules/airport/airport.schemas"; +import type { AirportResponse } from "@/modules/airport/airport.types"; +import { Router, type Request, type Response } from "express"; +import { ulid } from "ulid"; + +export class AirportController extends Controller { + public constructor(private readonly mapper: AirportMapper) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = airportRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const city = await orm.em.findOne( + City, + { id: body.city_id }, + { populate: ["*"] }, + ); + if (!city) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "city_id", + location: "body", + message: "City not found.", + }, + ], + } satisfies ErrorResponse); + } + + const airport = orm.em.create(Airport, { + id: ulid(), + name: body.name, + code: body.code, + city: city, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(airport), + errors: null, + } satisfies SingleResponse); + } + + async list(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const count = await orm.em.count(Airport); + + const airports = await orm.em.find( + Airport, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + populate: ["*"], + }, + ); + + return res.status(200).json({ + data: airports.map(this.mapper.mapEntityToResponse.bind(this.mapper)), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async view(req: Request, res: Response) { + const parseParamsResult = airportParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const airport = await orm.em.findOne( + Airport, + { id: params.id }, + { populate: ["*"] }, + ); + if (!airport) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Airport not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(airport), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = airportParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = airportRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const city = await orm.em.findOne( + City, + { id: body.city_id }, + { populate: ["*"] }, + ); + if (!city) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "city_id", + location: "body", + message: "City not found.", + }, + ], + } satisfies ErrorResponse); + } + + const airport = await orm.em.findOne( + Airport, + { id: params.id }, + { populate: ["*"] }, + ); + if (!airport) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Airport not found.", + }, + ], + } satisfies ErrorResponse); + } + + airport.name = body.name; + airport.code = body.code; + airport.city = city; + airport.updatedAt = new Date(); + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(airport), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = airportParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const airport = await orm.em.findOne( + Airport, + { id: params.id }, + { + populate: ["*"], + }, + ); + if (!airport) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Airport not found.", + }, + ], + } satisfies ErrorResponse); + } + + await orm.em.removeAndFlush(airport); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post("/", ormMiddleware, this.create.bind(this)); + router.get("/", ormMiddleware, this.list.bind(this)); + router.get("/:id", ormMiddleware, this.view.bind(this)); + router.put("/:id", ormMiddleware, this.update.bind(this)); + router.delete("/:id", ormMiddleware, this.delete.bind(this)); + + return router; + } +} diff --git a/src/modules/airport/airport.mapper.ts b/src/modules/airport/airport.mapper.ts new file mode 100644 index 0000000..3fa3e00 --- /dev/null +++ b/src/modules/airport/airport.mapper.ts @@ -0,0 +1,18 @@ +import type { Airport } from "@/database/entities/airport.entity"; +import type { AirportResponse } from "@/modules/airport/airport.types"; +import type { CityMapper } from "@/modules/city/city.mapper"; + +export class AirportMapper { + public constructor(private readonly cityMapper: CityMapper) {} + + public mapEntityToResponse(airport: Airport): AirportResponse { + return { + id: airport.id, + name: airport.name, + code: airport.code, + city: this.cityMapper.mapEntityToResponse(airport.city), + created_at: airport.createdAt, + updated_at: airport.updatedAt, + }; + } +} diff --git a/src/modules/airport/airport.schemas.ts b/src/modules/airport/airport.schemas.ts index c6ceb57..af0f61e 100644 --- a/src/modules/airport/airport.schemas.ts +++ b/src/modules/airport/airport.schemas.ts @@ -9,15 +9,15 @@ export const airportRequestSchema = z.object({ .string("Must be string.") .nonempty("Must not empty.") .max(10, "Max 10 characters."), - city_slug: z - .string("Must be string.") + city_id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), }); -export const airportQuerySchema = z.object({ - slug: z - .string("Must be string.") +export const airportParamsSchema = z.object({ + id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), }); diff --git a/src/modules/airport/airport.types.ts b/src/modules/airport/airport.types.ts index 900a8d1..bb0ff33 100644 --- a/src/modules/airport/airport.types.ts +++ b/src/modules/airport/airport.types.ts @@ -1,5 +1,5 @@ import type { - airportQuerySchema, + airportParamsSchema, airportRequestSchema, } from "@/modules/airport/airport.schemas"; import type { CityResponse } from "@/modules/city/city.types"; @@ -7,11 +7,10 @@ import z from "zod"; export type AirportRequest = z.infer; -export type AirportQuery = z.infer; +export type AirportParams = z.infer; export type AirportResponse = { id: string; - slug: string; name: string; code: string; city: CityResponse; diff --git a/src/modules/city/city.controller.ts b/src/modules/city/city.controller.ts new file mode 100644 index 0000000..1972dff --- /dev/null +++ b/src/modules/city/city.controller.ts @@ -0,0 +1,221 @@ +import { Controller } from "@/common/controller"; +import { ormMiddleware } from "@/common/middlewares/orm.middleware"; +import { paginationQuerySchema } from "@/common/schemas"; +import type { + ErrorResponse, + ListResponse, + SingleResponse, +} from "@/common/types"; +import { City } from "@/database/entities/city.entity"; +import { Country } from "@/database/entities/country.entity"; +import { orm } from "@/database/orm"; +import type { CityMapper } from "@/modules/city/city.mapper"; +import { + cityParamsSchema, + cityRequestSchema, +} from "@/modules/city/city.schemas"; +import type { CityResponse } from "@/modules/city/city.types"; +import { Router, type Request, type Response } from "express"; +import { ulid } from "ulid"; + +export class CityController extends Controller { + public constructor(private readonly mapper: CityMapper) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = cityRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const country = await orm.em.findOne(Country, { id: body.country_id }); + if (!country) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "country_id", + location: "body", + message: "Country not found.", + }, + ], + } satisfies ErrorResponse); + } + + const city = orm.em.create(City, { + id: ulid(), + name: body.name, + country, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(city), + errors: null, + } satisfies SingleResponse); + } + + async list(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const count = await orm.em.count(City); + + const cities = await orm.em.find( + City, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + populate: ["*"], + }, + ); + + return res.status(200).json({ + data: cities.map(this.mapper.mapEntityToResponse.bind(this.mapper)), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async view(req: Request, res: Response) { + const parseParamsResult = cityParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const city = await orm.em.findOne( + City, + { id: params.id }, + { populate: ["*"] }, + ); + if (!city) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "City not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(city), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = cityParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = cityRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const country = await orm.em.findOne(Country, { id: body.country_id }); + if (!country) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "country_id", + location: "body", + message: "Country not found.", + }, + ], + } satisfies ErrorResponse); + } + + const city = await orm.em.findOne(City, { id: params.id }); + if (!city) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "City not found.", + }, + ], + } satisfies ErrorResponse); + } + + city.name = body.name; + city.country = country; + city.updatedAt = new Date(); + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(city), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = cityParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const city = await orm.em.findOne( + City, + { id: params.id }, + { + populate: ["*"], + }, + ); + if (!city) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "City not found.", + }, + ], + } satisfies ErrorResponse); + } + + await orm.em.removeAndFlush(city); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post("/", ormMiddleware, this.create.bind(this)); + router.get("/", ormMiddleware, this.list.bind(this)); + router.get("/:id", ormMiddleware, this.view.bind(this)); + router.put("/:id", ormMiddleware, this.update.bind(this)); + router.delete("/:id", ormMiddleware, this.delete.bind(this)); + + return router; + } +} diff --git a/src/modules/city/city.mapper.ts b/src/modules/city/city.mapper.ts new file mode 100644 index 0000000..600be84 --- /dev/null +++ b/src/modules/city/city.mapper.ts @@ -0,0 +1,17 @@ +import type { City } from "@/database/entities/city.entity"; +import type { CityResponse } from "@/modules/city/city.types"; +import type { CountryMapper } from "@/modules/country/country.mapper"; + +export class CityMapper { + public constructor(private readonly countryMapper: CountryMapper) {} + + public mapEntityToResponse(city: City): CityResponse { + return { + id: city.id, + name: city.name, + country: this.countryMapper.mapEntityToResponse(city.country), + created_at: city.createdAt, + updated_at: city.updatedAt, + }; + } +} diff --git a/src/modules/city/city.schemas.ts b/src/modules/city/city.schemas.ts index 9e40c39..d740c3d 100644 --- a/src/modules/city/city.schemas.ts +++ b/src/modules/city/city.schemas.ts @@ -5,14 +5,14 @@ export const cityRequestSchema = z.object({ .string("Must be string.") .nonempty("Must not empty.") .max(100, "Max 100 characters."), - country_slug: z - .string("Must be string.") + country_id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), }); -export const cityQuerySchema = z.object({ - slug: z +export const cityParamsSchema = z.object({ + id: z .string("Must be string.") .nonempty("Must not empty.") .max(200, "Max 200 characters."), diff --git a/src/modules/city/city.types.ts b/src/modules/city/city.types.ts index 9da96f5..a809dd8 100644 --- a/src/modules/city/city.types.ts +++ b/src/modules/city/city.types.ts @@ -1,5 +1,5 @@ import type { - cityQuerySchema, + cityParamsSchema, cityRequestSchema, } from "@/modules/city/city.schemas"; import type { CountryResponse } from "@/modules/country/country.types"; @@ -7,11 +7,10 @@ import z from "zod"; export type CityRequest = z.infer; -export type CityQuery = z.infer; +export type CityParams = z.infer; export type CityResponse = { id: string; - slug: string; name: string; country: CountryResponse; created_at: Date; diff --git a/src/modules/country/country.controller.ts b/src/modules/country/country.controller.ts new file mode 100644 index 0000000..53d3f1c --- /dev/null +++ b/src/modules/country/country.controller.ts @@ -0,0 +1,185 @@ +import { Controller } from "@/common/controller"; +import { ormMiddleware } from "@/common/middlewares/orm.middleware"; +import { paginationQuerySchema } from "@/common/schemas"; +import type { + ErrorResponse, + ListResponse, + SingleResponse, +} from "@/common/types"; +import { Country } from "@/database/entities/country.entity"; +import { orm } from "@/database/orm"; +import type { CountryMapper } from "@/modules/country/country.mapper"; +import { + countryParamsSchema, + countryRequestSchema, +} from "@/modules/country/country.schemas"; +import type { CountryResponse } from "@/modules/country/country.types"; +import { Router, type Request, type Response } from "express"; +import { ulid } from "ulid"; + +export class CountryController extends Controller { + public constructor(private readonly mapper: CountryMapper) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = countryRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const country = orm.em.create(Country, { + id: ulid(), + name: body.name, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(country), + errors: null, + } satisfies SingleResponse); + } + + async list(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const count = await orm.em.count(Country); + + const countries = await orm.em.find( + Country, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + }, + ); + + return res.status(200).json({ + data: countries.map(this.mapper.mapEntityToResponse.bind(this.mapper)), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async view(req: Request, res: Response) { + const parseParamsResult = countryParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const country = await orm.em.findOne(Country, { id: params.id }); + if (!country) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Country not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(country), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = countryParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = countryRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const country = await orm.em.findOne(Country, { id: params.id }); + if (!country) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Country not found.", + }, + ], + } satisfies ErrorResponse); + } + + country.name = body.name; + country.updatedAt = new Date(); + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(country), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = countryParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const country = await orm.em.findOne( + Country, + { id: params.id }, + { + populate: ["*"], + }, + ); + if (!country) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Country not found.", + }, + ], + } satisfies ErrorResponse); + } + + await orm.em.removeAndFlush(country); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post("/", ormMiddleware, this.create.bind(this)); + router.get("/", ormMiddleware, this.list.bind(this)); + router.get("/:id", ormMiddleware, this.view.bind(this)); + router.put("/:id", ormMiddleware, this.update.bind(this)); + router.delete("/:id", ormMiddleware, this.delete.bind(this)); + + return router; + } +} diff --git a/src/modules/country/country.mapper.ts b/src/modules/country/country.mapper.ts new file mode 100644 index 0000000..367434c --- /dev/null +++ b/src/modules/country/country.mapper.ts @@ -0,0 +1,13 @@ +import type { Country } from "@/database/entities/country.entity"; +import type { CountryResponse } from "@/modules/country/country.types"; + +export class CountryMapper { + public mapEntityToResponse(country: Country): CountryResponse { + return { + id: country.id, + name: country.name, + created_at: country.createdAt, + updated_at: country.updatedAt, + }; + } +} diff --git a/src/modules/country/country.schemas.ts b/src/modules/country/country.schemas.ts index fef8d3f..783edb1 100644 --- a/src/modules/country/country.schemas.ts +++ b/src/modules/country/country.schemas.ts @@ -7,9 +7,9 @@ export const countryRequestSchema = z.object({ .max(100, "Max 100 characters."), }); -export const countryQuerySchema = z.object({ - slug: z - .string("Must be string.") +export const countryParamsSchema = z.object({ + id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), }); diff --git a/src/modules/country/country.types.ts b/src/modules/country/country.types.ts index 3a68261..18e3264 100644 --- a/src/modules/country/country.types.ts +++ b/src/modules/country/country.types.ts @@ -1,16 +1,15 @@ import type { - countryQuerySchema, + countryParamsSchema, countryRequestSchema, } from "@/modules/country/country.schemas"; import z from "zod"; export type CountryRequest = z.infer; -export type CountryQuery = z.infer; +export type CountryParams = z.infer; export type CountryResponse = { id: string; - slug: string; name: string; created_at: Date; updated_at: Date; diff --git a/src/modules/flight/flight.controller.ts b/src/modules/flight/flight.controller.ts new file mode 100644 index 0000000..5390de9 --- /dev/null +++ b/src/modules/flight/flight.controller.ts @@ -0,0 +1,650 @@ +import { Controller } from "@/common/controller"; +import { ormMiddleware } from "@/common/middlewares/orm.middleware"; +import { paginationQuerySchema } from "@/common/schemas"; +import type { + ErrorResponse, + ListResponse, + SingleResponse, +} from "@/common/types"; +import { Airline } from "@/database/entities/airline.entity"; +import { Airport } from "@/database/entities/airport.entity"; +import { FlightClass } from "@/database/entities/flight-class.entity"; +import { Flight } from "@/database/entities/flight.entity"; +import { orm } from "@/database/orm"; +import type { FlightMapper } from "@/modules/flight/flight.mapper"; +import { + flightClassParamsSchema, + flightClassRequestSchema, + flightParamsSchema, + flightRequestSchema, +} from "@/modules/flight/flight.schemas"; +import type { + FlightClassResponse, + FlightResponse, +} from "@/modules/flight/flight.types"; +import { Router, type Request, type Response } from "express"; +import { ulid } from "ulid"; + +export class FlightController extends Controller { + public constructor(private readonly mapper: FlightMapper) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = flightRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const airline = await orm.em.findOne( + Airline, + { + id: body.airline_id, + }, + { + populate: ["*"], + }, + ); + if (!airline) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "airline_id", + location: "body", + message: "Airline not found.", + }, + ], + } satisfies ErrorResponse); + } + + const departureAirport = await orm.em.findOne( + Airport, + { + id: body.departure_airport_id, + }, + { + populate: ["*"], + }, + ); + if (!departureAirport) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "departure_airport_id", + location: "body", + message: "Airport not found.", + }, + ], + } satisfies ErrorResponse); + } + + const arrivalAirport = await orm.em.findOne( + Airport, + { + id: body.arrival_airport_id, + }, + { + populate: ["*"], + }, + ); + if (!arrivalAirport) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "arrival_airport_id", + location: "body", + message: "Airport not found.", + }, + ], + } satisfies ErrorResponse); + } + + const flight = orm.em.create(Flight, { + id: ulid(), + airline, + number: body.number, + departureAirport, + departureTerminal: body.departure_terminal, + departureGate: body.departure_gate, + departureTime: `${body.departure_time.getHours()}:${body.departure_time.getMinutes()}:00`, + arrivalAirport, + arrivalTerminal: body.arrival_terminal, + arrivalGate: body.arrival_gate, + duration: body.duration, + aircraft: body.aircraft, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(flight), + errors: null, + } satisfies SingleResponse); + } + + async list(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const count = await orm.em.count(Flight); + + const flights = await orm.em.find( + Flight, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + populate: ["*"], + }, + ); + + return res.status(200).json({ + data: flights.map(this.mapper.mapEntityToResponse.bind(this.mapper)), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async view(req: Request, res: Response) { + const parseParamsResult = flightParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const flight = await orm.em.findOne( + Flight, + { id: params.id }, + { populate: ["*"] }, + ); + if (!flight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Flight not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(flight), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = flightParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = flightRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const airline = await orm.em.findOne( + Airline, + { + id: body.airline_id, + }, + { + populate: ["*"], + }, + ); + if (!airline) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "airline_id", + location: "body", + message: "Airline not found.", + }, + ], + } satisfies ErrorResponse); + } + + const departureAirport = await orm.em.findOne( + Airport, + { + id: body.departure_airport_id, + }, + { + populate: ["*"], + }, + ); + if (!departureAirport) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "departure_airport_id", + location: "body", + message: "Airport not found.", + }, + ], + } satisfies ErrorResponse); + } + + const arrivalAirport = await orm.em.findOne( + Airport, + { + id: body.arrival_airport_id, + }, + { + populate: ["*"], + }, + ); + if (!arrivalAirport) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "arrival_airport_id", + location: "body", + message: "Airport not found.", + }, + ], + } satisfies ErrorResponse); + } + + const flight = await orm.em.findOne( + Flight, + { id: params.id }, + { populate: ["*"] }, + ); + if (!flight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Flight not found.", + }, + ], + } satisfies ErrorResponse); + } + + flight.airline = airline; + flight.number = body.number; + flight.departureAirport = departureAirport; + flight.departureTerminal = body.departure_terminal; + flight.departureGate = body.departure_gate; + flight.departureTime = `${body.departure_time.getHours()}:${body.departure_time.getMinutes()}:00`; + flight.arrivalAirport = arrivalAirport; + flight.arrivalTerminal = body.arrival_terminal; + flight.arrivalGate = body.arrival_gate; + flight.duration = body.duration; + flight.aircraft = body.aircraft; + flight.updatedAt = new Date(); + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(flight), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = flightParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const flight = await orm.em.findOne( + Flight, + { id: params.id }, + { + populate: ["*"], + }, + ); + if (!flight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Flight not found.", + }, + ], + } satisfies ErrorResponse); + } + + await orm.em.removeAndFlush(flight); + + return res.status(204).send(); + } + + async createClass(req: Request, res: Response) { + const parseParamsResult = flightParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = flightClassRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const flight = await orm.em.findOne( + Flight, + { id: params.id }, + { populate: ["*"] }, + ); + if (!flight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Flight not found.", + }, + ], + } satisfies ErrorResponse); + } + + const flightClass = orm.em.create(FlightClass, { + id: ulid(), + flight, + class: body.class, + seatLayout: body.seat_layout, + baggage: body.baggage, + cabinBaggage: body.cabin_baggage, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapClassEntityToResponse(flightClass), + errors: null, + } satisfies SingleResponse); + } + + async listClasses(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const parseParamsResult = flightParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const flight = await orm.em.findOne( + Flight, + { id: params.id }, + { + populate: ["*"], + }, + ); + if (!flight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Flight not found.", + }, + ], + } satisfies ErrorResponse); + } + + const count = await orm.em.count(FlightClass, { + flight: flight, + }); + + const classes = await orm.em.find( + FlightClass, + { flight: flight }, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + populate: ["*"], + }, + ); + + return res.status(200).json({ + data: classes.map(this.mapper.mapClassEntityToResponse.bind(this.mapper)), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async viewClass(req: Request, res: Response) { + const parseParamsResult = flightClassParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const flight = await orm.em.findOne( + Flight, + { id: params.flight_id }, + { populate: ["*"] }, + ); + if (!flight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "flight_id", + location: "params", + message: "Flight not found.", + }, + ], + } satisfies ErrorResponse); + } + + const flightClass = await orm.em.findOne( + FlightClass, + { + id: params.id, + flight: flight, + }, + { populate: ["*"] }, + ); + if (!flightClass) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Flight class not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapClassEntityToResponse(flightClass), + errors: null, + } satisfies SingleResponse); + } + + async updateClass(req: Request, res: Response) { + const parseParamsResult = flightClassParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = flightClassRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const flight = await orm.em.findOne( + Flight, + { id: params.flight_id }, + { populate: ["*"] }, + ); + if (!flight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "flight_id", + location: "params", + message: "Flight not found.", + }, + ], + } satisfies ErrorResponse); + } + + const flightClass = await orm.em.findOne( + FlightClass, + { + id: params.id, + flight: flight, + }, + { populate: ["*"] }, + ); + if (!flightClass) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Flight class not found.", + }, + ], + } satisfies ErrorResponse); + } + + flightClass.class = body.class; + flightClass.seatLayout = body.seat_layout; + flightClass.baggage = body.baggage; + flightClass.cabinBaggage = body.cabin_baggage; + flightClass.updatedAt = new Date(); + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapClassEntityToResponse(flightClass), + errors: null, + } satisfies SingleResponse); + } + + async deleteClass(req: Request, res: Response) { + const parseParamsResult = flightClassParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const flight = await orm.em.findOne( + Flight, + { id: params.flight_id }, + { populate: ["*"] }, + ); + if (!flight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "flight_id", + location: "params", + message: "Flight not found.", + }, + ], + } satisfies ErrorResponse); + } + + const flightClass = await orm.em.findOne( + FlightClass, + { + id: params.id, + flight: flight, + }, + { populate: ["*"] }, + ); + if (!flightClass) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Flight class not found.", + }, + ], + } satisfies ErrorResponse); + } + + await orm.em.removeAndFlush(flightClass); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post("/", ormMiddleware, this.create.bind(this)); + router.get("/", ormMiddleware, this.list.bind(this)); + router.get("/:id", ormMiddleware, this.view.bind(this)); + router.put("/:id", ormMiddleware, this.update.bind(this)); + router.delete("/:id", ormMiddleware, this.delete.bind(this)); + router.post("/:id/classes", ormMiddleware, this.createClass.bind(this)); + router.get("/:id/classes", ormMiddleware, this.listClasses.bind(this)); + router.get( + "/:flight_id/classes/:id", + ormMiddleware, + this.viewClass.bind(this), + ); + router.put( + "/:flight_id/classes/:id", + ormMiddleware, + this.updateClass.bind(this), + ); + router.delete( + "/:flight_id/classes/:id", + ormMiddleware, + this.deleteClass.bind(this), + ); + + return router; + } +} diff --git a/src/modules/flight/flight.mapper.ts b/src/modules/flight/flight.mapper.ts new file mode 100644 index 0000000..3cfe7d6 --- /dev/null +++ b/src/modules/flight/flight.mapper.ts @@ -0,0 +1,62 @@ +import type { FlightClass } from "@/database/entities/flight-class.entity"; +import type { Flight } from "@/database/entities/flight.entity"; +import type { AirlineMapper } from "@/modules/airline/airline.mapper"; +import type { AirportMapper } from "@/modules/airport/airport.mapper"; +import type { + FlightClassResponse, + FlightResponse, +} from "@/modules/flight/flight.types"; +import * as dateFns from "date-fns"; + +export class FlightMapper { + public constructor( + private readonly airlineMapper: AirlineMapper, + private readonly airportMapper: AirportMapper, + ) {} + + public mapEntityToResponse(flight: Flight): FlightResponse { + const departureTime = dateFns.parse( + flight.departureTime, + "H:mm:ss", + new Date(), + ); + const arrivalTime = dateFns.addMinutes(departureTime, flight.duration); + + return { + id: flight.id, + airline: this.airlineMapper.mapEntityToResponse(flight.airline), + number: flight.number, + departure_airport: this.airportMapper.mapEntityToResponse( + flight.departureAirport, + ), + departure_terminal: flight.departureTerminal, + departure_gate: flight.departureGate, + departure_time: dateFns.format(departureTime, "HH:mm"), + arrival_airport: this.airportMapper.mapEntityToResponse( + flight.arrivalAirport, + ), + arrival_terminal: flight.arrivalTerminal, + arrival_gate: flight.arrivalGate, + arrival_time: dateFns.format(arrivalTime, "HH:mm"), + duration: flight.duration, + aircraft: flight.aircraft, + created_at: flight.createdAt, + updated_at: flight.updatedAt, + }; + } + + public mapClassEntityToResponse( + flightClass: FlightClass, + ): FlightClassResponse { + return { + id: flightClass.id, + flight: this.mapEntityToResponse(flightClass.flight), + class: flightClass.class, + seat_layout: flightClass.seatLayout, + baggage: flightClass.baggage, + cabin_baggage: flightClass.cabinBaggage, + created_at: flightClass.createdAt, + updated_at: flightClass.updatedAt, + }; + } +} diff --git a/src/modules/flight/flight.schemas.ts b/src/modules/flight/flight.schemas.ts index 330ba38..c4fcdc8 100644 --- a/src/modules/flight/flight.schemas.ts +++ b/src/modules/flight/flight.schemas.ts @@ -2,18 +2,18 @@ import { timeSchema } from "@/common/schemas"; import z from "zod"; export const flightRequestSchema = z.object({ - airline_slug: z - .string("Must be string.") + airline_id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), number: z .number("Must be number.") .int("Must be integer.") .positive("Must be positive."), - departure_airport_slug: z - .string("Must be string.") + departure_airport_id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), departure_terminal: z .string("Must be string.") .max(100, "Max 100 characters.") @@ -23,10 +23,10 @@ export const flightRequestSchema = z.object({ .max(100, "Max 100 characters.") .nullable(), departure_time: timeSchema, - arrival_airport_slug: z - .string("Must be string.") + arrival_airport_id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), arrival_terminal: z .string("Must be string.") .max(100, "Max 100 characters.") @@ -64,20 +64,20 @@ export const flightClassRequestSchema = z.object({ .positive("Must be positive."), }); -export const flightQuerySchema = z.object({ - slug: z - .string("Must be string.") +export const flightParamsSchema = z.object({ + id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(220, "Max 220 characters."), + .max(30, "Max 30 characters."), }); -export const flightClassQuerySchema = z.object({ - flight_slug: z - .string("Must be string.") +export const flightClassParamsSchema = z.object({ + flight_id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(220, "Max 220 characters."), - slug: z - .string("Must be string.") + .max(30, "Max 30 characters."), + id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(420, "Max 420 characters."), + .max(30, "Max 30 characters."), }); diff --git a/src/modules/flight/flight.types.ts b/src/modules/flight/flight.types.ts index b183e99..db4ce93 100644 --- a/src/modules/flight/flight.types.ts +++ b/src/modules/flight/flight.types.ts @@ -1,9 +1,9 @@ import type { AirlineResponse } from "@/modules/airline/airline.types"; import type { AirportResponse } from "@/modules/airport/airport.types"; import type { - flightClassQuerySchema, + flightClassParamsSchema, flightClassRequestSchema, - flightQuerySchema, + flightParamsSchema, flightRequestSchema, } from "@/modules/flight/flight.schemas"; import z from "zod"; @@ -12,13 +12,12 @@ export type FlightRequest = z.infer; export type FlightClassRequest = z.infer; -export type FlightQuery = z.infer; +export type FlightQuery = z.infer; -export type FlightClassQuery = z.infer; +export type FlightClassQuery = z.infer; export type FlightResponse = { id: string; - slug: string; airline: AirlineResponse; number: number; departure_airport: AirportResponse; @@ -37,7 +36,6 @@ export type FlightResponse = { export type FlightClassResponse = { id: string; - slug: string; flight: FlightResponse; class: string; seat_layout: string; diff --git a/src/modules/hotel-facility/hotel-facility.controller.ts b/src/modules/hotel-facility/hotel-facility.controller.ts new file mode 100644 index 0000000..ecd1afc --- /dev/null +++ b/src/modules/hotel-facility/hotel-facility.controller.ts @@ -0,0 +1,195 @@ +import { Controller } from "@/common/controller"; +import { ormMiddleware } from "@/common/middlewares/orm.middleware"; +import { paginationQuerySchema } from "@/common/schemas"; +import type { + ErrorResponse, + ListResponse, + SingleResponse, +} from "@/common/types"; +import { HotelFacility } from "@/database/entities/hotel-facility.entity"; +import { orm } from "@/database/orm"; +import type { HotelFacilityMapper } from "@/modules/hotel-facility/hotel-facility.mapper"; +import { + hotelFacilityParamsSchema, + hotelFacilityRequestSchema, +} from "@/modules/hotel-facility/hotel-facility.schemas"; +import type { HotelFacilityResponse } from "@/modules/hotel-facility/hotel-facility.types"; +import { Router, type Request, type Response } from "express"; +import { ulid } from "ulid"; + +export class HotelFacilityController extends Controller { + public constructor(private readonly mapper: HotelFacilityMapper) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = hotelFacilityRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const hotelFacility = orm.em.create(HotelFacility, { + id: ulid(), + name: body.name, + icon: body.icon, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(hotelFacility), + errors: null, + } satisfies SingleResponse); + } + + async list(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const count = await orm.em.count(HotelFacility); + + const hotelFacilities = await orm.em.find( + HotelFacility, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + }, + ); + + return res.status(200).json({ + data: hotelFacilities.map( + this.mapper.mapEntityToResponse.bind(this.mapper), + ), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async view(req: Request, res: Response) { + const parseParamsResult = hotelFacilityParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const hotelFacility = await orm.em.findOne(HotelFacility, { + id: params.id, + }); + if (!hotelFacility) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Hotel facility not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(hotelFacility), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = hotelFacilityParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = hotelFacilityRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const hotelFacility = await orm.em.findOne(HotelFacility, { + id: params.id, + }); + if (!hotelFacility) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Hotel facility not found.", + }, + ], + } satisfies ErrorResponse); + } + + hotelFacility.name = body.name; + hotelFacility.icon = body.icon; + hotelFacility.updatedAt = new Date(); + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(hotelFacility), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = hotelFacilityParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const hotelFacility = await orm.em.findOne( + HotelFacility, + { + id: params.id, + }, + { + populate: ["*"], + }, + ); + if (!hotelFacility) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Hotel facility not found.", + }, + ], + } satisfies ErrorResponse); + } + + await orm.em.removeAndFlush(hotelFacility); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post("/", ormMiddleware, this.create.bind(this)); + router.get("/", ormMiddleware, this.list.bind(this)); + router.get("/:id", ormMiddleware, this.view.bind(this)); + router.put("/:id", ormMiddleware, this.update.bind(this)); + router.delete("/:id", ormMiddleware, this.delete.bind(this)); + + return router; + } +} diff --git a/src/modules/hotel-facility/hotel-facility.mapper.ts b/src/modules/hotel-facility/hotel-facility.mapper.ts new file mode 100644 index 0000000..e064c2b --- /dev/null +++ b/src/modules/hotel-facility/hotel-facility.mapper.ts @@ -0,0 +1,16 @@ +import type { HotelFacility } from "@/database/entities/hotel-facility.entity"; +import type { HotelFacilityResponse } from "@/modules/hotel-facility/hotel-facility.types"; + +export class HotelFacilityMapper { + public mapEntityToResponse( + hotelFacility: HotelFacility, + ): HotelFacilityResponse { + return { + id: hotelFacility.id, + name: hotelFacility.name, + icon: hotelFacility.icon, + created_at: hotelFacility.createdAt, + updated_at: hotelFacility.updatedAt, + }; + } +} diff --git a/src/modules/hotel-facility/hotel-facility.schemas.ts b/src/modules/hotel-facility/hotel-facility.schemas.ts new file mode 100644 index 0000000..0a9db88 --- /dev/null +++ b/src/modules/hotel-facility/hotel-facility.schemas.ts @@ -0,0 +1,19 @@ +import z from "zod"; + +export const hotelFacilityRequestSchema = z.object({ + name: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), + icon: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), +}); + +export const hotelFacilityParamsSchema = z.object({ + id: z + .ulid("Must be ulid string.") + .nonempty("Must not empty.") + .max(30, "Max 30 characters."), +}); diff --git a/src/modules/hotel-facility/hotel-facility.types.ts b/src/modules/hotel-facility/hotel-facility.types.ts new file mode 100644 index 0000000..0039784 --- /dev/null +++ b/src/modules/hotel-facility/hotel-facility.types.ts @@ -0,0 +1,17 @@ +import type { + hotelFacilityParamsSchema, + hotelFacilityRequestSchema, +} from "@/modules/hotel-facility/hotel-facility.schemas"; +import z from "zod"; + +export type HotelFacilityRequest = z.infer; + +export type HotelFacilityParams = z.infer; + +export type HotelFacilityResponse = { + id: string; + name: string; + icon: string; + created_at: Date; + updated_at: Date; +}; diff --git a/src/modules/hotel/hotel.controller.ts b/src/modules/hotel/hotel.controller.ts new file mode 100644 index 0000000..713dbb4 --- /dev/null +++ b/src/modules/hotel/hotel.controller.ts @@ -0,0 +1,343 @@ +import { Controller } from "@/common/controller"; +import { ormMiddleware } from "@/common/middlewares/orm.middleware"; +import { paginationQuerySchema } from "@/common/schemas"; +import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage"; +import type { + ErrorResponse, + ListResponse, + SingleResponse, +} from "@/common/types"; +import { City } from "@/database/entities/city.entity"; +import { HotelFacility } from "@/database/entities/hotel-facility.entity"; +import { HotelImage } from "@/database/entities/hotel-image.entity"; +import { Hotel } from "@/database/entities/hotel.entity"; +import { orm } from "@/database/orm"; +import type { HotelMapper } from "@/modules/hotel/hotel.mapper"; +import { + hotelParamsSchema, + hotelRequestSchema, +} from "@/modules/hotel/hotel.schemas"; +import type { HotelResponse } from "@/modules/hotel/hotel.types"; +import { Router, type Request, type Response } from "express"; +import { ulid } from "ulid"; + +export class HotelController extends Controller { + public constructor( + private readonly mapper: HotelMapper, + private readonly fileStorage: AbstractFileStorage, + ) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = hotelRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const city = await orm.em.findOne( + City, + { id: body.city_id }, + { populate: ["*"] }, + ); + if (!city) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "city_id", + location: "body", + message: "City not found.", + }, + ], + } satisfies ErrorResponse); + } + + const hotel = orm.em.create(Hotel, { + id: ulid(), + name: body.name, + city: city, + star: body.star, + googleMapsLink: body.google_maps_link, + googleMapsEmbed: body.google_maps_embed, + googleReviewsLink: body.google_reviews_link, + description: body.description, + address: body.address, + landmark: body.landmark, + distanceToLandmark: body.distance_to_landmark, + foodType: body.food_type, + foodAmount: body.food_amount, + foodMenu: body.food_menu, + createdAt: new Date(), + updatedAt: new Date(), + }); + + for (const [index, image] of body.images.entries()) { + const imageFile = await this.fileStorage.storeFile( + Buffer.from(image, "base64"), + ); + + const hotelImage = orm.em.create(HotelImage, { + id: ulid(), + hotel: hotel, + src: imageFile.name, + createdAt: new Date(), + updatedAt: new Date(), + }); + + hotel.images.add(hotelImage); + } + + for (const [index, facilityId] of body.facility_ids.entries()) { + const facility = await orm.em.findOne(HotelFacility, { + id: facilityId, + }); + if (!facility) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `facility_ids.${index}`, + location: "body", + message: "Hotel facility not found.", + }, + ], + } satisfies ErrorResponse); + } + + hotel.facilities.add(facility); + } + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(hotel), + errors: null, + } satisfies SingleResponse); + } + + async list(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const count = await orm.em.count(Hotel); + + const hotels = await orm.em.find( + Hotel, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + populate: ["*"], + }, + ); + + return res.status(200).json({ + data: hotels.map(this.mapper.mapEntityToResponse.bind(this.mapper)), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async view(req: Request, res: Response) { + const parseParamsResult = hotelParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const hotel = await orm.em.findOne( + Hotel, + { id: params.id }, + { populate: ["*"] }, + ); + if (!hotel) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Hotel not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(hotel), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = hotelParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = hotelRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const city = await orm.em.findOne( + City, + { id: body.city_id }, + { populate: ["*"] }, + ); + if (!city) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "city_id", + location: "body", + message: "City not found.", + }, + ], + } satisfies ErrorResponse); + } + + const hotel = await orm.em.findOne( + Hotel, + { id: params.id }, + { populate: ["*"] }, + ); + if (!hotel) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Hotel not found.", + }, + ], + } satisfies ErrorResponse); + } + + hotel.name = body.name; + hotel.city = city; + hotel.star = body.star; + hotel.googleMapsLink = body.google_maps_link; + hotel.googleMapsEmbed = body.google_maps_embed; + hotel.googleReviewsLink = body.google_reviews_link; + hotel.description = body.description; + hotel.address = body.address; + hotel.landmark = body.landmark; + hotel.distanceToLandmark = body.distance_to_landmark; + hotel.foodType = body.food_type; + hotel.foodAmount = body.food_amount; + hotel.foodMenu = body.food_menu; + hotel.updatedAt = new Date(); + + for (const hotelImage of hotel.images) { + await this.fileStorage.removeFile(hotelImage.src); + + orm.em.remove(hotelImage); + } + for (const [index, image] of body.images.entries()) { + const imageFile = await this.fileStorage.storeFile( + Buffer.from(image, "base64"), + ); + + const hotelImage = orm.em.create(HotelImage, { + id: ulid(), + hotel: hotel, + src: imageFile.name, + createdAt: new Date(), + updatedAt: new Date(), + }); + + hotel.images.add(hotelImage); + } + + hotel.facilities.removeAll(); + for (const [index, facilityId] of body.facility_ids.entries()) { + const facility = await orm.em.findOne(HotelFacility, { + id: facilityId, + }); + if (!facility) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `facility_ids.${index}`, + location: "body", + message: "Hotel facility not found.", + }, + ], + } satisfies ErrorResponse); + } + + hotel.facilities.add(facility); + } + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(hotel), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = hotelParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const hotel = await orm.em.findOne( + Hotel, + { id: params.id }, + { + populate: ["*"], + }, + ); + if (!hotel) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Hotel not found.", + }, + ], + } satisfies ErrorResponse); + } + + for (const image of hotel.images.getItems()) { + await this.fileStorage.removeFile(image.src); + } + + await orm.em.removeAndFlush(hotel); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post("/", ormMiddleware, this.create.bind(this)); + router.get("/", ormMiddleware, this.list.bind(this)); + router.get("/:id", ormMiddleware, this.view.bind(this)); + router.put("/:id", ormMiddleware, this.update.bind(this)); + router.delete("/:id", ormMiddleware, this.delete.bind(this)); + + return router; + } +} diff --git a/src/modules/hotel/hotel.mapper.ts b/src/modules/hotel/hotel.mapper.ts new file mode 100644 index 0000000..cc9ce72 --- /dev/null +++ b/src/modules/hotel/hotel.mapper.ts @@ -0,0 +1,40 @@ +import type { Hotel } from "@/database/entities/hotel.entity"; +import type { CityMapper } from "@/modules/city/city.mapper"; +import type { HotelFacilityMapper } from "@/modules/hotel-facility/hotel-facility.mapper"; +import type { HotelResponse } from "@/modules/hotel/hotel.types"; + +export class HotelMapper { + public constructor( + private readonly cityMapper: CityMapper, + private readonly hotelFacilityMapper: HotelFacilityMapper, + ) {} + + public mapEntityToResponse(hotel: Hotel): HotelResponse { + return { + id: hotel.id, + name: hotel.name, + city: this.cityMapper.mapEntityToResponse(hotel.city), + star: hotel.star, + images: hotel.images.getItems().map((image) => image.src), + google_maps_link: hotel.googleMapsLink, + google_maps_embed: hotel.googleMapsEmbed, + google_reviews_link: hotel.googleReviewsLink, + description: hotel.description, + facilities: hotel.facilities + .getItems() + .map( + this.hotelFacilityMapper.mapEntityToResponse.bind( + this.hotelFacilityMapper, + ), + ), + address: hotel.address, + landmark: hotel.landmark, + distance_to_landmark: hotel.distanceToLandmark, + food_type: hotel.foodType, + food_amount: hotel.foodAmount, + food_menu: hotel.foodMenu, + created_at: hotel.createdAt, + updated_at: hotel.updatedAt, + }; + } +} diff --git a/src/modules/hotel/hotel.schemas.ts b/src/modules/hotel/hotel.schemas.ts index 44fc23d..ad41a2f 100644 --- a/src/modules/hotel/hotel.schemas.ts +++ b/src/modules/hotel/hotel.schemas.ts @@ -1,22 +1,11 @@ import z from "zod"; -export const hotelFacilityRequestSchema = z.object({ - name: z - .string("Must be string.") - .nonempty("Must not empty.") - .max(100, "Max 100 characters."), - icon: z - .string("Must be string.") - .nonempty("Must not empty.") - .max(100, "Max 100 characters."), -}); - export const hotelRequestSchema = z.object({ name: z .string("Must be string.") .nonempty("Must not empty.") .max(100, "Max 100 characters."), - city_slug: z + city_id: z .string("Must be string.") .nonempty("Must not empty.") .max(200, "Max 200 characters."), @@ -34,20 +23,20 @@ export const hotelRequestSchema = z.object({ google_maps_link: z .url("Must be valid string URL.") .nonempty("Must not empty.") - .max(500, "Max 500 characters."), + .max(1000, "Max 1000 characters."), google_maps_embed: z - .url("Must be valid string URL.") + .string("Must be string.") .nonempty("Must not empty.") - .max(500, "Max 500 characters."), + .max(1000, "Max 1000 characters."), google_reviews_link: z .url("Must be valid string URL.") .nonempty("Must not empty.") - .max(500, "Max 500 characters."), + .max(1000, "Max 1000 characters."), description: z .string("Must be string.") .nonempty("Must not empty.") .max(1000, "Max 1000 characters."), - facility_slugs: z + facility_ids: z .array( z .string("Must be string.") @@ -81,15 +70,8 @@ export const hotelRequestSchema = z.object({ .max(100, "Max 100 characters."), }); -export const hotelFacilityQuerySchema = z.object({ - slug: z - .string("Must be string.") - .nonempty("Must not empty.") - .max(200, "Max 200 characters."), -}); - -export const hotelQuerySchema = z.object({ - slug: z +export const hotelParamsSchema = z.object({ + id: z .string("Must be string.") .nonempty("Must not empty.") .max(200, "Max 200 characters."), diff --git a/src/modules/hotel/hotel.types.ts b/src/modules/hotel/hotel.types.ts index cbd5e68..f5ff80d 100644 --- a/src/modules/hotel/hotel.types.ts +++ b/src/modules/hotel/hotel.types.ts @@ -1,34 +1,21 @@ import type { CityResponse } from "@/modules/city/city.types"; +import type { HotelFacilityResponse } from "@/modules/hotel-facility/hotel-facility.types"; import type { - hotelFacilityRequestSchema, - hotelQuerySchema, + hotelParamsSchema, hotelRequestSchema, } from "@/modules/hotel/hotel.schemas"; import z from "zod"; -export type HotelFacilityRequest = z.infer; - export type HotelRequest = z.infer; -export type HotelFacilityQuery = z.infer; - -export type HotelQuery = z.infer; - -export type HotelFacilityResponse = { - id: string; - slug: string; - name: string; - icon: string; - created_at: Date; - updated_at: Date; -}; +export type HotelParams = z.infer; export type HotelResponse = { id: string; - slug: string; name: string; city: CityResponse; star: number; + images: string[]; google_maps_link: string; google_maps_embed: string; google_reviews_link: string; diff --git a/src/modules/package/package.controller.ts b/src/modules/package/package.controller.ts new file mode 100644 index 0000000..40df0a9 --- /dev/null +++ b/src/modules/package/package.controller.ts @@ -0,0 +1,1358 @@ +import { Controller } from "@/common/controller"; +import { ormMiddleware } from "@/common/middlewares/orm.middleware"; +import { paginationQuerySchema } from "@/common/schemas"; +import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage"; +import type { + ErrorResponse, + ListResponse, + SingleResponse, +} from "@/common/types"; +import { FlightClass } from "@/database/entities/flight-class.entity"; +import { FlightSchedule } from "@/database/entities/flight-schedule.entity"; +import { HotelSchedule } from "@/database/entities/hotel-schedule.entity"; +import { Hotel } from "@/database/entities/hotel.entity"; +import { PackageDetail } from "@/database/entities/package-detail.entity"; +import { PackageItineraryDay } from "@/database/entities/package-itinerary-day.entity"; +import { PackageItineraryImage } from "@/database/entities/package-itinerary-image.entity"; +import { + PackageItineraryWidgetHotel, + PackageItineraryWidgetInformation, + PackageItineraryWidgetTransport, +} from "@/database/entities/package-itinerary-widget.entity"; +import { PackageItinerary } from "@/database/entities/package-itinerary.entity"; +import { Package } from "@/database/entities/package.entity"; +import { TransportationClass } from "@/database/entities/transportation-class.entity"; +import { PackageItineraryWidgetType } from "@/database/enums/package-itinerary-widget-type.enum"; +import { orm } from "@/database/orm"; +import type { PackageMapper } from "@/modules/package/package.mapper"; +import { + packageDetailParamsSchema, + packageDetailRequestSchema, + packageParamsSchema, + packageRequestSchema, +} from "@/modules/package/package.schemas"; +import type { + PackageDetailResponse, + PackageResponse, +} from "@/modules/package/package.types"; +import { wrap } from "@mikro-orm/core"; +import { Router, type Request, type Response } from "express"; +import { ulid } from "ulid"; + +export class PackageController extends Controller { + public constructor( + private readonly mapper: PackageMapper, + private readonly fileStorage: AbstractFileStorage, + ) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = packageRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const thumbnailFile = await this.fileStorage.storeFile( + Buffer.from(body.thumbnail, "base64"), + ); + + const package_ = orm.em.create(Package, { + id: ulid(), + name: body.name, + type: this.mapper.mapPackageType(body.type), + class: this.mapper.mapPackageClass(body.class), + thumbnail: thumbnailFile.name, + useFastTrain: body.use_fast_train, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(package_), + errors: null, + } satisfies SingleResponse); + } + + async list(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const count = await orm.em.count(Package); + + const packages = await orm.em.find( + Package, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + populate: ["*"], + }, + ); + + return res.status(200).json({ + data: packages.map(this.mapper.mapEntityToResponse.bind(this.mapper)), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async view(req: Request, res: Response) { + const parseParamsResult = packageParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const package_ = await orm.em.findOne( + Package, + { id: params.id }, + { populate: ["*"] }, + ); + if (!package_) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Package not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(package_), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = packageParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = packageRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const package_ = await orm.em.findOne( + Package, + { id: params.id }, + { populate: ["*"] }, + ); + if (!package_) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Package not found.", + }, + ], + } satisfies ErrorResponse); + } + + await this.fileStorage.storeFile( + Buffer.from(body.thumbnail, "base64"), + package_.thumbnail, + ); + + package_.name = body.name; + package_.type = this.mapper.mapPackageType(body.type); + package_.class = this.mapper.mapPackageClass(body.class); + package_.useFastTrain = body.use_fast_train; + package_.updatedAt = new Date(); + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(package_), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = packageParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const package_ = await orm.em.findOne( + Package, + { + id: params.id, + }, + { + populate: ["*"], + }, + ); + if (!package_) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Package not found.", + }, + ], + } satisfies ErrorResponse); + } + + await this.fileStorage.removeFile(package_.thumbnail); + + await orm.em.removeAndFlush(package_); + + return res.status(204).send(); + } + + async createDetail(req: Request, res: Response) { + const parseParamsResult = packageParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = packageDetailRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const package_ = await orm.em.findOne( + Package, + { id: params.id }, + { populate: ["*"] }, + ); + if (!package_) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Package not found.", + }, + ], + } satisfies ErrorResponse); + } + + let tourFlightSchedule: FlightSchedule | null = null; + for (const [index, tourFlightId] of (body.tour_flight_ids ?? []) + .toReversed() + .entries()) { + const tourFlight = await orm.em.findOne( + FlightClass, + { id: tourFlightId }, + { populate: ["*"] }, + ); + if (!tourFlight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `tour_flight_ids.${index}`, + location: "body", + message: "Flight class not found.", + }, + ], + } satisfies ErrorResponse); + } + + tourFlightSchedule = orm.em.create(FlightSchedule, { + id: ulid(), + flight: tourFlight, + next: tourFlightSchedule, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + let outboundFlightSchedule: FlightSchedule | null = null; + for (const [ + index, + outboundFlightId, + ] of body.outbound_flight_ids.entries()) { + const outboundFlight = await orm.em.findOne( + FlightClass, + { id: outboundFlightId }, + { populate: ["*"] }, + ); + if (!outboundFlight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `outbound_flight_ids.${index}`, + location: "body", + message: "Flight class not found.", + }, + ], + } satisfies ErrorResponse); + } + + outboundFlightSchedule = orm.em.create(FlightSchedule, { + id: ulid(), + flight: outboundFlight, + next: outboundFlightSchedule, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + if (outboundFlightSchedule === null) { + return res.status(400).json({ + data: null, + errors: [ + { + path: "outbound_flight_ids", + location: "body", + message: "Must not empty.", + }, + ], + }); + } + + let inboundFlightSchedule: FlightSchedule | null = null; + for (const [index, inboundFlightId] of body.inbound_flight_ids + .toReversed() + .entries()) { + const inboundFlight = await orm.em.findOne( + FlightClass, + { id: inboundFlightId }, + { populate: ["*"] }, + ); + if (!inboundFlight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `inbound_flight_ids.${index}`, + location: "body", + message: "Flight class not found.", + }, + ], + } satisfies ErrorResponse); + } + + inboundFlightSchedule = orm.em.create(FlightSchedule, { + id: ulid(), + flight: inboundFlight, + next: inboundFlightSchedule, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + if (inboundFlightSchedule === null) { + return res.status(400).json({ + data: null, + errors: [ + { + path: "inbound_flight_ids", + location: "body", + message: "Must not empty.", + }, + ], + }); + } + + const makkahHotel = await orm.em.findOne( + Hotel, + { + id: body.makkah_hotel.hotel_id, + }, + { + populate: ["*"], + }, + ); + if (!makkahHotel) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "makkah_hotel.hotel_id", + location: "body", + message: "Hotel not found.", + }, + ], + }); + } + const makkahHotelSchedule = orm.em.create(HotelSchedule, { + id: ulid(), + hotel: makkahHotel, + checkIn: `${body.makkah_hotel.check_in.getHours()}:${body.makkah_hotel.check_in.getMinutes()}:00`, + checkOut: `${body.makkah_hotel.check_out.getHours()}:${body.makkah_hotel.check_out.getMinutes()}:00`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const madinahHotel = await orm.em.findOne( + Hotel, + { + id: body.madinah_hotel.hotel_id, + }, + { + populate: ["*"], + }, + ); + if (!madinahHotel) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "madinah_hotel.hotel_id", + location: "body", + message: "Hotel not found.", + }, + ], + }); + } + const madinahHotelSchedule = orm.em.create(HotelSchedule, { + id: ulid(), + hotel: madinahHotel, + checkIn: `${body.madinah_hotel.check_in.getHours()}:${body.madinah_hotel.check_in.getMinutes()}:00`, + checkOut: `${body.madinah_hotel.check_out.getHours()}:${body.madinah_hotel.check_out.getMinutes()}:00`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const transportationClass = await orm.em.findOne( + TransportationClass, + { + id: body.transportation_id, + }, + { + populate: ["*"], + }, + ); + if (!transportationClass) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "transportation_id", + location: "body", + message: "Transportation class not found.", + }, + ], + }); + } + + let itineraryEntity: PackageItinerary | null = null; + for (const [index, itinerary] of body.itineraries.toReversed().entries()) { + let itineraryDayEntity: PackageItineraryDay | null = null; + for (const [dayIndex, itineraryDay] of itinerary.days + .toReversed() + .entries()) { + itineraryDayEntity = orm.em.create(PackageItineraryDay, { + id: ulid(), + title: itineraryDay.title, + description: itineraryDay.description, + next: itineraryDayEntity, + createdAt: new Date(), + updatedAt: new Date(), + }); + + for (const [ + widgetIndex, + itineraryWidget, + ] of itineraryDay.widgets.entries()) { + switch (itineraryWidget.type) { + case "transport": + const transportWidget = orm.em.create( + PackageItineraryWidgetTransport, + { + id: ulid(), + packageItineraryDay: itineraryDayEntity, + type: PackageItineraryWidgetType.transport, + createdAt: new Date(), + updatedAt: new Date(), + transportation: itineraryWidget.transportation, + from: itineraryWidget.from, + to: itineraryWidget.to, + }, + ); + itineraryDayEntity.widgets.add(transportWidget); + break; + case "hotel": + const hotel = await orm.em.findOne( + Hotel, + { + id: itineraryWidget.hotel_id, + }, + { + populate: ["*"], + }, + ); + if (!hotel) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `itineraries.${index}.days.${dayIndex}.widgets.${widgetIndex}.hotel_id`, + location: "body", + message: "Hotel not found.", + }, + ], + }); + } + const hotelWidget = orm.em.create(PackageItineraryWidgetHotel, { + id: ulid(), + packageItineraryDay: itineraryDayEntity, + type: PackageItineraryWidgetType.hotel, + createdAt: new Date(), + updatedAt: new Date(), + hotel, + }); + itineraryDayEntity.widgets.add(hotelWidget); + break; + case "information": + const informationWidget = orm.em.create( + PackageItineraryWidgetInformation, + { + id: ulid(), + packageItineraryDay: itineraryDayEntity, + type: PackageItineraryWidgetType.information, + createdAt: new Date(), + updatedAt: new Date(), + description: itineraryWidget.description, + }, + ); + itineraryDayEntity.widgets.add(informationWidget); + break; + } + } + } + if (itineraryDayEntity === null) { + return res.status(400).json({ + data: null, + errors: [ + { + path: `itineraries.${index}.days`, + location: "body", + message: "Must not empty.", + }, + ], + } satisfies ErrorResponse); + } + + itineraryEntity = orm.em.create(PackageItinerary, { + id: ulid(), + location: itinerary.location, + day: itineraryDayEntity, + next: itineraryEntity, + createdAt: new Date(), + updatedAt: new Date(), + }); + + for (const [index, image] of itinerary.images.entries()) { + const imageFile = await this.fileStorage.storeFile( + Buffer.from(image, "base64"), + ); + + const itineraryImage = orm.em.create(PackageItineraryImage, { + id: ulid(), + packageItinerary: itineraryEntity, + src: imageFile.name, + createdAt: new Date(), + updatedAt: new Date(), + }); + + itineraryEntity.images.add(itineraryImage); + } + } + if (itineraryEntity === null) { + return res.status(400).json({ + data: null, + errors: [ + { + path: "itineraries", + location: "body", + message: "Must not empty.", + }, + ], + }); + } + + const packageDetail = orm.em.create(PackageDetail, { + id: ulid(), + package: package_, + departureDate: body.departure_date, + tourFlight: tourFlightSchedule, + outboundFlight: outboundFlightSchedule, + inboundFlight: inboundFlightSchedule, + makkahHotel: makkahHotelSchedule, + madinahHotel: madinahHotelSchedule, + transportation: transportationClass, + quadPrice: body.quad_price, + triplePrice: body.triple_price, + doublePrice: body.double_price, + infantPrice: body.infant_price, + itinerary: itineraryEntity, + createdAt: new Date(), + updatedAt: new Date(), + }); + + for (const [index, tourHotel] of (body.tour_hotels ?? []).entries()) { + const tourHotelEntity = await orm.em.findOne( + Hotel, + { + id: tourHotel.hotel_id, + }, + { + populate: ["*"], + }, + ); + if (!tourHotelEntity) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `tour_hotels.${index}.hotel_id`, + location: "body", + message: "Hotel not found.", + }, + ], + }); + } + + const tourHotelSchedule = orm.em.create(HotelSchedule, { + id: ulid(), + hotel: tourHotelEntity, + checkIn: `${tourHotel.check_in.getHours()}:${tourHotel.check_in.getMinutes()}:00`, + checkOut: `${tourHotel.check_out.getHours()}:${tourHotel.check_out.getMinutes()}:00`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + packageDetail.tourHotels.add(tourHotelSchedule); + } + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapDetailEntityToResponse(packageDetail), + errors: null, + } satisfies SingleResponse); + } + + async listDetails(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const parseParamsResult = packageParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const package_ = await orm.em.findOne( + Package, + { id: params.id }, + { + populate: ["*"], + }, + ); + if (!package_) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Package not found.", + }, + ], + } satisfies ErrorResponse); + } + + const count = await orm.em.count(PackageDetail, { + package: package_, + }); + + const details = await orm.em.find( + PackageDetail, + { package: package_ }, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + populate: ["*"], + }, + ); + + return res.status(200).json({ + data: details.map( + this.mapper.mapDetailEntityToResponse.bind(this.mapper), + ), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async viewDetail(req: Request, res: Response) { + const parseParamsResult = packageDetailParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const package_ = await orm.em.findOne( + Package, + { id: params.package_id }, + { populate: ["*"] }, + ); + if (!package_) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Package not found.", + }, + ], + } satisfies ErrorResponse); + } + + const packageDetail = await orm.em.findOne( + PackageDetail, + { + id: params.id, + package: package_, + }, + { populate: ["*"] }, + ); + if (!packageDetail) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Package detail not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapDetailEntityToResponse(packageDetail), + errors: null, + } satisfies SingleResponse); + } + + async updateDetail(req: Request, res: Response) { + const parseParamsResult = packageDetailParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = packageDetailRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const package_ = await orm.em.findOne( + Package, + { id: params.package_id }, + { populate: ["*"] }, + ); + if (!package_) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "package_id", + location: "params", + message: "Package not found.", + }, + ], + } satisfies ErrorResponse); + } + + const packageDetail = await orm.em.findOne( + PackageDetail, + { + id: params.id, + package: package_, + }, + { populate: ["*"] }, + ); + if (!packageDetail) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Package detail not found.", + }, + ], + } satisfies ErrorResponse); + } + + for ( + let tourFlight: FlightSchedule | null = packageDetail.tourFlight; + tourFlight !== null; + tourFlight = tourFlight.next + ) { + orm.em.remove(tourFlight); + } + let tourFlightSchedule: FlightSchedule | null = null; + for (const [index, tourFlightId] of (body.tour_flight_ids ?? []) + .toReversed() + .entries()) { + const tourFlight = await orm.em.findOne( + FlightClass, + { id: tourFlightId }, + { populate: ["*"] }, + ); + if (!tourFlight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `tour_flight_ids.${index}`, + location: "body", + message: "Flight class not found.", + }, + ], + } satisfies ErrorResponse); + } + + tourFlightSchedule = orm.em.create(FlightSchedule, { + id: ulid(), + flight: tourFlight, + next: tourFlightSchedule, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + for ( + let outboundFlight: FlightSchedule | null = packageDetail.outboundFlight; + outboundFlight !== null; + outboundFlight = outboundFlight.next + ) { + orm.em.remove(outboundFlight); + } + let outboundFlightSchedule: FlightSchedule | null = null; + for (const [ + index, + outboundFlightId, + ] of body.outbound_flight_ids.entries()) { + const outboundFlight = await orm.em.findOne( + FlightClass, + { id: outboundFlightId }, + { populate: ["*"] }, + ); + if (!outboundFlight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `outbound_flight_ids.${index}`, + location: "body", + message: "Flight class not found.", + }, + ], + } satisfies ErrorResponse); + } + + outboundFlightSchedule = orm.em.create(FlightSchedule, { + id: ulid(), + flight: outboundFlight, + next: outboundFlightSchedule, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + if (outboundFlightSchedule === null) { + return res.status(400).json({ + data: null, + errors: [ + { + path: "outbound_flight_ids", + location: "body", + message: "Must not empty.", + }, + ], + }); + } + + for ( + let inboundFlight: FlightSchedule | null = packageDetail.inboundFlight; + inboundFlight !== null; + inboundFlight = inboundFlight.next + ) { + orm.em.remove(inboundFlight); + } + let inboundFlightSchedule: FlightSchedule | null = null; + for (const [index, inboundFlightId] of body.inbound_flight_ids + .toReversed() + .entries()) { + const inboundFlight = await orm.em.findOne( + FlightClass, + { id: inboundFlightId }, + { populate: ["*"] }, + ); + if (!inboundFlight) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `inbound_flight_ids.${index}`, + location: "body", + message: "Flight class not found.", + }, + ], + } satisfies ErrorResponse); + } + + inboundFlightSchedule = orm.em.create(FlightSchedule, { + id: ulid(), + flight: inboundFlight, + next: inboundFlightSchedule, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + if (inboundFlightSchedule === null) { + return res.status(400).json({ + data: null, + errors: [ + { + path: "inbound_flight_ids", + location: "body", + message: "Must not empty.", + }, + ], + }); + } + + orm.em.remove(packageDetail.makkahHotel); + const makkahHotel = await orm.em.findOne( + Hotel, + { + id: body.makkah_hotel.hotel_id, + }, + { + populate: ["*"], + }, + ); + if (!makkahHotel) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "makkah_hotel.hotel_id", + location: "body", + message: "Hotel not found.", + }, + ], + }); + } + const makkahHotelSchedule = orm.em.create(HotelSchedule, { + id: ulid(), + hotel: makkahHotel, + checkIn: `${body.makkah_hotel.check_in.getHours()}:${body.makkah_hotel.check_in.getMinutes()}:00`, + checkOut: `${body.makkah_hotel.check_out.getHours()}:${body.makkah_hotel.check_out.getMinutes()}:00`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + orm.em.remove(packageDetail.madinahHotel); + const madinahHotel = await orm.em.findOne( + Hotel, + { + id: body.madinah_hotel.hotel_id, + }, + { + populate: ["*"], + }, + ); + if (!madinahHotel) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "madinah_hotel.hotel_id", + location: "body", + message: "Hotel not found.", + }, + ], + }); + } + const madinahHotelSchedule = orm.em.create(HotelSchedule, { + id: ulid(), + hotel: madinahHotel, + checkIn: `${body.madinah_hotel.check_in.getHours()}:${body.madinah_hotel.check_in.getMinutes()}:00`, + checkOut: `${body.madinah_hotel.check_out.getHours()}:${body.madinah_hotel.check_out.getMinutes()}:00`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const transportationClass = await orm.em.findOne( + TransportationClass, + { + id: body.transportation_id, + }, + { + populate: ["*"], + }, + ); + if (!transportationClass) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "transportation_id", + location: "body", + message: "Transportation class not found.", + }, + ], + }); + } + + for ( + let itinerary: PackageItinerary | null = packageDetail.itinerary; + itinerary !== null; + itinerary = itinerary.next + ) { + for ( + let itineraryDay: PackageItineraryDay | null = itinerary.day; + itineraryDay !== null; + itineraryDay = itineraryDay.next + ) { + for (const itineraryWidget of itineraryDay.widgets) { + orm.em.remove(itineraryWidget); + } + + orm.em.remove(itineraryDay); + } + + for (const itineraryImage of itinerary.images) { + await this.fileStorage.removeFile(itineraryImage.src); + + orm.em.remove(itineraryImage); + } + + orm.em.remove(itinerary); + } + let itineraryEntity: PackageItinerary | null = null; + for (const [index, itinerary] of body.itineraries.toReversed().entries()) { + let itineraryDayEntity: PackageItineraryDay | null = null; + for (const [dayIndex, itineraryDay] of itinerary.days + .toReversed() + .entries()) { + itineraryDayEntity = orm.em.create(PackageItineraryDay, { + id: ulid(), + title: itineraryDay.title, + description: itineraryDay.description, + next: itineraryDayEntity, + createdAt: new Date(), + updatedAt: new Date(), + }); + + for (const [ + widgetIndex, + itineraryWidget, + ] of itineraryDay.widgets.entries()) { + switch (itineraryWidget.type) { + case "transport": + const transportWidget = orm.em.create( + PackageItineraryWidgetTransport, + { + id: ulid(), + packageItineraryDay: itineraryDayEntity, + type: PackageItineraryWidgetType.transport, + createdAt: new Date(), + updatedAt: new Date(), + transportation: itineraryWidget.transportation, + from: itineraryWidget.from, + to: itineraryWidget.to, + }, + ); + itineraryDayEntity.widgets.add(transportWidget); + break; + case "hotel": + const hotel = await orm.em.findOne( + Hotel, + { + id: itineraryWidget.hotel_id, + }, + { + populate: ["*"], + }, + ); + if (!hotel) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `itineraries.${index}.days.${dayIndex}.widgets.${widgetIndex}.hotel_id`, + location: "body", + message: "Hotel not found.", + }, + ], + }); + } + const hotelWidget = orm.em.create(PackageItineraryWidgetHotel, { + id: ulid(), + packageItineraryDay: itineraryDayEntity, + type: PackageItineraryWidgetType.hotel, + createdAt: new Date(), + updatedAt: new Date(), + hotel, + }); + itineraryDayEntity.widgets.add(hotelWidget); + break; + case "information": + const informationWidget = orm.em.create( + PackageItineraryWidgetInformation, + { + id: ulid(), + packageItineraryDay: itineraryDayEntity, + type: PackageItineraryWidgetType.information, + createdAt: new Date(), + updatedAt: new Date(), + description: itineraryWidget.description, + }, + ); + itineraryDayEntity.widgets.add(informationWidget); + break; + } + } + } + if (itineraryDayEntity === null) { + return res.status(400).json({ + data: null, + errors: [ + { + path: `itineraries.${index}.days`, + location: "body", + message: "Must not empty.", + }, + ], + } satisfies ErrorResponse); + } + + itineraryEntity = orm.em.create(PackageItinerary, { + id: ulid(), + location: itinerary.location, + day: itineraryDayEntity, + next: itineraryEntity, + createdAt: new Date(), + updatedAt: new Date(), + }); + + for (const [index, image] of itinerary.images.entries()) { + const imageFile = await this.fileStorage.storeFile( + Buffer.from(image, "base64"), + ); + + const itineraryImage = orm.em.create(PackageItineraryImage, { + id: ulid(), + packageItinerary: itineraryEntity, + src: imageFile.name, + createdAt: new Date(), + updatedAt: new Date(), + }); + + itineraryEntity.images.add(itineraryImage); + } + } + if (itineraryEntity === null) { + return res.status(400).json({ + data: null, + errors: [ + { + path: "itineraries", + location: "body", + message: "Must not empty.", + }, + ], + }); + } + + wrap(packageDetail).assign({ + departureDate: body.departure_date, + tourFlight: tourFlightSchedule, + outboundFlight: outboundFlightSchedule, + inboundFlight: inboundFlightSchedule, + makkahHotel: makkahHotelSchedule, + madinahHotel: madinahHotelSchedule, + transportation: transportationClass, + quadPrice: body.quad_price, + triplePrice: body.triple_price, + doublePrice: body.double_price, + infantPrice: body.infant_price, + itinerary: itineraryEntity, + updatedAt: new Date(), + }); + + for (const tourHotel of packageDetail.tourHotels) { + orm.em.remove(tourHotel); + } + for (const [index, tourHotel] of (body.tour_hotels ?? []).entries()) { + const tourHotelEntity = await orm.em.findOne( + Hotel, + { + id: tourHotel.hotel_id, + }, + { + populate: ["*"], + }, + ); + if (!tourHotelEntity) { + return res.status(404).json({ + data: null, + errors: [ + { + path: `tour_hotels.${index}.hotel_id`, + location: "body", + message: "Hotel not found.", + }, + ], + }); + } + + const tourHotelSchedule = orm.em.create(HotelSchedule, { + id: ulid(), + hotel: tourHotelEntity, + checkIn: `${tourHotel.check_in.getHours()}:${tourHotel.check_in.getMinutes()}:00`, + checkOut: `${tourHotel.check_out.getHours()}:${tourHotel.check_out.getMinutes()}:00`, + createdAt: new Date(), + updatedAt: new Date(), + }); + + packageDetail.tourHotels.add(tourHotelSchedule); + } + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapDetailEntityToResponse(packageDetail), + errors: null, + } satisfies SingleResponse); + } + + async deleteDetail(req: Request, res: Response) { + const parseParamsResult = packageDetailParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const package_ = await orm.em.findOne( + Package, + { id: params.package_id }, + { populate: ["*"] }, + ); + if (!package_) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "package_id", + location: "params", + message: "Package not found.", + }, + ], + } satisfies ErrorResponse); + } + + const packageDetail = await orm.em.findOne( + PackageDetail, + { + id: params.id, + package: package_, + }, + { populate: ["*"] }, + ); + if (!packageDetail) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Package detail not found.", + }, + ], + } satisfies ErrorResponse); + } + + for ( + let itinerary: PackageItinerary | null = packageDetail.itinerary; + itinerary !== null; + itinerary = itinerary.next + ) { + for (const itineraryImage of itinerary.images) { + await this.fileStorage.removeFile(itineraryImage.src); + } + } + + await orm.em.removeAndFlush(packageDetail); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post("/", ormMiddleware, this.create.bind(this)); + router.get("/", ormMiddleware, this.list.bind(this)); + router.get("/:id", ormMiddleware, this.view.bind(this)); + router.put("/:id", ormMiddleware, this.update.bind(this)); + router.delete("/:id", ormMiddleware, this.delete.bind(this)); + router.post("/:id/details", ormMiddleware, this.createDetail.bind(this)); + router.get("/:id/details", ormMiddleware, this.listDetails.bind(this)); + router.get( + "/:package_id/details/:id", + ormMiddleware, + this.viewDetail.bind(this), + ); + router.put( + "/:package_id/details/:id", + ormMiddleware, + this.updateDetail.bind(this), + ); + router.delete( + "/:package_id/details/:id", + ormMiddleware, + this.deleteDetail.bind(this), + ); + + return router; + } +} diff --git a/src/modules/package/package.mapper.ts b/src/modules/package/package.mapper.ts new file mode 100644 index 0000000..60d7415 --- /dev/null +++ b/src/modules/package/package.mapper.ts @@ -0,0 +1,216 @@ +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 { + PackageItineraryWidget, + PackageItineraryWidgetHotel, + PackageItineraryWidgetInformation, + PackageItineraryWidgetTransport, +} from "@/database/entities/package-itinerary-widget.entity"; +import type { PackageItinerary } from "@/database/entities/package-itinerary.entity"; +import type { Package } from "@/database/entities/package.entity"; +import { PackageClass } from "@/database/enums/package-class.enum"; +import { PackageItineraryWidgetType } from "@/database/enums/package-itinerary-widget-type.enum"; +import { PackageType } from "@/database/enums/package-type.enum"; +import type { FlightMapper } from "@/modules/flight/flight.mapper"; +import type { FlightClassResponse } from "@/modules/flight/flight.types"; +import type { HotelMapper } from "@/modules/hotel/hotel.mapper"; +import type { + PackageDetailResponse, + PackageHotelResponse, + PackageItineraryDayResponse, + PackageItineraryResponse, + PackageItineraryWidgetResponse, + PackageRequest, + PackageResponse, +} from "@/modules/package/package.types"; +import type { TransportationMapper } from "@/modules/transportation/transportation.mapper"; +import * as dateFns from "date-fns"; + +export class PackageMapper { + public constructor( + private readonly flightMapper: FlightMapper, + private readonly hotelMapper: HotelMapper, + private readonly transportationMapper: TransportationMapper, + ) {} + + public mapPackageType(packageType: PackageRequest["type"]): PackageType { + switch (packageType) { + case "reguler": + return PackageType.reguler; + case "plus": + return PackageType.plus; + } + } + + public mapPackageClass(packageClass: PackageRequest["class"]): PackageClass { + switch (packageClass) { + case "silver": + return PackageClass.silver; + case "gold": + return PackageClass.gold; + case "platinum": + return PackageClass.platinum; + } + } + + public mapEntityToResponse(package_: Package): PackageResponse { + return { + id: package_.id, + name: package_.name, + type: package_.type, + class: package_.class, + thumbnail: package_.thumbnail, + use_fast_train: package_.useFastTrain, + created_at: package_.createdAt, + updated_at: package_.updatedAt, + }; + } + + private mapFlightSchedule( + flightSchedule: FlightSchedule, + ): FlightClassResponse[] { + const flightClassResponses: FlightClassResponse[] = []; + + let currentFlightSchedule: FlightSchedule | null = flightSchedule; + while (currentFlightSchedule !== null) { + flightClassResponses.push( + this.flightMapper.mapClassEntityToResponse( + currentFlightSchedule.flight, + ), + ); + currentFlightSchedule = currentFlightSchedule.next; + } + + return flightClassResponses; + } + + private mapHotelSchedule(hotelSchedule: HotelSchedule): PackageHotelResponse { + console.log(hotelSchedule.checkIn); + const checkIn = dateFns.parse(hotelSchedule.checkIn, "H:mm:ss", new Date()); + + const checkOut = dateFns.parse( + hotelSchedule.checkOut, + "H:mm:ss", + new Date(), + ); + + return { + hotel: this.hotelMapper.mapEntityToResponse(hotelSchedule.hotel), + check_in: dateFns.format(checkIn, "HH:mm"), + check_out: dateFns.format(checkOut, "HH:mm"), + }; + } + + private mapItineraryWidget( + packageItineraryWidget: PackageItineraryWidget, + ): PackageItineraryWidgetResponse { + switch (packageItineraryWidget.type) { + case PackageItineraryWidgetType.transport: + const transportWidget = + packageItineraryWidget as PackageItineraryWidgetTransport; + + return { + type: "transport", + transportation: transportWidget.transportation, + from: transportWidget.from, + to: transportWidget.to, + }; + case PackageItineraryWidgetType.hotel: + const hotelWidget = + packageItineraryWidget as PackageItineraryWidgetHotel; + + return { + type: "hotel", + hotel: this.hotelMapper.mapEntityToResponse(hotelWidget.hotel), + }; + case PackageItineraryWidgetType.information: + const informationWidget = + packageItineraryWidget as PackageItineraryWidgetInformation; + + return { + type: "information", + description: informationWidget.description, + }; + } + } + + private mapItineraryDay( + packageItineraryDay: PackageItineraryDay, + nth: number, + ): PackageItineraryDayResponse { + return { + nth, + title: packageItineraryDay.title, + description: packageItineraryDay.description, + widgets: packageItineraryDay.widgets + .getItems() + .map((widget) => this.mapItineraryWidget(widget)), + }; + } + + private mapItinerary( + packageItinerary: PackageItinerary, + nthDay: number = 1, + ): PackageItineraryResponse { + const days: PackageItineraryDayResponse[] = []; + for ( + let currentItineraryDay: PackageItineraryDay | null = + packageItinerary.day, + index = 0; + currentItineraryDay !== null; + currentItineraryDay = currentItineraryDay.next, index++ + ) { + days.push(this.mapItineraryDay(currentItineraryDay, nthDay + index)); + } + + return { + location: packageItinerary.location, + images: packageItinerary.images + .getItems() + .map((packageItineraryImage) => packageItineraryImage.src), + days, + }; + } + + public mapDetailEntityToResponse( + packageDetail: PackageDetail, + ): PackageDetailResponse { + const itineraries: PackageItineraryResponse[] = []; + for ( + let currentItinerary: PackageItinerary | null = packageDetail.itinerary, + nthDay = 1; + currentItinerary !== null; + currentItinerary = currentItinerary.next + ) { + const itineraryResponse = this.mapItinerary(currentItinerary, nthDay); + itineraries.push(itineraryResponse); + nthDay += itineraryResponse.days.length; + } + + return { + id: packageDetail.id, + package: this.mapEntityToResponse(packageDetail.package), + departure_date: dateFns.format(packageDetail.departureDate, "yyyy-MM-dd"), + tour_flights: packageDetail.tourFlight + ? this.mapFlightSchedule(packageDetail.tourFlight) + : null, + outbound_flights: this.mapFlightSchedule(packageDetail.outboundFlight), + inbound_flights: this.mapFlightSchedule(packageDetail.inboundFlight), + tour_hotels: packageDetail.tourHotels.map(this.mapHotelSchedule), + makkah_hotel: this.mapHotelSchedule(packageDetail.makkahHotel), + madinah_hotel: this.mapHotelSchedule(packageDetail.madinahHotel), + transportation: this.transportationMapper.mapClassEntityToResponse( + packageDetail.transportation, + ), + quad_price: packageDetail.quadPrice, + triple_price: packageDetail.triplePrice, + double_price: packageDetail.doublePrice, + infant_price: packageDetail.infantPrice, + itineraries, + created_at: packageDetail.createdAt, + updated_at: packageDetail.updatedAt, + }; + } +} diff --git a/src/modules/package/package.schemas.ts b/src/modules/package/package.schemas.ts index e166e8a..fc41158 100644 --- a/src/modules/package/package.schemas.ts +++ b/src/modules/package/package.schemas.ts @@ -17,53 +17,57 @@ export const packageRequestSchema = z.object({ export const packageDetailRequestSchema = z.object({ departure_date: dateSchema, - tour_flight_slugs: z + tour_flight_ids: z .array( z - .string("Must be string.") + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(420, "Max 420 characters."), + .max(30, "Max 30 characters."), + "Must be array.", + ) + .nonempty("Must not empty.") + .nullable(), + outbound_flight_ids: z + .array( + z + .ulid("Must be ulid string.") + .nonempty("Must not empty.") + .max(30, "Max 30 characters."), "Must be array.", ) .nonempty("Must not empty."), - outbound_flight_slugs: z + inbound_flight_ids: z .array( z - .string("Must be string.") + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(420, "Max 420 characters."), + .max(30, "Max 30 characters."), "Must be array.", ) .nonempty("Must not empty."), - inbound_flight_slugs: z + tour_hotels: z .array( - z - .string("Must be string.") - .nonempty("Must not empty.") - .max(420, "Max 420 characters."), + z.object( + { + hotel_id: z + .ulid("Must be ulid string.") + .nonempty("Must not empty.") + .max(30, "Max 30 characters."), + check_in: timeSchema, + check_out: timeSchema, + }, + "Must be object.", + ), "Must be array.", ) - .nonempty("Must not empty."), - tour_hotels: z.array( - z.object( - { - hotel_slug: z - .string("Must be string.") - .nonempty("Must not empty.") - .max(200, "Max 200 characters."), - check_in: timeSchema, - check_out: timeSchema, - }, - "Must be object.", - ), - "Must be array.", - ), + .nonempty("Must not empty.") + .nullable(), makkah_hotel: z.object( { - hotel_slug: z - .string("Must be string.") + hotel_id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), check_in: timeSchema, check_out: timeSchema, }, @@ -71,19 +75,19 @@ export const packageDetailRequestSchema = z.object({ ), madinah_hotel: z.object( { - hotel_slug: z - .string("Must be string.") + hotel_id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), check_in: timeSchema, check_out: timeSchema, }, "Must be object.", ), - transportation_slug: z - .string("Must be string.") + transportation_id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), quad_price: z .number("Must be number.") .int("Must be integer.") @@ -101,22 +105,97 @@ export const packageDetailRequestSchema = z.object({ .int("Must be integer.") .positive("Must be positive.") .nullable(), + itineraries: z.array( + z.object( + { + location: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), + images: z.array( + z.base64("Must be base64 string.").nonempty("Must not empty."), + "Must be array.", + ), + days: z.array( + z.object( + { + title: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), + description: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(1000, "Max 1000 characters."), + widgets: z.array( + z.discriminatedUnion("type", [ + z.object( + { + type: z.literal("transport"), + transportation: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), + from: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), + to: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(100, "Max 100 characters."), + }, + "Must be object.", + ), + z.object( + { + type: z.literal("hotel"), + hotel_id: z + .ulid("Must be ulid string.") + .nonempty("Must not empty.") + .max(30, "Max 30 characters."), + }, + "Must be object.", + ), + z.object( + { + type: z.literal("information"), + description: z + .string("Must be string.") + .nonempty("Must not empty.") + .max(1000, "Max 1000 characters."), + }, + "Must be object.", + ), + ]), + "Must be array.", + ), + }, + "Must be object.", + ), + "Must be array.", + ), + }, + "Must be object.", + ), + "Must be array.", + ), }); -export const packageQuerySchema = z.object({ - detail_slug: z - .string("Must be string.") +export const packageParamsSchema = z.object({ + id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), - slug: z - .string("Must be string.") - .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), }); -export const packageDetailQuerySchema = z.object({ - slug: z - .string("Must be string.") +export const packageDetailParamsSchema = z.object({ + package_id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), + id: z + .ulid("Must be ulid string.") + .nonempty("Must not empty.") + .max(30, "Max 30 characters."), }); diff --git a/src/modules/package/package.types.ts b/src/modules/package/package.types.ts index e395362..3f096fc 100644 --- a/src/modules/package/package.types.ts +++ b/src/modules/package/package.types.ts @@ -1,9 +1,9 @@ import type { FlightClassResponse } from "@/modules/flight/flight.types"; import type { HotelResponse } from "@/modules/hotel/hotel.types"; import type { - packageDetailQuerySchema, + packageDetailParamsSchema, packageDetailRequestSchema, - packageQuerySchema, + packageParamsSchema, packageRequestSchema, } from "@/modules/package/package.schemas"; import type { TransportationClassResponse } from "@/modules/transportation/transportation.types"; @@ -13,13 +13,12 @@ export type PackageRequest = z.infer; export type PackageDetailRequest = z.infer; -export type PackageQuery = z.infer; +export type PackageParams = z.infer; -export type PackageDetailQuery = z.infer; +export type PackageDetailParams = z.infer; export type PackageResponse = { id: string; - slug: string; name: string; type: "reguler" | "plus"; class: "silver" | "gold" | "platinum"; @@ -35,22 +34,51 @@ export type PackageHotelResponse = { check_out: string; }; +export type PackageItineraryWidgetResponse = + | { + type: "transport"; + transportation: string; + from: string; + to: string; + } + | { + type: "hotel"; + hotel: HotelResponse; + } + | { + type: "information"; + description: string; + }; + +export type PackageItineraryDayResponse = { + nth: number; + title: string; + description: string; + widgets: PackageItineraryWidgetResponse[]; +}; + +export type PackageItineraryResponse = { + location: string; + images: string[]; + days: PackageItineraryDayResponse[]; +}; + export type PackageDetailResponse = { id: string; - slug: string; package: PackageResponse; departure_date: string; - tour_flights: FlightClassResponse[]; + tour_flights: FlightClassResponse[] | null; outbound_flights: FlightClassResponse[]; inbound_flights: FlightClassResponse[]; - tour_hotels: PackageHotelResponse[]; + tour_hotels: PackageHotelResponse[] | null; makkah_hotel: PackageHotelResponse; - medina_hotel: PackageHotelResponse; + madinah_hotel: PackageHotelResponse; transportation: TransportationClassResponse; quad_price: number; triple_price: number; double_price: number; infant_price: number | null; + itineraries: PackageItineraryResponse[]; created_at: Date; updated_at: Date; }; diff --git a/src/modules/transportation/transportation.controller.ts b/src/modules/transportation/transportation.controller.ts new file mode 100644 index 0000000..9566926 --- /dev/null +++ b/src/modules/transportation/transportation.controller.ts @@ -0,0 +1,554 @@ +import { Controller } from "@/common/controller"; +import { ormMiddleware } from "@/common/middlewares/orm.middleware"; +import { paginationQuerySchema } from "@/common/schemas"; +import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage"; +import type { + ErrorResponse, + ListResponse, + SingleResponse, +} from "@/common/types"; +import { TransportationClass } from "@/database/entities/transportation-class.entity"; +import { TransportationImage } from "@/database/entities/transportation-image.entity"; +import { Transportation } from "@/database/entities/transportation.entity"; +import { orm } from "@/database/orm"; +import type { TransportationMapper } from "@/modules/transportation/transportation.mapper"; +import { + transportationClassParamsSchema, + transportationClassRequestSchema, + transportationParamsSchema, + transportationRequestSchema, +} from "@/modules/transportation/transportation.schemas"; +import type { + TransportationClassResponse, + TransportationResponse, +} from "@/modules/transportation/transportation.types"; +import { Router, type Request, type Response } from "express"; +import { ulid } from "ulid"; + +export class TransportationController extends Controller { + public constructor( + private readonly mapper: TransportationMapper, + private readonly fileStorage: AbstractFileStorage, + ) { + super(); + } + + async create(req: Request, res: Response) { + const parseBodyResult = transportationRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const transportation = orm.em.create(Transportation, { + id: ulid(), + name: body.name, + type: body.type, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapEntityToResponse(transportation), + errors: null, + } satisfies SingleResponse); + } + + async list(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const count = await orm.em.count(Transportation); + + const transportations = await orm.em.find( + Transportation, + {}, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + populate: ["*"], + }, + ); + + return res.status(200).json({ + data: transportations.map( + this.mapper.mapEntityToResponse.bind(this.mapper), + ), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async view(req: Request, res: Response) { + const parseParamsResult = transportationParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const transportation = await orm.em.findOne( + Transportation, + { id: params.id }, + { populate: ["*"] }, + ); + if (!transportation) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Transportation not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(transportation), + errors: null, + } satisfies SingleResponse); + } + + async update(req: Request, res: Response) { + const parseParamsResult = transportationParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = transportationRequestSchema.safeParse(req.body); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const transportation = await orm.em.findOne( + Transportation, + { id: params.id }, + { populate: ["*"] }, + ); + if (!transportation) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Transportation not found.", + }, + ], + } satisfies ErrorResponse); + } + + transportation.name = body.name; + transportation.type = body.type; + transportation.updatedAt = new Date(); + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapEntityToResponse(transportation), + errors: null, + } satisfies SingleResponse); + } + + async delete(req: Request, res: Response) { + const parseParamsResult = transportationParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const transportation = await orm.em.findOne( + Transportation, + { + id: params.id, + }, + { + populate: ["*"], + }, + ); + if (!transportation) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Transportation not found.", + }, + ], + } satisfies ErrorResponse); + } + + await orm.em.removeAndFlush(transportation); + + return res.status(204).send(); + } + + async createClass(req: Request, res: Response) { + const parseParamsResult = transportationParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = transportationClassRequestSchema.safeParse( + req.body, + ); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const transportation = await orm.em.findOne( + Transportation, + { id: params.id }, + { populate: ["*"] }, + ); + if (!transportation) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Transportation not found.", + }, + ], + } satisfies ErrorResponse); + } + + const transportationClass = orm.em.create(TransportationClass, { + id: ulid(), + transportation, + class: body.class, + totalSeats: body.total_seats, + createdAt: new Date(), + updatedAt: new Date(), + }); + + for (const [index, image] of body.images.entries()) { + const imageFile = await this.fileStorage.storeFile( + Buffer.from(image, "base64"), + ); + + const transportationImage = orm.em.create(TransportationImage, { + id: ulid(), + transportation: transportationClass, + src: imageFile.name, + createdAt: new Date(), + updatedAt: new Date(), + }); + + transportationClass.images.add(transportationImage); + } + + await orm.em.flush(); + + return res.status(201).json({ + data: this.mapper.mapClassEntityToResponse(transportationClass), + errors: null, + } satisfies SingleResponse); + } + + async listClasses(req: Request, res: Response) { + const parseQueryResult = paginationQuerySchema.safeParse(req.query); + if (!parseQueryResult.success) { + return this.handleZodError(parseQueryResult.error, res, "query"); + } + const query = parseQueryResult.data; + + const parseParamsResult = transportationParamsSchema.safeParse(req.params); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const transportation = await orm.em.findOne( + Transportation, + { id: params.id }, + { + populate: ["*"], + }, + ); + if (!transportation) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Transportation not found.", + }, + ], + } satisfies ErrorResponse); + } + + const count = await orm.em.count(TransportationClass, { + transportation, + }); + + const classes = await orm.em.find( + TransportationClass, + { transportation }, + { + limit: query.per_page, + offset: (query.page - 1) * query.per_page, + orderBy: { createdAt: "DESC" }, + populate: ["*"], + }, + ); + + return res.status(200).json({ + data: classes.map(this.mapper.mapClassEntityToResponse.bind(this.mapper)), + errors: null, + meta: { + page: query.page, + per_page: query.per_page, + total_pages: Math.ceil(count / query.per_page), + total_items: count, + }, + } satisfies ListResponse); + } + + async viewClass(req: Request, res: Response) { + const parseParamsResult = transportationClassParamsSchema.safeParse( + req.params, + ); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const transportation = await orm.em.findOne( + Transportation, + { id: params.transportation_id }, + { populate: ["*"] }, + ); + if (!transportation) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Transportation not found.", + }, + ], + } satisfies ErrorResponse); + } + + const transportationClass = await orm.em.findOne( + TransportationClass, + { + id: params.id, + transportation, + }, + { populate: ["*"] }, + ); + if (!transportationClass) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Transportation class not found.", + }, + ], + } satisfies ErrorResponse); + } + + return res.status(200).json({ + data: this.mapper.mapClassEntityToResponse(transportationClass), + errors: null, + } satisfies SingleResponse); + } + + async updateClass(req: Request, res: Response) { + const parseParamsResult = transportationClassParamsSchema.safeParse( + req.params, + ); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const parseBodyResult = transportationClassRequestSchema.safeParse( + req.body, + ); + if (!parseBodyResult.success) { + return this.handleZodError(parseBodyResult.error, res, "body"); + } + const body = parseBodyResult.data; + + const transportation = await orm.em.findOne( + Transportation, + { id: params.transportation_id }, + { populate: ["*"] }, + ); + if (!transportation) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "transportation_id", + location: "params", + message: "Transportation not found.", + }, + ], + } satisfies ErrorResponse); + } + + const transportationClass = await orm.em.findOne( + TransportationClass, + { + id: params.id, + transportation, + }, + { populate: ["*"] }, + ); + if (!transportationClass) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Transportation class not found.", + }, + ], + } satisfies ErrorResponse); + } + + transportationClass.class = body.class; + transportationClass.totalSeats = body.total_seats; + transportationClass.updatedAt = new Date(); + + for (const transportationImage of transportationClass.images) { + await this.fileStorage.removeFile(transportationImage.src); + + orm.em.remove(transportationImage); + } + for (const [index, image] of body.images.entries()) { + const imageFile = await this.fileStorage.storeFile( + Buffer.from(image, "base64"), + ); + + const transportationImage = orm.em.create(TransportationImage, { + id: ulid(), + transportation: transportationClass, + src: imageFile.name, + createdAt: new Date(), + updatedAt: new Date(), + }); + + transportationClass.images.add(transportationImage); + } + + await orm.em.flush(); + + return res.status(200).json({ + data: this.mapper.mapClassEntityToResponse(transportationClass), + errors: null, + } satisfies SingleResponse); + } + + async deleteClass(req: Request, res: Response) { + const parseParamsResult = transportationClassParamsSchema.safeParse( + req.params, + ); + if (!parseParamsResult.success) { + return this.handleZodError(parseParamsResult.error, res, "params"); + } + const params = parseParamsResult.data; + + const transportation = await orm.em.findOne( + Transportation, + { id: params.transportation_id }, + { populate: ["*"] }, + ); + if (!transportation) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "transportation_id", + location: "params", + message: "Transportation not found.", + }, + ], + } satisfies ErrorResponse); + } + + const transportationClass = await orm.em.findOne( + TransportationClass, + { + id: params.id, + transportation, + }, + { populate: ["*"] }, + ); + if (!transportationClass) { + return res.status(404).json({ + data: null, + errors: [ + { + path: "id", + location: "params", + message: "Transportation class not found.", + }, + ], + } satisfies ErrorResponse); + } + + for (const image of transportationClass.images.getItems()) { + await this.fileStorage.removeFile(image.src); + } + + await orm.em.removeAndFlush(transportationClass); + + return res.status(204).send(); + } + + public buildRouter(): Router { + const router = Router(); + router.post("/", ormMiddleware, this.create.bind(this)); + router.get("/", ormMiddleware, this.list.bind(this)); + router.get("/:id", ormMiddleware, this.view.bind(this)); + router.put("/:id", ormMiddleware, this.update.bind(this)); + router.delete("/:id", ormMiddleware, this.delete.bind(this)); + router.post("/:id/classes", ormMiddleware, this.createClass.bind(this)); + router.get("/:id/classes", ormMiddleware, this.listClasses.bind(this)); + router.get( + "/:transportation_id/classes/:id", + ormMiddleware, + this.viewClass.bind(this), + ); + router.put( + "/:transportation_id/classes/:id", + ormMiddleware, + this.updateClass.bind(this), + ); + router.delete( + "/:transportation_id/classes/:id", + ormMiddleware, + this.deleteClass.bind(this), + ); + + return router; + } +} diff --git a/src/modules/transportation/transportation.mapper.ts b/src/modules/transportation/transportation.mapper.ts new file mode 100644 index 0000000..e4dc626 --- /dev/null +++ b/src/modules/transportation/transportation.mapper.ts @@ -0,0 +1,36 @@ +import type { TransportationClass } from "@/database/entities/transportation-class.entity"; +import type { Transportation } from "@/database/entities/transportation.entity"; +import type { + TransportationClassResponse, + TransportationResponse, +} from "@/modules/transportation/transportation.types"; + +export class TransportationMapper { + public mapEntityToResponse( + transportation: Transportation, + ): TransportationResponse { + return { + id: transportation.id, + name: transportation.name, + type: transportation.type, + created_at: transportation.createdAt, + updated_at: transportation.updatedAt, + }; + } + + public mapClassEntityToResponse( + transportationClass: TransportationClass, + ): TransportationClassResponse { + return { + id: transportationClass.id, + transportation: this.mapEntityToResponse( + transportationClass.transportation, + ), + class: transportationClass.class, + total_seats: transportationClass.totalSeats, + images: transportationClass.images.getItems().map((image) => image.src), + created_at: transportationClass.createdAt, + updated_at: transportationClass.updatedAt, + }; + } +} diff --git a/src/modules/transportation/transportation.schemas.ts b/src/modules/transportation/transportation.schemas.ts index 33ffb56..65591dc 100644 --- a/src/modules/transportation/transportation.schemas.ts +++ b/src/modules/transportation/transportation.schemas.ts @@ -28,20 +28,20 @@ export const transportationClassRequestSchema = z.object({ .nonempty("Must not empty."), }); -export const transportationQuerySchema = z.object({ - slug: z - .string("Must be string.") +export const transportationParamsSchema = z.object({ + id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), + .max(30, "Max 30 characters."), }); -export const transportationClassQuerySchema = z.object({ - transportation_slug: z - .string("Must be string.") +export const transportationClassParamsSchema = z.object({ + transportation_id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(200, "Max 200 characters."), - slug: z - .string("Must be string.") + .max(30, "Max 30 characters."), + id: z + .ulid("Must be ulid string.") .nonempty("Must not empty.") - .max(400, "Max 400 characters."), + .max(30, "Max 30 characters."), }); diff --git a/src/modules/transportation/transportation.types.ts b/src/modules/transportation/transportation.types.ts index a805f43..35a9dc9 100644 --- a/src/modules/transportation/transportation.types.ts +++ b/src/modules/transportation/transportation.types.ts @@ -1,7 +1,7 @@ import type { - transportationClassQuerySchema, + transportationClassParamsSchema, transportationClassRequestSchema, - transportationQuerySchema, + transportationParamsSchema, transportationRequestSchema, } from "@/modules/transportation/transportation.schemas"; import z from "zod"; @@ -12,23 +12,22 @@ export type TransportationClassRequest = z.infer< typeof transportationClassRequestSchema >; -export type TransportationQuery = z.infer; +export type TransportationParams = z.infer; -export type TransportationClassQuery = z.infer< - typeof transportationClassQuerySchema +export type TransportationClassParams = z.infer< + typeof transportationClassParamsSchema >; export type TransportationResponse = { id: string; - slug: string; name: string; + type: string; created_at: Date; updated_at: Date; }; export type TransportationClassResponse = { id: string; - slug: string; transportation: TransportationResponse; class: string; total_seats: number; diff --git a/src/server.ts b/src/server.ts index 1d02bb2..5d5d472 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,6 +16,7 @@ process.on("unhandledRejection", (reason) => { }); const application = new Application(); +application.initializeServices(); application.initializeMiddlewares(); application.initializeRouters(); application.initializeErrorHandlers();