add auth and payment api

This commit is contained in:
ItsMalma
2025-11-19 23:53:56 +07:00
parent 8f91994f29
commit 7e7a865368
64 changed files with 9067 additions and 2662 deletions

View File

@@ -1,6 +1,14 @@
import type { AbstractEmailService } from "@/common/services/email-service/abstract.email-service";
import { LibraryEmailService } from "@/common/services/email-service/library.email-service";
import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage";
import { LocalFileStorage } from "@/common/services/file-storage/local.file-storage";
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import { LibraryJwtService } from "@/common/services/jwt-service/library.jwt-service";
import type { AbstractPaymentService } from "@/common/services/payment-service/abstract.payment-service";
import { MidtransPaymentService } from "@/common/services/payment-service/midtrans.payment-service";
import { serverConfig } from "@/configs/server.config";
import { AdminController } from "@/modules/admin/admin.controller";
import { AdminMapper } from "@/modules/admin/admin.mapper";
import { AirlineController } from "@/modules/airline/airline.controller";
import { AirlineMapper } from "@/modules/airline/airline.mapper";
import { AirportController } from "@/modules/airport/airport.controller";
@@ -15,8 +23,12 @@ import { HotelFacilityController } from "@/modules/hotel-facility/hotel-facility
import { HotelFacilityMapper } from "@/modules/hotel-facility/hotel-facility.mapper";
import { HotelController } from "@/modules/hotel/hotel.controller";
import { HotelMapper } from "@/modules/hotel/hotel.mapper";
import { OrderController } from "@/modules/order/order.controller";
import { OrderMapper } from "@/modules/order/order.mapper";
import { PackageController } from "@/modules/package/package.controller";
import { PackageMapper } from "@/modules/package/package.mapper";
import { PartnerController } from "@/modules/partner/partner.controller";
import { PartnerMapper } from "@/modules/partner/partner.mapper";
import { TransportationController } from "@/modules/transportation/transportation.controller";
import { TransportationMapper } from "@/modules/transportation/transportation.mapper";
import compression from "compression";
@@ -28,14 +40,20 @@ export class Application {
private readonly _app: express.Application;
// Services
private _emailService!: AbstractEmailService;
private _fileStorage!: AbstractFileStorage;
private _jwtService!: AbstractJwtService;
private _paymentService!: AbstractPaymentService;
public constructor() {
this._app = express();
}
public initializeServices() {
this._emailService = new LibraryEmailService();
this._fileStorage = new LocalFileStorage();
this._jwtService = new LibraryJwtService();
this._paymentService = new MidtransPaymentService();
}
public initializeMiddlewares() {
@@ -60,29 +78,66 @@ export class Application {
hotelMapper,
transportationMapper,
);
const adminMapper = new AdminMapper();
const partnerMapper = new PartnerMapper();
const orderMapper = new OrderMapper(packageMapper, partnerMapper);
const countryRouter = new CountryController(countryMapper).buildRouter();
const cityRouter = new CityController(cityMapper).buildRouter();
const countryRouter = new CountryController(
countryMapper,
this._jwtService,
).buildRouter();
const cityRouter = new CityController(
cityMapper,
this._jwtService,
).buildRouter();
const airlineRouter = new AirlineController(
airlineMapper,
this._fileStorage,
this._jwtService,
).buildRouter();
const airportRouter = new AirportController(
airportMapper,
this._jwtService,
).buildRouter();
const flightRouter = new FlightController(
flightMapper,
this._jwtService,
).buildRouter();
const airportRouter = new AirportController(airportMapper).buildRouter();
const flightRouter = new FlightController(flightMapper).buildRouter();
const hotelFacilityRouter = new HotelFacilityController(
hotelFacilityMapper,
this._jwtService,
).buildRouter();
const hotelRouter = new HotelController(
hotelMapper,
this._fileStorage,
this._jwtService,
).buildRouter();
const transportationRouter = new TransportationController(
transportationMapper,
this._fileStorage,
this._jwtService,
).buildRouter();
const packageRouter = new PackageController(
packageMapper,
this._fileStorage,
this._jwtService,
).buildRouter();
const adminRouter = new AdminController(
adminMapper,
this._fileStorage,
this._emailService,
this._jwtService,
).buildRouter();
const partnerRouter = new PartnerController(
partnerMapper,
this._fileStorage,
this._emailService,
this._jwtService,
).buildRouter();
const orderRouter = new OrderController(
orderMapper,
this._paymentService,
this._jwtService,
).buildRouter();
this._app.use("/countries", countryRouter);
@@ -94,6 +149,9 @@ export class Application {
this._app.use("/hotels", hotelRouter);
this._app.use("/transportations", transportationRouter);
this._app.use("/packages", packageRouter);
this._app.use("/admins", adminRouter);
this._app.use("/partners", partnerRouter);
this._app.use("/orders", orderRouter);
}
public initializeErrorHandlers() {}

View File

@@ -0,0 +1,5 @@
export class InvalidJwtError extends Error {
public constructor(message: string) {
super(message);
}
}

View File

@@ -0,0 +1,5 @@
export class PaymentError extends Error {
public constructor(message: string) {
super(message);
}
}

View File

@@ -2,7 +2,7 @@ import { orm } from "@/database/orm";
import { RequestContext } from "@mikro-orm/core";
import type { NextFunction, Request, Response } from "express";
export function ormMiddleware(
export function createOrmContextMiddleware(
_req: Request,
_res: Response,
next: NextFunction,

View File

@@ -0,0 +1,51 @@
import { type AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type { ErrorResponse } from "@/common/types";
import type { Admin } from "@/database/entities/admin.entity";
import type { AdminPermission } from "@/database/enums/admin-permission.enum";
import type { NextFunction, Request, Response } from "express";
export type AdminRequestPlugin = {
admin: Admin;
};
export function isAdminMiddleware(
jwtService: AbstractJwtService,
permissions: AdminPermission[] = [],
) {
return async (_req: Request, res: Response, next: NextFunction) => {
const req = _req as Request & AdminRequestPlugin;
const authorization = req.headers["authorization"];
if (!authorization || !authorization.startsWith("Bearer ")) {
return res.status(401).json({
data: null,
errors: [
{
path: "Authorization",
location: "header",
message: "Invalid token.",
},
],
} satisfies ErrorResponse);
}
const token = authorization.slice(7);
req.admin = await jwtService.verifyAdminToken(token);
for (const permission of permissions) {
if (!req.admin.permissions.includes(permission)) {
return res.status(403).json({
data: null,
errors: [
{
message: `You don't have '${permission}' permission.`,
},
],
} satisfies ErrorResponse);
}
}
next();
};
}

View File

@@ -0,0 +1,34 @@
import { type AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type { ErrorResponse } from "@/common/types";
import type { Partner } from "@/database/entities/partner.entity";
import type { NextFunction, Request, Response } from "express";
export type PartnerRequestPlugin = {
partner: Partner;
};
export function isPartnerMiddleware(jwtService: AbstractJwtService) {
return async (_req: Request, res: Response, next: NextFunction) => {
const req = _req as Request & PartnerRequestPlugin;
const authorization = req.headers["authorization"];
if (!authorization || !authorization.startsWith("Bearer ")) {
return res.status(401).json({
data: null,
errors: [
{
path: "Authorization",
location: "header",
message: "Invalid token.",
},
],
} satisfies ErrorResponse);
}
const token = authorization.slice(7);
req.partner = await jwtService.verifyPartnerToken(token);
next();
};
}

View File

@@ -45,3 +45,19 @@ export const dateSchema = z
}
return parsedDate;
});
export const emailSchema = z
.email("Must be email string.")
.nonempty("Must not empty.")
.max(254, "Max 254 characters.");
export const phoneNumberSchema = z
.string("Must be string.")
.nonempty("Must not empty.")
.regex(/^\d+$/, "Must be numeric string.")
.max(20, "Max 20 characters.");
export const passwordSchema = z
.string("Must be string.")
.nonempty("Must not empty.")
.max(72, "Max 72 characters.");

View File

@@ -0,0 +1,6 @@
export abstract class AbstractEmailService {
public abstract sendVerificationEmail(
to: string,
code: string,
): Promise<void>;
}

View File

@@ -0,0 +1,30 @@
import { AbstractEmailService } from "@/common/services/email-service/abstract.email-service";
import { mailConfig } from "@/configs/mail.config";
import * as nodemailer from "nodemailer";
export class LibraryEmailService extends AbstractEmailService {
private readonly _transporter: nodemailer.Transporter;
public constructor() {
super();
this._transporter = nodemailer.createTransport({
host: mailConfig.host,
port: mailConfig.port,
secure: true,
auth: {
user: mailConfig.username,
pass: mailConfig.password,
},
});
}
public async sendVerificationEmail(to: string, code: string): Promise<void> {
await this._transporter.sendMail({
from: mailConfig.username,
to,
subject: "Email Verification",
text: `Your verification code is: ${code}`,
});
}
}

View File

@@ -0,0 +1,29 @@
import type { Admin } from "@/database/entities/admin.entity";
import type { Partner } from "@/database/entities/partner.entity";
export enum JwtType {
access = "access",
refresh = "refresh",
}
export abstract class AbstractJwtService {
public abstract createAdminToken(
admin: Admin,
type: JwtType,
): Promise<{
token: string;
expiresAt: Date;
}>;
public abstract verifyAdminToken(token: string): Promise<Admin>;
public abstract createPartnerToken(
partner: Partner,
type: JwtType,
): Promise<{
token: string;
expiresAt: Date;
}>;
public abstract verifyPartnerToken(token: string): Promise<Partner>;
}

View File

@@ -0,0 +1,149 @@
import { InvalidJwtError } from "@/common/errors/invalid-jwt.error";
import {
AbstractJwtService,
JwtType,
} from "@/common/services/jwt-service/abstract.jwt-service";
import { jwtConfig } from "@/configs/jwt.config";
import { Admin } from "@/database/entities/admin.entity";
import { Partner } from "@/database/entities/partner.entity";
import { orm } from "@/database/orm";
import * as dateFns from "date-fns";
import * as jwt from "jsonwebtoken";
import { ulid } from "ulid";
import z from "zod";
const adminPayloadSchema = z.object({
sub: z.ulid(),
role: z.literal("admin"),
});
const partnerPayloadSchema = z.object({
sub: z.ulid(),
role: z.literal("partner"),
});
export class LibraryJwtService extends AbstractJwtService {
public async createAdminToken(
admin: Admin,
type: JwtType,
): Promise<{
token: string;
expiresAt: Date;
}> {
const now = new Date();
let expiresAt: Date;
switch (type) {
case JwtType.access:
expiresAt = dateFns.addDays(now, 1);
break;
case JwtType.refresh:
expiresAt = dateFns.addDays(now, 30);
break;
}
const token = jwt.sign(
{
role: "admin",
},
jwtConfig.secret,
{
algorithm: jwtConfig.algorithm,
expiresIn: Math.floor((expiresAt.getTime() - now.getTime()) / 1000),
notBefore: "0 seconds",
subject: admin.id,
issuer: jwtConfig.issuer,
jwtid: ulid(),
},
);
return {
token,
expiresAt,
};
}
public async verifyAdminToken(token: string): Promise<Admin> {
const payload = jwt.verify(token, jwtConfig.secret, {
algorithms: [jwtConfig.algorithm],
issuer: jwtConfig.issuer,
});
const parsePayloadResult = adminPayloadSchema.safeParse(payload);
if (!parsePayloadResult.success) {
throw new InvalidJwtError("Invalid payload.");
}
const adminPayload = parsePayloadResult.data;
const admin = await orm.em.findOne(Admin, {
id: adminPayload.sub,
});
if (!admin) {
throw new InvalidJwtError("Admin not found.");
}
return admin;
}
public async createPartnerToken(
partner: Partner,
type: JwtType,
): Promise<{
token: string;
expiresAt: Date;
}> {
const now = new Date();
let expiresAt: Date;
switch (type) {
case JwtType.access:
expiresAt = dateFns.addDays(now, 1);
break;
case JwtType.refresh:
expiresAt = dateFns.addDays(now, 30);
break;
}
const token = jwt.sign(
{
role: "partner",
},
jwtConfig.secret,
{
algorithm: jwtConfig.algorithm,
expiresIn: Math.floor((expiresAt.getTime() - now.getTime()) / 1000),
notBefore: "0 seconds",
subject: partner.id,
issuer: jwtConfig.issuer,
jwtid: ulid(),
},
);
return {
token,
expiresAt,
};
}
public async verifyPartnerToken(token: string): Promise<Partner> {
const payload = jwt.verify(token, jwtConfig.secret, {
algorithms: [jwtConfig.algorithm],
issuer: jwtConfig.issuer,
});
const parsePayloadResult = partnerPayloadSchema.safeParse(payload);
if (!parsePayloadResult.success) {
throw new InvalidJwtError("Invalid payload.");
}
const partnerPayload = parsePayloadResult.data;
const partner = await orm.em.findOne(Partner, {
id: partnerPayload.sub,
});
if (!partner) {
throw new InvalidJwtError("Partner not found.");
}
return partner;
}
}

View File

@@ -0,0 +1,5 @@
import type { Order } from "@/database/entities/order.entity";
export abstract class AbstractPaymentService {
public abstract createPaymentUrl(order: Order): Promise<string>;
}

View File

@@ -0,0 +1,141 @@
import { PaymentError } from "@/common/errors/payment.error";
import { AbstractPaymentService } from "@/common/services/payment-service/abstract.payment-service";
import { midtransConfig } from "@/configs/midtrans.config";
import type { OrderDetail } from "@/database/entities/order-detail.entity";
import type { Order } from "@/database/entities/order.entity";
import { RoomType } from "@/database/enums/room-type.enum";
type CreateTransactionResponseSuccess = {
token: string;
redirect_url: string;
};
type CreateTransactionResponseFailed = {
error_messages: string[];
};
export class MidtransPaymentService extends AbstractPaymentService {
private readonly _basicAuth: string;
public constructor() {
super();
this._basicAuth = `Basic ${Buffer.from(`${midtransConfig.serverKey}:`).toBase64()}`;
}
private calculateOrderDetailsPrice(orderDetails: OrderDetail[]): number {
let price = 0;
for (const orderDetail of orderDetails) {
switch (orderDetail.roomType) {
case RoomType.double:
price += orderDetail.order.package.doublePrice;
break;
case RoomType.triple:
price += orderDetail.order.package.triplePrice;
break;
case RoomType.quad:
price += orderDetail.order.package.quadPrice;
break;
case RoomType.infant:
price += orderDetail.order.package.infantPrice ?? 0;
break;
}
}
return price;
}
public async createPaymentUrl(order: Order): Promise<string> {
const doubleOrderDetails = order.details.filter(
(orderDetail) => orderDetail.roomType === RoomType.double,
);
const tripleOrderDetails = order.details.filter(
(orderDetail) => orderDetail.roomType === RoomType.double,
);
const quadOrderDetails = order.details.filter(
(orderDetail) => orderDetail.roomType === RoomType.double,
);
const infantOrderDetails = order.details.filter(
(orderDetail) => orderDetail.roomType === RoomType.double,
);
const response = await fetch(`${midtransConfig.baseUrl}/transactions`, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: this._basicAuth,
"Content-Type": "application/json",
},
body: JSON.stringify({
transaction_details: {
order_id: order.id,
gross_amount: this.calculateOrderDetailsPrice(
order.details.getItems(),
),
},
item_details: [
doubleOrderDetails.length > 0
? {
id: doubleOrderDetails[0].id,
price: order.package.doublePrice,
quantity: doubleOrderDetails.length,
name: `${order.package.package.name} / Double`,
brand: "GoUmrah",
category: "Paket",
merchant_name: "GoUmrah",
}
: undefined,
tripleOrderDetails.length > 0
? {
id: tripleOrderDetails[0].id,
price: order.package.triplePrice,
quantity: tripleOrderDetails.length,
name: `${order.package.package.name} / Triple`,
brand: "GoUmrah",
category: "Paket",
merchant_name: "GoUmrah",
}
: undefined,
quadOrderDetails.length > 0
? {
id: quadOrderDetails[0].id,
price: order.package.quadPrice,
quantity: quadOrderDetails.length,
name: `${order.package.package.name} / Quad`,
brand: "GoUmrah",
category: "Paket",
merchant_name: "GoUmrah",
}
: undefined,
infantOrderDetails.length > 0
? {
id: infantOrderDetails[0].id,
price: order.package.infantPrice,
quantity: infantOrderDetails.length,
name: `${order.package.package.name} / Infant`,
brand: "GoUmrah",
category: "Paket",
merchant_name: "GoUmrah",
}
: undefined,
],
customer_details: {
first_name: order.name,
phone: order.whatsapp,
},
credit_card: {
secure: true,
},
}),
});
if (response.status === 201) {
const responseBody =
(await response.json()) as CreateTransactionResponseSuccess;
return responseBody.redirect_url;
} else {
const responseBody =
(await response.json()) as CreateTransactionResponseFailed;
throw new PaymentError(responseBody.error_messages[0]);
}
}
}

View File

@@ -3,7 +3,7 @@ import type z from "zod";
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
export type SingleResponse<T> = {
export type SingleResponse<T = unknown> = {
data: T;
errors: null;
};

11
src/common/utils.ts Normal file
View File

@@ -0,0 +1,11 @@
export function generateRandomCode(length: number): string {
const numbers = "0123456789";
let result = "";
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * numbers.length);
result += numbers[randomIndex];
}
return result;
}

View File

@@ -16,5 +16,41 @@ export const _env = z
DATABASE_USERNAME: z.string("Must be string.").nonempty("Must not empty."),
DATABASE_PASSWORD: z.string("Must be string.").nonempty("Must not empty."),
DATABASE_NAME: z.string("Must be string.").nonempty("Must not empty."),
JWT_SECRET: z.string("Must be string.").nonempty("Must not empty."),
JWT_ALGORITHM: z.enum(
[
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
"PS256",
"PS384",
"PS512",
],
"Invalid value.",
),
JWT_ISSUER: z.string("Must be string.").nonempty("Must not empty."),
MIDTRANS_BASE_URL: z.url("Must be valid URL.").nonempty("Must not empty."),
MIDTRANS_MERCHANT_ID: z
.string("Must be string.")
.nonempty("Must not empty."),
MIDTRANS_SERVER_KEY: z
.string("Must be string.")
.nonempty("Must not empty."),
MAIL_HOST: z.string("Must be string.").nonempty("Must not empty."),
MAIL_PORT: z.coerce
.number("Must be number.")
.int("Must be integer.")
.min(0, "Min 0."),
MAIL_USERNAME: z.string("Must be string.").nonempty("Must not empty."),
MAIL_PASSWORD: z.string("Must be string.").nonempty("Must not empty."),
})
.parse(Bun.env);

View File

@@ -0,0 +1,7 @@
import { _env } from "@/configs/_env";
export const jwtConfig = {
secret: _env.JWT_SECRET,
algorithm: _env.JWT_ALGORITHM,
issuer: _env.JWT_ISSUER,
} as const;

View File

@@ -0,0 +1,8 @@
import { _env } from "@/configs/_env";
export const mailConfig = {
host: _env.MAIL_HOST,
port: _env.MAIL_PORT,
username: _env.MAIL_USERNAME,
password: _env.MAIL_PASSWORD,
} as const;

View File

@@ -0,0 +1,7 @@
import { _env } from "@/configs/_env";
export const midtransConfig = {
baseUrl: _env.MIDTRANS_BASE_URL,
merchantId: _env.MIDTRANS_MERCHANT_ID,
serverKey: _env.MIDTRANS_SERVER_KEY,
} as const;

View File

@@ -0,0 +1,51 @@
import { Verification } from "@/database/entities/verification.entity";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import {
Entity,
Enum,
ManyToOne,
PrimaryKey,
Property,
type Rel,
} from "@mikro-orm/core";
@Entity()
export class Admin {
@PrimaryKey({ type: "varchar", length: 30 })
id!: string;
@Property({ type: "varchar", length: 100 })
name!: string;
@Property({ type: "varchar", length: 254, unique: true })
email!: string;
@Property({ type: "text" })
password!: string;
@Property({ type: "varchar", length: 100, nullable: true })
avatar!: string | null;
@Enum({
items: () => AdminPermission,
array: true,
nativeEnumName: "admin_permission",
})
permissions!: AdminPermission[];
@ManyToOne(() => Verification, { nullable: true })
verification!: Rel<Verification> | null;
@Property({
type: "timestamp",
onCreate: () => new Date(),
})
createdAt!: Date;
@Property({
type: "timestamp",
onCreate: () => new Date(),
onUpdate: () => new Date(),
})
updatedAt!: Date;
}

View File

@@ -14,13 +14,12 @@ export class Airline {
code!: string;
@Property({ type: "varchar", length: 100 })
@Unique()
logo!: string;
@Property({ type: "int", unsigned: true })
skytraxRating!: number;
@Enum(() => SkytraxType)
@Enum({ items: () => SkytraxType, nativeEnumName: "skytrax_type" })
skytraxType!: SkytraxType;
@Property({

View File

@@ -0,0 +1,35 @@
import { Order } from "@/database/entities/order.entity";
import { RoomType } from "@/database/enums/room-type.enum";
import {
Entity,
Enum,
ManyToOne,
PrimaryKey,
Property,
type Rel,
} from "@mikro-orm/core";
@Entity()
export class OrderDetail {
@PrimaryKey({ type: "varchar", length: 30 })
id!: string;
@ManyToOne(() => Order)
order!: Rel<Order>;
@Enum({ items: () => RoomType, nativeEnumName: "room_type" })
roomType!: RoomType;
@Property({
type: "timestamp",
onCreate: () => new Date(),
})
createdAt!: Date;
@Property({
type: "timestamp",
onCreate: () => new Date(),
onUpdate: () => new Date(),
})
updatedAt!: Date;
}

View File

@@ -0,0 +1,61 @@
import { OrderDetail } from "@/database/entities/order-detail.entity";
import { PackageDetail } from "@/database/entities/package-detail.entity";
import { Partner } from "@/database/entities/partner.entity";
import { Verification } from "@/database/entities/verification.entity";
import {
Collection,
Entity,
ManyToOne,
OneToMany,
PrimaryKey,
Property,
type Rel,
} from "@mikro-orm/core";
@Entity()
export class Order {
@PrimaryKey({ type: "varchar", length: 30 })
id!: string;
@ManyToOne(() => PackageDetail)
package!: Rel<PackageDetail>;
@Property({ type: "varchar", length: 100 })
name!: string;
@Property({ type: "varchar", length: 20 })
whatsapp!: string;
@ManyToOne(() => Verification, { nullable: true })
verification!: Rel<Verification> | null;
@ManyToOne(() => Partner, { nullable: true })
partner!: Rel<Partner | null>;
@Property({ type: "timestamp", nullable: true })
expiredAt!: Date | null;
@Property({ type: "timestamp", nullable: true })
purchasedAt!: Date | null;
@Property({ type: "timestamp", nullable: true })
finishedAt!: Date | null;
@Property({
type: "timestamp",
onCreate: () => new Date(),
})
createdAt!: Date;
@Property({
type: "timestamp",
onCreate: () => new Date(),
onUpdate: () => new Date(),
})
updatedAt!: Date;
// Collections
@OneToMany(() => OrderDetail, (orderDetail) => orderDetail.order)
details = new Collection<OrderDetail>(this);
}

View File

@@ -21,7 +21,10 @@ export abstract class PackageItineraryWidget {
@ManyToOne(() => PackageItineraryDay)
packageItineraryDay!: Rel<PackageItineraryDay>;
@Enum(() => PackageItineraryWidgetType)
@Enum({
type: () => PackageItineraryWidgetType,
nativeEnumName: "package_itinerary_widget_type",
})
type!: PackageItineraryWidgetType;
@Property({

View File

@@ -9,7 +9,6 @@ import {
OneToMany,
PrimaryKey,
Property,
Unique,
} from "@mikro-orm/core";
@Entity()
@@ -20,14 +19,13 @@ export class Package {
@Property({ type: "varchar", length: 100 })
name!: string;
@Enum(() => PackageType)
@Enum({ items: () => PackageType, nativeEnumName: "package_type" })
type!: PackageType;
@Enum(() => PackageClass)
@Enum({ items: () => PackageClass, nativeEnumName: "package_class" })
class!: PackageClass;
@Property({ type: "varchar", length: 100 })
@Unique()
thumbnail!: string;
@Property({ type: "boolean" })

View File

@@ -0,0 +1,53 @@
import { Order } from "@/database/entities/order.entity";
import { Verification } from "@/database/entities/verification.entity";
import {
Collection,
Entity,
ManyToOne,
OneToMany,
PrimaryKey,
Property,
type Rel,
} from "@mikro-orm/core";
@Entity()
export class Partner {
@PrimaryKey({ type: "varchar", length: 30 })
id!: string;
@Property({ type: "varchar", length: 100 })
name!: string;
@Property({ type: "varchar", length: 254, unique: true })
email!: string;
@Property({ type: "varchar", length: 20, unique: true })
whatsapp!: string;
@Property({ type: "text" })
password!: string;
@Property({ type: "varchar", length: 100, nullable: true })
avatar!: string | null;
@ManyToOne(() => Verification, { nullable: true })
verification!: Rel<Verification> | null;
@Property({
type: "timestamp",
onCreate: () => new Date(),
})
createdAt!: Date;
@Property({
type: "timestamp",
onCreate: () => new Date(),
onUpdate: () => new Date(),
})
updatedAt!: Date;
// Collections
@OneToMany(() => Order, (order) => order.partner)
orders = new Collection<Order>(this);
}

View File

@@ -0,0 +1,35 @@
import { VerificationType } from "@/database/enums/verification-type.enum";
import { Entity, Enum, PrimaryKey, Property } from "@mikro-orm/core";
@Entity()
export class Verification {
@PrimaryKey({ type: "varchar", length: 30 })
id!: string;
@Property({ type: "char", length: 6 })
code!: string;
@Enum({
items: () => VerificationType,
nativeEnumName: "verification_type",
})
type!: VerificationType;
@Property({
type: "timestamp",
})
expiredAt!: Date;
@Property({
type: "timestamp",
onCreate: () => new Date(),
})
createdAt!: Date;
@Property({
type: "timestamp",
onCreate: () => new Date(),
onUpdate: () => new Date(),
})
updatedAt!: Date;
}

View File

@@ -0,0 +1,58 @@
export enum AdminPermission {
// Country permissions
createCountry = "country:create",
updateCountry = "country:update",
deleteCountry = "country:delete",
// City permissions
createCity = "city:create",
updateCity = "city:update",
deleteCity = "city:delete",
// Airline permissions
createAirline = "airline:create",
updateAirline = "airline:update",
deleteAirline = "airline:delete",
// Airport permissions
createAirport = "airport:create",
updateAirport = "airport:update",
deleteAirport = "airport:delete",
// Flight permissions
createFlight = "flight:create",
updateFlight = "flight:update",
deleteFlight = "flight:delete",
// Flight class permissions
createFlightClass = "flight-class:create",
updateFlightClass = "flight-class:update",
deleteFlightClass = "flight-class:delete",
// Hotel facility permissions
createHotelFacility = "hotel-facility:create",
updateHotelFacility = "hotel-facility:update",
deleteHotelFacility = "hotel-facility:delete",
// Hotel permissions
createHotel = "hotel:create",
updateHotel = "hotel:update",
deleteHotel = "hotel:delete",
// Transportation permissions
createTransportation = "transportation:create",
updateTransportation = "transportation:update",
deleteTransportation = "transportation:delete",
// Transportation class permissions
createTransportationClass = "transportation-class:create",
updateTransportationClass = "transportation-class:update",
deleteTransportationClass = "transportation-class:delete",
// Package permissions
createPackage = "package:create",
updatePackage = "package:update",
deletePackage = "package:delete",
// Package detail permissions
createPackageDetail = "package-detail:create",
updatePackageDetail = "package-detail:update",
deletePackageDetail = "package-detail:delete",
// Admin permissions
createAdmin = "admin:create",
updateAdmin = "admin:update",
deleteAdmin = "admin:delete",
// Partner permissions
createPartner = "partner:create",
updatePartner = "partner:update",
deletePartner = "partner:delete",
}

View File

@@ -0,0 +1,6 @@
export enum RoomType {
double = "double",
triple = "triple",
quad = "quad",
infant = "infant",
}

View File

@@ -0,0 +1,14 @@
export enum VerificationType {
// Admin verifications
createAdmin = "admin:create",
changeEmailAdmin = "admin:changeEmail",
changePasswordAdmin = "admin:changePassword",
updateAdmin = "admin:update",
// Partner verifications
createPartner = "partner:create",
changeEmailPartner = "partner:changeEmail",
changePasswordPartner = "partner:changePassword",
updatePartner = "partner:update",
// Order verifications
createOrder = "order:create",
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,35 @@
import { Migration } from '@mikro-orm/migrations';
import { Migration } from "@mikro-orm/migrations";
export class Migration20251112105413 extends Migration {
override async up(): Promise<void> {
this.addSql(
`alter table "transportation_image" drop constraint "transportation_image_transportation_id_foreign";`,
);
override async up(): Promise<void> {
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;`,
);
}
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<void> {
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;`);
}
override async down(): Promise<void> {
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;`,
);
}
}

View File

@@ -0,0 +1,184 @@
import { Migration } from "@mikro-orm/migrations";
export class Migration20251119103455 extends Migration {
override async up(): Promise<void> {
this.addSql(
`create type "skytrax_type" as enum ('full_service', 'low_cost');`,
);
this.addSql(`create type "package_type" as enum ('reguler', 'plus');`);
this.addSql(
`create type "package_class" as enum ('silver', 'gold', 'platinum');`,
);
this.addSql(
`create type "package_itinerary_widget_type" as enum ('hotel', 'information', 'transport');`,
);
this.addSql(
`create type "verification_type" as enum ('admin:create', 'admin:changeEmail', 'admin:changePassword', 'admin:update', 'partner:create', 'partner:changeEmail', 'partner:changePassword', 'partner:update', 'order:create');`,
);
this.addSql(
`create type "room_type" as enum ('double', 'triple', 'quad', 'infant');`,
);
this.addSql(
`create type "admin_permission" as enum ('country:create', 'country:update', 'country:delete', 'city:create', 'city:update', 'city:delete', 'airline:create', 'airline:update', 'airline:delete', 'airport:create', 'airport:update', 'airport:delete', 'flight:create', 'flight:update', 'flight:delete', 'flight-class:create', 'flight-class:update', 'flight-class:delete', 'hotel-facility:create', 'hotel-facility:update', 'hotel-facility:delete', 'hotel:create', 'hotel:update', 'hotel:delete', 'transportation:create', 'transportation:update', 'transportation:delete', 'transportation-class:create', 'transportation-class:update', 'transportation-class:delete', 'package:create', 'package:update', 'package:delete', 'package-detail:create', 'package-detail:update', 'package-detail:delete', 'admin:create', 'admin:update', 'admin:delete', 'partner:create', 'partner:update', 'partner:delete');`,
);
this.addSql(
`create table "verification" ("id" varchar(30) not null, "code" char(6) not null, "type" "verification_type" not null, "expired_at" timestamptz not null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "verification_pkey" primary key ("id"));`,
);
this.addSql(
`create table "partner" ("id" varchar(30) not null, "name" varchar(100) not null, "email" varchar(254) not null, "whatsapp" varchar(20) not null, "password" text not null, "avatar" varchar(100) null, "verification_id" varchar(30) null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "partner_pkey" primary key ("id"));`,
);
this.addSql(
`alter table "partner" add constraint "partner_email_unique" unique ("email");`,
);
this.addSql(
`alter table "partner" add constraint "partner_whatsapp_unique" unique ("whatsapp");`,
);
this.addSql(
`create table "order" ("id" varchar(30) not null, "package_id" varchar(30) not null, "name" varchar(100) not null, "whatsapp" varchar(20) not null, "verification_id" varchar(30) null, "partner_id" varchar(30) null, "expired_at" timestamptz null, "purchased_at" timestamptz null, "finished_at" timestamptz null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "order_pkey" primary key ("id"));`,
);
this.addSql(
`create table "order_detail" ("id" varchar(30) not null, "order_id" varchar(30) not null, "room_type" "room_type" not null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "order_detail_pkey" primary key ("id"));`,
);
this.addSql(
`create table "admin" ("id" varchar(30) not null, "name" varchar(100) not null, "email" varchar(254) not null, "password" text not null, "avatar" varchar(100) null, "permissions" "admin_permission"[] not null, "verification_id" varchar(30) null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "admin_pkey" primary key ("id"));`,
);
this.addSql(
`alter table "admin" add constraint "admin_email_unique" unique ("email");`,
);
this.addSql(
`alter table "partner" add constraint "partner_verification_id_foreign" foreign key ("verification_id") references "verification" ("id") on update cascade on delete set null;`,
);
this.addSql(
`alter table "order" add constraint "order_package_id_foreign" foreign key ("package_id") references "package_detail" ("id") on update cascade;`,
);
this.addSql(
`alter table "order" add constraint "order_verification_id_foreign" foreign key ("verification_id") references "verification" ("id") on update cascade on delete set null;`,
);
this.addSql(
`alter table "order" add constraint "order_partner_id_foreign" foreign key ("partner_id") references "partner" ("id") on update cascade on delete set null;`,
);
this.addSql(
`alter table "order_detail" add constraint "order_detail_order_id_foreign" foreign key ("order_id") references "order" ("id") on update cascade;`,
);
this.addSql(
`alter table "admin" add constraint "admin_verification_id_foreign" foreign key ("verification_id") references "verification" ("id") on update cascade on delete set null;`,
);
this.addSql(
`alter table "airline" drop constraint if exists "airline_skytrax_type_check";`,
);
this.addSql(
`alter table "package" drop constraint if exists "package_type_check";`,
);
this.addSql(
`alter table "package" drop constraint if exists "package_class_check";`,
);
this.addSql(
`alter table "package_itinerary_widget" drop constraint if exists "package_itinerary_widget_type_check";`,
);
this.addSql(`alter table "airline" drop constraint "airline_logo_unique";`);
this.addSql(
`alter table "airline" alter column "skytrax_type" type "skytrax_type" using ("skytrax_type"::"skytrax_type");`,
);
this.addSql(
`alter table "package" drop constraint "package_thumbnail_unique";`,
);
this.addSql(
`alter table "package" alter column "type" type "package_type" using ("type"::"package_type");`,
);
this.addSql(
`alter table "package" alter column "class" type "package_class" using ("class"::"package_class");`,
);
this.addSql(
`alter table "package_itinerary_widget" alter column "type" type "package_itinerary_widget_type" using ("type"::"package_itinerary_widget_type");`,
);
}
override async down(): Promise<void> {
this.addSql(
`alter table "partner" drop constraint "partner_verification_id_foreign";`,
);
this.addSql(
`alter table "order" drop constraint "order_verification_id_foreign";`,
);
this.addSql(
`alter table "admin" drop constraint "admin_verification_id_foreign";`,
);
this.addSql(
`alter table "order" drop constraint "order_partner_id_foreign";`,
);
this.addSql(
`alter table "order_detail" drop constraint "order_detail_order_id_foreign";`,
);
this.addSql(`drop table if exists "verification" cascade;`);
this.addSql(`drop table if exists "partner" cascade;`);
this.addSql(`drop table if exists "order" cascade;`);
this.addSql(`drop table if exists "order_detail" cascade;`);
this.addSql(`drop table if exists "admin" cascade;`);
this.addSql(
`alter table "airline" alter column "skytrax_type" type text using ("skytrax_type"::text);`,
);
this.addSql(
`alter table "airline" add constraint "airline_skytrax_type_check" check("skytrax_type" in ('full_service', 'low_cost'));`,
);
this.addSql(
`alter table "airline" add constraint "airline_logo_unique" unique ("logo");`,
);
this.addSql(
`alter table "package" alter column "type" type text using ("type"::text);`,
);
this.addSql(
`alter table "package" alter column "class" type text using ("class"::text);`,
);
this.addSql(
`alter table "package" add constraint "package_type_check" check("type" in ('reguler', 'plus'));`,
);
this.addSql(
`alter table "package" add constraint "package_class_check" check("class" in ('silver', 'gold', 'platinum'));`,
);
this.addSql(
`alter table "package" add constraint "package_thumbnail_unique" unique ("thumbnail");`,
);
this.addSql(
`alter table "package_itinerary_widget" alter column "type" type text using ("type"::text);`,
);
this.addSql(
`alter table "package_itinerary_widget" add constraint "package_itinerary_widget_type_check" check("type" in ('transport', 'hotel', 'information'));`,
);
this.addSql(`drop type "skytrax_type";`);
this.addSql(`drop type "package_type";`);
this.addSql(`drop type "package_class";`);
this.addSql(`drop type "package_itinerary_widget_type";`);
this.addSql(`drop type "verification_type";`);
this.addSql(`drop type "room_type";`);
this.addSql(`drop type "admin_permission";`);
}
}

View File

@@ -0,0 +1,627 @@
import { Controller } from "@/common/controller";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import {
isAdminMiddleware,
type AdminRequestPlugin,
} from "@/common/middlewares/is-admin.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractEmailService } from "@/common/services/email-service/abstract.email-service";
import type {
AbstractFileStorage,
FileResult,
} from "@/common/services/file-storage/abstract.file-storage";
import {
JwtType,
type AbstractJwtService,
} from "@/common/services/jwt-service/abstract.jwt-service";
import type {
ErrorResponse,
ListResponse,
SingleResponse,
} from "@/common/types";
import { generateRandomCode } from "@/common/utils";
import { Admin } from "@/database/entities/admin.entity";
import { Verification } from "@/database/entities/verification.entity";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import { VerificationType } from "@/database/enums/verification-type.enum";
import { orm } from "@/database/orm";
import type { AdminMapper } from "@/modules/admin/admin.mapper";
import {
adminChangeEmailRequestSchema,
adminChangePasswordRequestSchema,
adminParamsSchema,
adminRequestSchema,
adminUpdateRequestSchema,
adminVerifyRequestSchema,
} from "@/modules/admin/admin.schemas";
import type { AdminResponse } from "@/modules/admin/admin.types";
import * as dateFns from "date-fns";
import { Router, type Request, type Response } from "express";
import { ulid } from "ulid";
export class AdminController extends Controller {
public constructor(
private readonly mapper: AdminMapper,
private readonly fileStorage: AbstractFileStorage,
private readonly emailService: AbstractEmailService,
private readonly jwtService: AbstractJwtService,
) {
super();
}
async create(req: Request, res: Response) {
const parseBodyResult = adminRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
let avatarFile: null | FileResult = null;
if (body.avatar !== null) {
avatarFile = await this.fileStorage.storeFile(
Buffer.from(body.avatar, "base64"),
);
}
const verification = orm.em.create(Verification, {
id: ulid(),
code: generateRandomCode(6),
type: VerificationType.createAdmin,
expiredAt: dateFns.addHours(new Date(), 1),
createdAt: new Date(),
updatedAt: new Date(),
});
const admin = orm.em.create(Admin, {
id: ulid(),
name: body.name,
email: body.email,
password: await Bun.password.hash(body.password),
avatar: avatarFile?.name,
permissions: body.permissions,
verification,
createdAt: new Date(),
updatedAt: new Date(),
});
await orm.em.flush();
await this.emailService.sendVerificationEmail(
admin.email,
verification.code,
);
return res.status(201).json({
data: {
message:
"Admin created successfully. Please check your email for verification.",
},
errors: null,
} satisfies SingleResponse);
}
async login(req: Request, res: Response) {
const parseBodyResult = adminRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const admin = await orm.em.findOne(Admin, { email: body.email });
if (!admin) {
return res.status(401).json({
data: null,
errors: [
{
location: "body",
message: "Incorrect email or password.",
},
],
} satisfies ErrorResponse);
}
if (!(await Bun.password.verify(body.password, admin.password))) {
return res.status(401).json({
data: null,
errors: [
{
location: "body",
message: "Incorrect email or password.",
},
],
} satisfies ErrorResponse);
}
if (admin.verification !== null) {
return res.status(400).json({
data: null,
errors: [
{
message: "Admin is not verified.",
},
],
} satisfies ErrorResponse);
}
const access = await this.jwtService.createAdminToken(
admin,
JwtType.access,
);
const refresh = await this.jwtService.createAdminToken(
admin,
JwtType.refresh,
);
return res.status(200).json({
data: {
access_token: access.token,
access_token_expires_at: access.expiresAt,
refresh_token: refresh.token,
refresh_token_expires_at: refresh.expiresAt,
},
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(Admin);
const admins = await orm.em.find(
Admin,
{
verification: null,
},
{
limit: query.per_page,
offset: (query.page - 1) * query.per_page,
orderBy: { createdAt: "DESC" },
populate: ["*"],
},
);
return res.status(200).json({
data: admins.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<AdminResponse>);
}
async view(req: Request, res: Response) {
const parseParamsResult = adminParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const admin = await orm.em.findOne(
Admin,
{
id: params.id,
verification: null,
},
{
populate: ["*"],
},
);
if (!admin) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Admin not found.",
},
],
} satisfies ErrorResponse);
}
return res.status(200).json({
data: this.mapper.mapEntityToResponse(admin),
errors: null,
} satisfies SingleResponse<AdminResponse>);
}
async update(req: Request, res: Response) {
const parseParamsResult = adminParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const parseBodyResult = adminUpdateRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const admin = await orm.em.findOne(
Admin,
{
id: params.id,
verification: null,
},
{
populate: ["*"],
},
);
if (!admin) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Admin not found.",
},
],
} satisfies ErrorResponse);
}
if (body.avatar !== null) {
await this.fileStorage.storeFile(
Buffer.from(body.avatar, "base64"),
admin.avatar ?? undefined,
);
} else if (admin.avatar !== null) {
await this.fileStorage.removeFile(admin.avatar);
}
admin.name = body.name;
admin.permissions = body.permissions;
admin.verification = orm.em.create(Verification, {
id: ulid(),
code: generateRandomCode(6),
type: VerificationType.updateAdmin,
expiredAt: dateFns.addHours(new Date(), 1),
createdAt: new Date(),
updatedAt: new Date(),
});
admin.updatedAt = new Date();
await orm.em.flush();
await this.emailService.sendVerificationEmail(
admin.email,
admin.verification.code,
);
return res.status(200).json({
data: {
message:
"Admin updated successfully. Please check your email for verification.",
},
errors: null,
} satisfies SingleResponse);
}
async changeEmail(req: Request, res: Response) {
const parseParamsResult = adminParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const parseBodyResult = adminChangeEmailRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const admin = await orm.em.findOne(
Admin,
{
id: params.id,
verification: null,
},
{
populate: ["*"],
},
);
if (!admin) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Admin not found.",
},
],
} satisfies ErrorResponse);
}
admin.email = body.new_email;
admin.verification = orm.em.create(Verification, {
id: ulid(),
code: generateRandomCode(6),
type: VerificationType.changeEmailAdmin,
expiredAt: dateFns.addHours(new Date(), 1),
createdAt: new Date(),
updatedAt: new Date(),
});
admin.updatedAt = new Date();
await orm.em.flush();
await this.emailService.sendVerificationEmail(
admin.email,
admin.verification.code,
);
return res.status(200).json({
data: {
message:
"Admin's email changed successfully. Please check your email for verification.",
},
errors: null,
} satisfies SingleResponse);
}
async changePassword(req: Request, res: Response) {
const parseParamsResult = adminParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const parseBodyResult = adminChangePasswordRequestSchema.safeParse(
req.body,
);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const admin = await orm.em.findOne(
Admin,
{
id: params.id,
verification: null,
},
{
populate: ["*"],
},
);
if (!admin) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Admin not found.",
},
],
} satisfies ErrorResponse);
}
if (!(await Bun.password.verify(body.old_password, admin.password))) {
return res.status(400).json({
data: null,
errors: [
{
path: "old_password",
location: "body",
message: "Incorrect.",
},
],
} satisfies ErrorResponse);
}
admin.password = await Bun.password.hash(body.new_password);
admin.verification = orm.em.create(Verification, {
id: ulid(),
code: generateRandomCode(6),
type: VerificationType.changePasswordAdmin,
expiredAt: dateFns.addHours(new Date(), 1),
createdAt: new Date(),
updatedAt: new Date(),
});
admin.updatedAt = new Date();
await orm.em.flush();
await this.emailService.sendVerificationEmail(
admin.email,
admin.verification.code,
);
return res.status(200).json({
data: {
message:
"Admin's password changed successfully. Please check your email for verification.",
},
errors: null,
} satisfies SingleResponse);
}
async verify(req: Request, res: Response) {
const parseParamsResult = adminParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const parseBodyResult = adminVerifyRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const admin = await orm.em.findOne(
Admin,
{ id: params.id },
{
populate: ["*"],
},
);
if (!admin) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Admin not found.",
},
],
} satisfies ErrorResponse);
}
if (admin.verification === null) {
return res.status(400).json({
data: null,
errors: [
{
message: "Admin is already verified.",
},
],
} satisfies ErrorResponse);
}
if (admin.verification.code !== body.code) {
return res.status(400).json({
data: null,
errors: [
{
path: "code",
location: "body",
message: "Incorrect.",
},
],
} satisfies ErrorResponse);
}
orm.em.remove(admin.verification);
admin.verification = null;
admin.updatedAt = new Date();
await orm.em.flush();
return res.status(200).json({
data: this.mapper.mapEntityToResponse(admin),
errors: null,
} satisfies SingleResponse<AdminResponse>);
}
async refresh(_req: Request, res: Response) {
const req = _req as Request & AdminRequestPlugin;
const access = await this.jwtService.createAdminToken(
req.admin,
JwtType.access,
);
const refresh = await this.jwtService.createAdminToken(
req.admin,
JwtType.refresh,
);
return res.status(200).json({
data: {
access_token: access.token,
access_token_expires_at: access.expiresAt,
refresh_token: refresh.token,
refresh_token_expires_at: refresh.expiresAt,
},
errors: null,
} satisfies SingleResponse);
}
async delete(req: Request, res: Response) {
const parseParamsResult = adminParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const admin = await orm.em.findOne(
Admin,
{ id: params.id, verification: null },
{
populate: ["*"],
},
);
if (!admin) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Admin not found.",
},
],
} satisfies ErrorResponse);
}
if (admin.avatar !== null) {
await this.fileStorage.removeFile(admin.avatar);
}
await orm.em.removeAndFlush(admin);
return res.status(204).send();
}
public buildRouter(): Router {
const router = Router();
router.post(
"/",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createAdmin]),
this.create.bind(this),
);
router.post("/login", createOrmContextMiddleware, this.login.bind(this));
router.get("/", createOrmContextMiddleware, this.list.bind(this));
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
router.put(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateAdmin]),
this.update.bind(this),
);
router.put(
"/:id/email",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateAdmin]),
this.changeEmail.bind(this),
);
router.put(
"/:id/password",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateAdmin]),
this.changePassword.bind(this),
);
router.put(
"/:id/verify",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateAdmin]),
this.verify.bind(this),
);
router.put(
"/refresh",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService),
this.refresh.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deleteAdmin]),
this.delete.bind(this),
);
return router;
}
}

View File

@@ -0,0 +1,18 @@
import type { Admin } from "@/database/entities/admin.entity";
import type { AdminResponse } from "@/modules/admin/admin.types";
export class AdminMapper {
public constructor() {}
public mapEntityToResponse(admin: Admin): AdminResponse {
return {
id: admin.id,
name: admin.name,
email: admin.email,
avatar: admin.avatar,
permissions: admin.permissions,
created_at: admin.createdAt,
updated_at: admin.updatedAt,
};
}
}

View File

@@ -0,0 +1,56 @@
import { emailSchema, passwordSchema } from "@/common/schemas";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import z from "zod";
export const adminRequestSchema = z.object({
name: z
.string("Must be string.")
.nonempty("Must not empty.")
.max(100, "Max 100 characters."),
email: emailSchema,
password: passwordSchema,
avatar: z
.base64("Must be base64 string.")
.nonempty("Must not empty.")
.nullable(),
permissions: z
.array(
z.enum(AdminPermission, "Must be valid permission."),
"Must be array.",
)
.nonempty("Must not empty."),
});
export const adminLoginRequestSchema = adminRequestSchema.pick({
email: true,
password: true,
});
export const adminUpdateRequestSchema = adminRequestSchema.pick({
name: true,
avatar: true,
permissions: true,
});
export const adminChangeEmailRequestSchema = z.object({
new_email: emailSchema,
});
export const adminChangePasswordRequestSchema = z.object({
old_password: passwordSchema,
new_password: passwordSchema,
});
export const adminVerifyRequestSchema = z.object({
code: z
.string("Must be string.")
.nonempty("Must not empty.")
.length(6, "Must be 6 characters."),
});
export const adminParamsSchema = z.object({
id: z
.string("Must be string.")
.nonempty("Must not empty.")
.max(200, "Max 200 characters."),
});

View File

@@ -0,0 +1,33 @@
import type { AdminPermission } from "@/database/enums/admin-permission.enum";
import type {
adminChangeEmailRequestSchema,
adminChangePasswordRequestSchema,
adminParamsSchema,
adminRequestSchema,
adminUpdateRequestSchema,
} from "@/modules/admin/admin.schemas";
import z from "zod";
export type AdminRequest = z.infer<typeof adminRequestSchema>;
export type AdminUpdateRequest = z.infer<typeof adminUpdateRequestSchema>;
export type AdminChangeEmailRequest = z.infer<
typeof adminChangeEmailRequestSchema
>;
export type AdminChangePasswordRequest = z.infer<
typeof adminChangePasswordRequestSchema
>;
export type AdminParams = z.infer<typeof adminParamsSchema>;
export type AdminResponse = {
id: string;
name: string;
email: string;
avatar: string | null;
permissions: AdminPermission[];
created_at: Date;
updated_at: Date;
};

View File

@@ -1,13 +1,16 @@
import { Controller } from "@/common/controller";
import { ormMiddleware } from "@/common/middlewares/orm.middleware";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage";
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type {
ErrorResponse,
ListResponse,
SingleResponse,
} from "@/common/types";
import { Airline } from "@/database/entities/airline.entity";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import { orm } from "@/database/orm";
import type { AirlineMapper } from "@/modules/airline/airline.mapper";
import {
@@ -22,6 +25,7 @@ export class AirlineController extends Controller {
public constructor(
private readonly mapper: AirlineMapper,
private readonly fileStorage: AbstractFileStorage,
private readonly jwtService: AbstractJwtService,
) {
super();
}
@@ -43,7 +47,7 @@ export class AirlineController extends Controller {
code: body.code,
logo: logoFile.name,
skytraxRating: body.skytrax_rating,
skytraxType: this.mapper.mapSkytraxType(body.skytrax_type),
skytraxType: body.skytrax_type,
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -149,7 +153,7 @@ export class AirlineController extends Controller {
airline.name = body.name;
airline.code = body.code;
airline.skytraxRating = body.skytrax_rating;
airline.skytraxType = this.mapper.mapSkytraxType(body.skytrax_type);
airline.skytraxType = body.skytrax_type;
airline.updatedAt = new Date();
await orm.em.flush();
@@ -196,11 +200,26 @@ export class AirlineController extends Controller {
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(
"/",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createAirline]),
this.create.bind(this),
);
router.get("/", createOrmContextMiddleware, this.list.bind(this));
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
router.put(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateAirline]),
this.update.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deleteAirline]),
this.delete.bind(this),
);
return router;
}

View File

@@ -1,24 +1,9 @@
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";
import type { 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,

View File

@@ -1,3 +1,4 @@
import { SkytraxType } from "@/database/enums/skytrax-type.enum";
import z from "zod";
export const airlineRequestSchema = z.object({
@@ -16,7 +17,7 @@ export const airlineRequestSchema = z.object({
.min(1, "Minimum 1.")
.max(5, "Maximum 5."),
skytrax_type: z.enum(
["full_service", "low_cost"],
SkytraxType,
"Must be either 'full_service' or 'low_cost'.",
),
});

View File

@@ -1,3 +1,4 @@
import type { SkytraxType } from "@/database/enums/skytrax-type.enum";
import type {
airlineParamsSchema,
airlineRequestSchema,
@@ -14,7 +15,7 @@ export type AirlineResponse = {
code: string;
logo: string;
skytrax_rating: number;
skytrax_type: "full_service" | "low_cost";
skytrax_type: SkytraxType;
created_at: Date;
updated_at: Date;
};

View File

@@ -1,6 +1,8 @@
import { Controller } from "@/common/controller";
import { ormMiddleware } from "@/common/middlewares/orm.middleware";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type {
ErrorResponse,
ListResponse,
@@ -8,6 +10,7 @@ import type {
} from "@/common/types";
import { Airport } from "@/database/entities/airport.entity";
import { City } from "@/database/entities/city.entity";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import { orm } from "@/database/orm";
import type { AirportMapper } from "@/modules/airport/airport.mapper";
import {
@@ -19,7 +22,10 @@ import { Router, type Request, type Response } from "express";
import { ulid } from "ulid";
export class AirportController extends Controller {
public constructor(private readonly mapper: AirportMapper) {
public constructor(
private readonly mapper: AirportMapper,
private readonly jwtService: AbstractJwtService,
) {
super();
}
@@ -224,11 +230,26 @@ export class AirportController extends Controller {
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(
"/",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createAirport]),
this.create.bind(this),
);
router.get("/", createOrmContextMiddleware, this.list.bind(this));
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
router.put(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateAirport]),
this.update.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deleteAirport]),
this.delete.bind(this),
);
return router;
}

View File

@@ -1,6 +1,8 @@
import { Controller } from "@/common/controller";
import { ormMiddleware } from "@/common/middlewares/orm.middleware";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type {
ErrorResponse,
ListResponse,
@@ -8,6 +10,7 @@ import type {
} from "@/common/types";
import { City } from "@/database/entities/city.entity";
import { Country } from "@/database/entities/country.entity";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import { orm } from "@/database/orm";
import type { CityMapper } from "@/modules/city/city.mapper";
import {
@@ -19,7 +22,10 @@ import { Router, type Request, type Response } from "express";
import { ulid } from "ulid";
export class CityController extends Controller {
public constructor(private readonly mapper: CityMapper) {
public constructor(
private readonly mapper: CityMapper,
private readonly jwtService: AbstractJwtService,
) {
super();
}
@@ -210,11 +216,26 @@ export class CityController extends Controller {
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(
"/",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createCity]),
this.create.bind(this),
);
router.get("/", createOrmContextMiddleware, this.list.bind(this));
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
router.put(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateCity]),
this.update.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deleteCity]),
this.delete.bind(this),
);
return router;
}

View File

@@ -1,12 +1,15 @@
import { Controller } from "@/common/controller";
import { ormMiddleware } from "@/common/middlewares/orm.middleware";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type {
ErrorResponse,
ListResponse,
SingleResponse,
} from "@/common/types";
import { Country } from "@/database/entities/country.entity";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import { orm } from "@/database/orm";
import type { CountryMapper } from "@/modules/country/country.mapper";
import {
@@ -18,7 +21,10 @@ import { Router, type Request, type Response } from "express";
import { ulid } from "ulid";
export class CountryController extends Controller {
public constructor(private readonly mapper: CountryMapper) {
public constructor(
private readonly mapper: CountryMapper,
private readonly jwtService: AbstractJwtService,
) {
super();
}
@@ -174,11 +180,26 @@ export class CountryController extends Controller {
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(
"/",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createCountry]),
this.create.bind(this),
);
router.get("/", createOrmContextMiddleware, this.list.bind(this));
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
router.put(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateCountry]),
this.update.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deleteCountry]),
this.delete.bind(this),
);
return router;
}

View File

@@ -1,6 +1,8 @@
import { Controller } from "@/common/controller";
import { ormMiddleware } from "@/common/middlewares/orm.middleware";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type {
ErrorResponse,
ListResponse,
@@ -10,6 +12,7 @@ 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 { AdminPermission } from "@/database/enums/admin-permission.enum";
import { orm } from "@/database/orm";
import type { FlightMapper } from "@/modules/flight/flight.mapper";
import {
@@ -26,7 +29,10 @@ import { Router, type Request, type Response } from "express";
import { ulid } from "ulid";
export class FlightController extends Controller {
public constructor(private readonly mapper: FlightMapper) {
public constructor(
private readonly mapper: FlightMapper,
private readonly jwtService: AbstractJwtService,
) {
super();
}
@@ -622,26 +628,52 @@ export class FlightController extends Controller {
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.post(
"/",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createFlight]),
this.create.bind(this),
);
router.get("/", createOrmContextMiddleware, this.list.bind(this));
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
router.put(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateFlight]),
this.update.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deleteFlight]),
this.delete.bind(this),
);
router.post(
"/:id/classes",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createFlightClass]),
this.createClass.bind(this),
);
router.get(
"/:id/classes",
createOrmContextMiddleware,
this.listClasses.bind(this),
);
router.get(
"/:flight_id/classes/:id",
ormMiddleware,
createOrmContextMiddleware,
this.viewClass.bind(this),
);
router.put(
"/:flight_id/classes/:id",
ormMiddleware,
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateFlightClass]),
this.updateClass.bind(this),
);
router.delete(
"/:flight_id/classes/:id",
ormMiddleware,
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deleteFlightClass]),
this.deleteClass.bind(this),
);

View File

@@ -1,12 +1,15 @@
import { Controller } from "@/common/controller";
import { ormMiddleware } from "@/common/middlewares/orm.middleware";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type {
ErrorResponse,
ListResponse,
SingleResponse,
} from "@/common/types";
import { HotelFacility } from "@/database/entities/hotel-facility.entity";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import { orm } from "@/database/orm";
import type { HotelFacilityMapper } from "@/modules/hotel-facility/hotel-facility.mapper";
import {
@@ -18,7 +21,10 @@ import { Router, type Request, type Response } from "express";
import { ulid } from "ulid";
export class HotelFacilityController extends Controller {
public constructor(private readonly mapper: HotelFacilityMapper) {
public constructor(
private readonly mapper: HotelFacilityMapper,
private readonly jwtService: AbstractJwtService,
) {
super();
}
@@ -184,11 +190,26 @@ export class HotelFacilityController extends Controller {
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(
"/",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createHotelFacility]),
this.create.bind(this),
);
router.get("/", createOrmContextMiddleware, this.list.bind(this));
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
router.put(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateHotelFacility]),
this.update.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deleteHotelFacility]),
this.delete.bind(this),
);
return router;
}

View File

@@ -1,7 +1,9 @@
import { Controller } from "@/common/controller";
import { ormMiddleware } from "@/common/middlewares/orm.middleware";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage";
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type {
ErrorResponse,
ListResponse,
@@ -11,6 +13,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 { Hotel } from "@/database/entities/hotel.entity";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import { orm } from "@/database/orm";
import type { HotelMapper } from "@/modules/hotel/hotel.mapper";
import {
@@ -25,6 +28,7 @@ export class HotelController extends Controller {
public constructor(
private readonly mapper: HotelMapper,
private readonly fileStorage: AbstractFileStorage,
private readonly jwtService: AbstractJwtService,
) {
super();
}
@@ -332,11 +336,26 @@ export class HotelController extends Controller {
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(
"/",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createHotel]),
this.create.bind(this),
);
router.get("/", createOrmContextMiddleware, this.list.bind(this));
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
router.put(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updateHotel]),
this.update.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deleteHotel]),
this.delete.bind(this),
);
return router;
}

View File

@@ -0,0 +1,389 @@
import { Controller } from "@/common/controller";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import {
isPartnerMiddleware,
type PartnerRequestPlugin,
} from "@/common/middlewares/is-partner.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type { AbstractPaymentService } from "@/common/services/payment-service/abstract.payment-service";
import type {
ErrorResponse,
ListResponse,
SingleResponse,
} from "@/common/types";
import { generateRandomCode } from "@/common/utils";
import { OrderDetail } from "@/database/entities/order-detail.entity";
import { Order } from "@/database/entities/order.entity";
import { PackageDetail } from "@/database/entities/package-detail.entity";
import { Partner } from "@/database/entities/partner.entity";
import { Verification } from "@/database/entities/verification.entity";
import { VerificationType } from "@/database/enums/verification-type.enum";
import { orm } from "@/database/orm";
import type { OrderMapper } from "@/modules/order/order.mapper";
import {
orderParamsSchema,
orderRequestSchema,
orderVerifyRequestSchema,
} from "@/modules/order/order.schemas";
import type { OrderResponse } from "@/modules/order/order.types";
import * as dateFns from "date-fns";
import { Router, type Request, type Response } from "express";
import { ulid } from "ulid";
export class OrderController extends Controller {
public constructor(
private readonly mapper: OrderMapper,
private readonly paymentService: AbstractPaymentService,
private readonly jwtService: AbstractJwtService,
) {
super();
}
async create(req: Request, res: Response) {
const parseBodyResult = orderRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const packageDetail = await orm.em.findOne(PackageDetail, {
id: body.package_id,
});
if (!packageDetail) {
return res.status(404).json({
data: null,
errors: [
{
path: "package_id",
location: "body",
message: "Package detail not found.",
},
],
} satisfies ErrorResponse);
}
const verification = orm.em.create(Verification, {
id: ulid(),
code: generateRandomCode(6),
type: VerificationType.createOrder,
expiredAt: dateFns.addHours(new Date(), 1),
createdAt: new Date(),
updatedAt: new Date(),
});
const order = orm.em.create(Order, {
id: ulid(),
package: packageDetail,
name: body.name,
whatsapp: body.whatsapp,
verification,
partner: null,
expiredAt: null,
purchasedAt: null,
finishedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
});
for (const roomType of body.room_types) {
order.details.add(
orm.em.create(OrderDetail, {
id: ulid(),
order,
roomType,
createdAt: new Date(),
updatedAt: new Date(),
}),
);
}
await orm.em.flush();
return res.status(201).json({
data: {
message:
"Order created successfully. Please check your email for verification.",
},
errors: null,
} satisfies SingleResponse);
}
async list(_req: Request, res: Response) {
const req = _req as Request & PartnerRequestPlugin;
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(Order);
const orders = await orm.em.find(
Order,
{
verification: null,
partner: req.partner,
},
{
limit: query.per_page,
offset: (query.page - 1) * query.per_page,
orderBy: { createdAt: "DESC" },
populate: ["*"],
},
);
return res.status(200).json({
data: orders.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<OrderResponse>);
}
async view(_req: Request, res: Response) {
const req = _req as Request & PartnerRequestPlugin;
const parseParamsResult = orderParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const order = await orm.em.findOne(
Order,
{
id: params.id,
verification: null,
partner: req.partner,
},
{
populate: ["*"],
},
);
if (!order) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Order not found.",
},
],
} satisfies ErrorResponse);
}
return res.status(200).json({
data: this.mapper.mapEntityToResponse(order),
errors: null,
} satisfies SingleResponse<OrderResponse>);
}
async finish(_req: Request, res: Response) {
const req = _req as Request & PartnerRequestPlugin;
const parseParamsResult = orderParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const order = await orm.em.findOne(
Order,
{
id: params.id,
verification: null,
partner: req.partner,
},
{
populate: ["*"],
},
);
if (!order) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Order not found.",
},
],
} satisfies ErrorResponse);
}
order.finishedAt = new Date();
order.updatedAt = new Date();
await orm.em.flush();
return res.status(200).json({
data: this.mapper.mapEntityToResponse(order),
errors: null,
} satisfies SingleResponse<OrderResponse>);
}
async verify(req: Request, res: Response) {
const parseParamsResult = orderParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const parseBodyResult = orderVerifyRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const order = await orm.em.findOne(
Order,
{ id: params.id },
{
populate: ["*"],
},
);
if (!order) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Order not found.",
},
],
} satisfies ErrorResponse);
}
if (order.verification === null) {
return res.status(400).json({
data: null,
errors: [
{
message: "Order is already verified.",
},
],
} satisfies ErrorResponse);
}
if (order.verification.code !== body.code) {
return res.status(400).json({
data: null,
errors: [
{
path: "code",
location: "body",
message: "Incorrect.",
},
],
} satisfies ErrorResponse);
}
orm.em.remove(order.verification);
const partners = await orm.em.findAll(Partner, { populate: ["*"] });
const partner = partners.toSorted(
(a, b) =>
a.orders.filter((order) => order.finishedAt === null).length -
b.orders.filter((order) => order.finishedAt === null).length,
)[0];
order.verification = null;
order.partner = partner;
order.expiredAt = dateFns.addHours(new Date(), 24);
order.updatedAt = new Date();
await orm.em.flush();
const paymentUrl = await this.paymentService.createPaymentUrl(order);
return res.status(200).json({
data: {
...this.mapper.mapEntityToResponse(order),
payment_url: paymentUrl,
},
errors: null,
} satisfies SingleResponse<
OrderResponse & {
payment_url: string;
}
>);
}
async delete(_req: Request, res: Response) {
const req = _req as Request & PartnerRequestPlugin;
const parseParamsResult = orderParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const order = await orm.em.findOne(
Order,
{ id: params.id, verification: null, partner: req.partner },
{
populate: ["*"],
},
);
if (!order) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Order not found.",
},
],
} satisfies ErrorResponse);
}
await orm.em.removeAndFlush(order);
return res.status(204).send();
}
public buildRouter(): Router {
const router = Router();
router.post("/", createOrmContextMiddleware, this.create.bind(this));
router.get(
"/",
createOrmContextMiddleware,
isPartnerMiddleware(this.jwtService),
this.list.bind(this),
);
router.get(
"/:id",
createOrmContextMiddleware,
isPartnerMiddleware(this.jwtService),
this.view.bind(this),
);
router.put(
"/:id/finish",
createOrmContextMiddleware,
isPartnerMiddleware(this.jwtService),
this.finish.bind(this),
);
router.put(
"/:id/verify",
createOrmContextMiddleware,
this.verify.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isPartnerMiddleware(this.jwtService),
this.delete.bind(this),
);
return router;
}
}

View File

@@ -0,0 +1,59 @@
import type { Order } from "@/database/entities/order.entity";
import { RoomType } from "@/database/enums/room-type.enum";
import type { OrderResponse } from "@/modules/order/order.types";
import type { PackageMapper } from "@/modules/package/package.mapper";
import type { PartnerMapper } from "@/modules/partner/partner.mapper";
export class OrderMapper {
public constructor(
private readonly packageMapper: PackageMapper,
private readonly partnerMapper: PartnerMapper,
) {}
public mapEntityToResponse(order: Order): OrderResponse {
const details: OrderResponse["details"] = [];
let totalPrice = 0;
for (const detail of order.details) {
let price = 0;
switch (detail.roomType) {
case RoomType.double:
price = order.package.doublePrice;
break;
case RoomType.triple:
price = order.package.triplePrice;
break;
case RoomType.quad:
price = order.package.quadPrice;
break;
case RoomType.infant:
price = order.package.infantPrice ?? 0;
break;
}
details.push({
price,
room_type: detail.roomType,
});
totalPrice += price;
}
return {
id: order.id,
package: this.packageMapper.mapDetailEntityToResponse(order.package),
name: order.name,
whatsapp: order.whatsapp,
details,
total_price: totalPrice,
is_verified: order.verification === null,
partner: order.partner
? this.partnerMapper.mapEntityToResponse(order.partner)
: null,
expired_at: order.expiredAt,
purchased_at: order.purchasedAt,
finished_at: order.finishedAt,
created_at: order.createdAt,
updated_at: order.updatedAt,
};
}
}

View File

@@ -0,0 +1,38 @@
import { phoneNumberSchema } from "@/common/schemas";
import { RoomType } from "@/database/enums/room-type.enum";
import z from "zod";
export const orderRequestSchema = z.object({
package_id: z
.ulid("Must be ulid string.")
.nonempty("Must not empty.")
.max(30, "Max 30 characters."),
name: z
.string("Must be string.")
.nonempty("Must not empty.")
.max(100, "Max 100 characters."),
whatsapp: phoneNumberSchema,
room_types: z
.array(
z.enum(
RoomType,
"Must be either 'double', 'triple', 'quad', or 'infant'.",
),
"Must be array.",
)
.nonempty("Must not empty."),
});
export const orderVerifyRequestSchema = z.object({
code: z
.string("Must be string.")
.nonempty("Must not empty.")
.length(6, "Must be 6 characters."),
});
export const orderParamsSchema = z.object({
id: z
.string("Must be string.")
.nonempty("Must not empty.")
.max(200, "Max 200 characters."),
});

View File

@@ -0,0 +1,31 @@
import type { RoomType } from "@/database/enums/room-type.enum";
import type {
orderParamsSchema,
orderRequestSchema,
} from "@/modules/order/order.schemas";
import type { PackageDetailResponse } from "@/modules/package/package.types";
import type { PartnerResponse } from "@/modules/partner/partner.types";
import z from "zod";
export type OrderRequest = z.infer<typeof orderRequestSchema>;
export type OrderParams = z.infer<typeof orderParamsSchema>;
export type OrderResponse = {
id: string;
package: PackageDetailResponse;
name: string;
whatsapp: string;
details: {
room_type: RoomType;
price: number;
}[];
total_price: number;
is_verified: boolean;
partner: PartnerResponse | null;
expired_at: Date | null;
purchased_at: Date | null;
finished_at: Date | null;
created_at: Date;
updated_at: Date;
};

View File

@@ -1,7 +1,9 @@
import { Controller } from "@/common/controller";
import { ormMiddleware } from "@/common/middlewares/orm.middleware";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage";
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type {
ErrorResponse,
ListResponse,
@@ -22,6 +24,7 @@ import {
import { PackageItinerary } from "@/database/entities/package-itinerary.entity";
import { Package } from "@/database/entities/package.entity";
import { TransportationClass } from "@/database/entities/transportation-class.entity";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import { PackageItineraryWidgetType } from "@/database/enums/package-itinerary-widget-type.enum";
import { orm } from "@/database/orm";
import type { PackageMapper } from "@/modules/package/package.mapper";
@@ -43,6 +46,7 @@ export class PackageController extends Controller {
public constructor(
private readonly mapper: PackageMapper,
private readonly fileStorage: AbstractFileStorage,
private readonly jwtService: AbstractJwtService,
) {
super();
}
@@ -61,8 +65,8 @@ export class PackageController extends Controller {
const package_ = orm.em.create(Package, {
id: ulid(),
name: body.name,
type: this.mapper.mapPackageType(body.type),
class: this.mapper.mapPackageClass(body.class),
type: body.type,
class: body.class,
thumbnail: thumbnailFile.name,
useFastTrain: body.use_fast_train,
createdAt: new Date(),
@@ -177,8 +181,8 @@ export class PackageController extends Controller {
);
package_.name = body.name;
package_.type = this.mapper.mapPackageType(body.type);
package_.class = this.mapper.mapPackageClass(body.class);
package_.type = body.type;
package_.class = body.class;
package_.useFastTrain = body.use_fast_train;
package_.updatedAt = new Date();
@@ -1330,26 +1334,52 @@ export class PackageController extends Controller {
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.post(
"/",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createPackage]),
this.create.bind(this),
);
router.get("/", createOrmContextMiddleware, this.list.bind(this));
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
router.put(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updatePackage]),
this.update.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deletePackage]),
this.delete.bind(this),
);
router.post(
"/:id/details",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createPackageDetail]),
this.createDetail.bind(this),
);
router.get(
"/:id/details",
createOrmContextMiddleware,
this.listDetails.bind(this),
);
router.get(
"/:package_id/details/:id",
ormMiddleware,
createOrmContextMiddleware,
this.viewDetail.bind(this),
);
router.put(
"/:package_id/details/:id",
ormMiddleware,
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updatePackageDetail]),
this.updateDetail.bind(this),
);
router.delete(
"/:package_id/details/:id",
ormMiddleware,
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deletePackageDetail]),
this.deleteDetail.bind(this),
);

View File

@@ -10,9 +10,7 @@ import type {
} 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";
@@ -22,7 +20,6 @@ import type {
PackageItineraryDayResponse,
PackageItineraryResponse,
PackageItineraryWidgetResponse,
PackageRequest,
PackageResponse,
} from "@/modules/package/package.types";
import type { TransportationMapper } from "@/modules/transportation/transportation.mapper";
@@ -35,26 +32,6 @@ export class PackageMapper {
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,

View File

@@ -1,4 +1,6 @@
import { dateSchema, timeSchema } from "@/common/schemas";
import { PackageClass } from "@/database/enums/package-class.enum";
import { PackageType } from "@/database/enums/package-type.enum";
import z from "zod";
export const packageRequestSchema = z.object({
@@ -6,9 +8,9 @@ export const packageRequestSchema = z.object({
.string("Must be string.")
.nonempty("Must not empty.")
.max(100, "Max 100 characters."),
type: z.enum(["reguler", "plus"], "Must be either 'reguler' or 'plus'."),
type: z.enum(PackageType, "Must be either 'reguler' or 'plus'."),
class: z.enum(
["silver", "gold", "platinum"],
PackageClass,
"Must be either 'silver', 'gold', or 'platinum'.",
),
thumbnail: z.base64("Must be base64 string.").nonempty("Must not empty."),

View File

@@ -1,3 +1,5 @@
import type { PackageClass } from "@/database/enums/package-class.enum";
import type { PackageType } from "@/database/enums/package-type.enum";
import type { FlightClassResponse } from "@/modules/flight/flight.types";
import type { HotelResponse } from "@/modules/hotel/hotel.types";
import type {
@@ -20,8 +22,8 @@ export type PackageDetailParams = z.infer<typeof packageDetailParamsSchema>;
export type PackageResponse = {
id: string;
name: string;
type: "reguler" | "plus";
class: "silver" | "gold" | "platinum";
type: PackageType;
class: PackageClass;
thumbnail: string;
use_fast_train: boolean;
created_at: Date;

View File

@@ -0,0 +1,627 @@
import { Controller } from "@/common/controller";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
import {
isPartnerMiddleware,
type PartnerRequestPlugin,
} from "@/common/middlewares/is-partner.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractEmailService } from "@/common/services/email-service/abstract.email-service";
import type {
AbstractFileStorage,
FileResult,
} from "@/common/services/file-storage/abstract.file-storage";
import {
JwtType,
type AbstractJwtService,
} from "@/common/services/jwt-service/abstract.jwt-service";
import type {
ErrorResponse,
ListResponse,
SingleResponse,
} from "@/common/types";
import { generateRandomCode } from "@/common/utils";
import { Partner } from "@/database/entities/partner.entity";
import { Verification } from "@/database/entities/verification.entity";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import { VerificationType } from "@/database/enums/verification-type.enum";
import { orm } from "@/database/orm";
import type { PartnerMapper } from "@/modules/partner/partner.mapper";
import {
partnerChangeEmailRequestSchema,
partnerChangePasswordRequestSchema,
partnerParamsSchema,
partnerRequestSchema,
partnerUpdateRequestSchema,
partnerVerifyRequestSchema,
} from "@/modules/partner/partner.schemas";
import type { PartnerResponse } from "@/modules/partner/partner.types";
import * as dateFns from "date-fns";
import { Router, type Request, type Response } from "express";
import { ulid } from "ulid";
export class PartnerController extends Controller {
public constructor(
private readonly mapper: PartnerMapper,
private readonly fileStorage: AbstractFileStorage,
private readonly emailService: AbstractEmailService,
private readonly jwtService: AbstractJwtService,
) {
super();
}
async create(req: Request, res: Response) {
const parseBodyResult = partnerRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
let avatarFile: null | FileResult = null;
if (body.avatar !== null) {
avatarFile = await this.fileStorage.storeFile(
Buffer.from(body.avatar, "base64"),
);
}
const verification = orm.em.create(Verification, {
id: ulid(),
code: generateRandomCode(6),
type: VerificationType.createPartner,
expiredAt: dateFns.addHours(new Date(), 1),
createdAt: new Date(),
updatedAt: new Date(),
});
const partner = orm.em.create(Partner, {
id: ulid(),
name: body.name,
email: body.email,
whatsapp: body.whatsapp,
password: await Bun.password.hash(body.password),
avatar: avatarFile?.name,
verification,
createdAt: new Date(),
updatedAt: new Date(),
});
await orm.em.flush();
await this.emailService.sendVerificationEmail(
partner.email,
verification.code,
);
return res.status(201).json({
data: {
message:
"Partner created successfully. Please check your email for verification.",
},
errors: null,
} satisfies SingleResponse);
}
async login(req: Request, res: Response) {
const parseBodyResult = partnerRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const partner = await orm.em.findOne(Partner, { email: body.email });
if (!partner) {
return res.status(401).json({
data: null,
errors: [
{
location: "body",
message: "Incorrect email or password.",
},
],
} satisfies ErrorResponse);
}
if (!(await Bun.password.verify(body.password, partner.password))) {
return res.status(401).json({
data: null,
errors: [
{
location: "body",
message: "Incorrect email or password.",
},
],
} satisfies ErrorResponse);
}
if (partner.verification !== null) {
return res.status(400).json({
data: null,
errors: [
{
message: "Partner is not verified.",
},
],
} satisfies ErrorResponse);
}
const access = await this.jwtService.createPartnerToken(
partner,
JwtType.access,
);
const refresh = await this.jwtService.createPartnerToken(
partner,
JwtType.refresh,
);
return res.status(200).json({
data: {
access_token: access.token,
access_token_expires_at: access.expiresAt,
refresh_token: refresh.token,
refresh_token_expires_at: refresh.expiresAt,
},
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(Partner);
const partners = await orm.em.find(
Partner,
{
verification: null,
},
{
limit: query.per_page,
offset: (query.page - 1) * query.per_page,
orderBy: { createdAt: "DESC" },
populate: ["*"],
},
);
return res.status(200).json({
data: partners.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<PartnerResponse>);
}
async view(req: Request, res: Response) {
const parseParamsResult = partnerParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const partner = await orm.em.findOne(
Partner,
{
id: params.id,
verification: null,
},
{
populate: ["*"],
},
);
if (!partner) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Partner not found.",
},
],
} satisfies ErrorResponse);
}
return res.status(200).json({
data: this.mapper.mapEntityToResponse(partner),
errors: null,
} satisfies SingleResponse<PartnerResponse>);
}
async update(req: Request, res: Response) {
const parseParamsResult = partnerParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const parseBodyResult = partnerUpdateRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const partner = await orm.em.findOne(
Partner,
{
id: params.id,
verification: null,
},
{
populate: ["*"],
},
);
if (!partner) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Partner not found.",
},
],
} satisfies ErrorResponse);
}
if (body.avatar !== null) {
await this.fileStorage.storeFile(
Buffer.from(body.avatar, "base64"),
partner.avatar ?? undefined,
);
} else if (partner.avatar !== null) {
await this.fileStorage.removeFile(partner.avatar);
}
partner.name = body.name;
partner.verification = orm.em.create(Verification, {
id: ulid(),
code: generateRandomCode(6),
type: VerificationType.updatePartner,
expiredAt: dateFns.addHours(new Date(), 1),
createdAt: new Date(),
updatedAt: new Date(),
});
partner.updatedAt = new Date();
await orm.em.flush();
await this.emailService.sendVerificationEmail(
partner.email,
partner.verification.code,
);
return res.status(200).json({
data: {
message:
"Partner updated successfully. Please check your email for verification.",
},
errors: null,
} satisfies SingleResponse);
}
async changeEmail(req: Request, res: Response) {
const parseParamsResult = partnerParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const parseBodyResult = partnerChangeEmailRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const partner = await orm.em.findOne(
Partner,
{
id: params.id,
verification: null,
},
{
populate: ["*"],
},
);
if (!partner) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Partner not found.",
},
],
} satisfies ErrorResponse);
}
partner.email = body.new_email;
partner.verification = orm.em.create(Verification, {
id: ulid(),
code: generateRandomCode(6),
type: VerificationType.changeEmailPartner,
expiredAt: dateFns.addHours(new Date(), 1),
createdAt: new Date(),
updatedAt: new Date(),
});
partner.updatedAt = new Date();
await orm.em.flush();
await this.emailService.sendVerificationEmail(
partner.email,
partner.verification.code,
);
return res.status(200).json({
data: {
message:
"Partner's email changed successfully. Please check your email for verification.",
},
errors: null,
} satisfies SingleResponse);
}
async changePassword(req: Request, res: Response) {
const parseParamsResult = partnerParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const parseBodyResult = partnerChangePasswordRequestSchema.safeParse(
req.body,
);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const partner = await orm.em.findOne(
Partner,
{
id: params.id,
verification: null,
},
{
populate: ["*"],
},
);
if (!partner) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Partner not found.",
},
],
} satisfies ErrorResponse);
}
if (!(await Bun.password.verify(body.old_password, partner.password))) {
return res.status(400).json({
data: null,
errors: [
{
path: "old_password",
location: "body",
message: "Incorrect.",
},
],
} satisfies ErrorResponse);
}
partner.password = await Bun.password.hash(body.new_password);
partner.verification = orm.em.create(Verification, {
id: ulid(),
code: generateRandomCode(6),
type: VerificationType.changePasswordPartner,
expiredAt: dateFns.addHours(new Date(), 1),
createdAt: new Date(),
updatedAt: new Date(),
});
partner.updatedAt = new Date();
await orm.em.flush();
await this.emailService.sendVerificationEmail(
partner.email,
partner.verification.code,
);
return res.status(200).json({
data: {
message:
"Partner's password changed successfully. Please check your email for verification.",
},
errors: null,
} satisfies SingleResponse);
}
async verify(req: Request, res: Response) {
const parseParamsResult = partnerParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const parseBodyResult = partnerVerifyRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const partner = await orm.em.findOne(
Partner,
{ id: params.id },
{
populate: ["*"],
},
);
if (!partner) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Partner not found.",
},
],
} satisfies ErrorResponse);
}
if (partner.verification === null) {
return res.status(400).json({
data: null,
errors: [
{
message: "Partner is already verified.",
},
],
} satisfies ErrorResponse);
}
if (partner.verification.code !== body.code) {
return res.status(400).json({
data: null,
errors: [
{
path: "code",
location: "body",
message: "Incorrect.",
},
],
} satisfies ErrorResponse);
}
orm.em.remove(partner.verification);
partner.verification = null;
partner.updatedAt = new Date();
await orm.em.flush();
return res.status(200).json({
data: this.mapper.mapEntityToResponse(partner),
errors: null,
} satisfies SingleResponse<PartnerResponse>);
}
async refresh(_req: Request, res: Response) {
const req = _req as Request & PartnerRequestPlugin;
const access = await this.jwtService.createPartnerToken(
req.partner,
JwtType.access,
);
const refresh = await this.jwtService.createPartnerToken(
req.partner,
JwtType.refresh,
);
return res.status(200).json({
data: {
access_token: access.token,
access_token_expires_at: access.expiresAt,
refresh_token: refresh.token,
refresh_token_expires_at: refresh.expiresAt,
},
errors: null,
} satisfies SingleResponse);
}
async delete(req: Request, res: Response) {
const parseParamsResult = partnerParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const partner = await orm.em.findOne(
Partner,
{ id: params.id, verification: null },
{
populate: ["*"],
},
);
if (!partner) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Partner not found.",
},
],
} satisfies ErrorResponse);
}
if (partner.avatar !== null) {
await this.fileStorage.removeFile(partner.avatar);
}
await orm.em.removeAndFlush(partner);
return res.status(204).send();
}
public buildRouter(): Router {
const router = Router();
router.post(
"/",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.createPartner]),
this.create.bind(this),
);
router.post("/login", createOrmContextMiddleware, this.login.bind(this));
router.get("/", createOrmContextMiddleware, this.list.bind(this));
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
router.put(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updatePartner]),
this.update.bind(this),
);
router.put(
"/:id/email",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updatePartner]),
this.changeEmail.bind(this),
);
router.put(
"/:id/password",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updatePartner]),
this.changePassword.bind(this),
);
router.put(
"/:id/verify",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.updatePartner]),
this.verify.bind(this),
);
router.put(
"/refresh",
createOrmContextMiddleware,
isPartnerMiddleware(this.jwtService),
this.refresh.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [AdminPermission.deletePartner]),
this.delete.bind(this),
);
return router;
}
}

View File

@@ -0,0 +1,18 @@
import type { Partner } from "@/database/entities/partner.entity";
import type { PartnerResponse } from "@/modules/partner/partner.types";
export class PartnerMapper {
public constructor() {}
public mapEntityToResponse(partner: Partner): PartnerResponse {
return {
id: partner.id,
name: partner.name,
email: partner.email,
whatsapp: partner.whatsapp,
avatar: partner.avatar,
created_at: partner.createdAt,
updated_at: partner.updatedAt,
};
}
}

View File

@@ -0,0 +1,54 @@
import {
emailSchema,
passwordSchema,
phoneNumberSchema,
} from "@/common/schemas";
import z from "zod";
export const partnerRequestSchema = z.object({
name: z
.string("Must be string.")
.nonempty("Must not empty.")
.max(100, "Max 100 characters."),
email: emailSchema,
whatsapp: phoneNumberSchema,
password: passwordSchema,
avatar: z
.base64("Must be base64 string.")
.nonempty("Must not empty.")
.nullable(),
});
export const partnerLoginRequestSchema = partnerRequestSchema.pick({
email: true,
password: true,
});
export const partnerUpdateRequestSchema = partnerRequestSchema.pick({
name: true,
avatar: true,
permissions: true,
});
export const partnerChangeEmailRequestSchema = z.object({
new_email: emailSchema,
});
export const partnerChangePasswordRequestSchema = z.object({
old_password: passwordSchema,
new_password: passwordSchema,
});
export const partnerVerifyRequestSchema = z.object({
code: z
.string("Must be string.")
.nonempty("Must not empty.")
.length(6, "Must be 6 characters."),
});
export const partnerParamsSchema = z.object({
id: z
.string("Must be string.")
.nonempty("Must not empty.")
.max(200, "Max 200 characters."),
});

View File

@@ -0,0 +1,32 @@
import type {
partnerChangeEmailRequestSchema,
partnerChangePasswordRequestSchema,
partnerParamsSchema,
partnerRequestSchema,
partnerUpdateRequestSchema,
} from "@/modules/partner/partner.schemas";
import z from "zod";
export type PartnerRequest = z.infer<typeof partnerRequestSchema>;
export type PartnerUpdateRequest = z.infer<typeof partnerUpdateRequestSchema>;
export type PartnerChangeEmailRequest = z.infer<
typeof partnerChangeEmailRequestSchema
>;
export type PartnerChangePasswordRequest = z.infer<
typeof partnerChangePasswordRequestSchema
>;
export type PartnerParams = z.infer<typeof partnerParamsSchema>;
export type PartnerResponse = {
id: string;
name: string;
email: string;
whatsapp: string;
avatar: string | null;
created_at: Date;
updated_at: Date;
};

View File

@@ -1,7 +1,9 @@
import { Controller } from "@/common/controller";
import { ormMiddleware } from "@/common/middlewares/orm.middleware";
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
import { paginationQuerySchema } from "@/common/schemas";
import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage";
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
import type {
ErrorResponse,
ListResponse,
@@ -10,6 +12,7 @@ import type {
import { TransportationClass } from "@/database/entities/transportation-class.entity";
import { TransportationImage } from "@/database/entities/transportation-image.entity";
import { Transportation } from "@/database/entities/transportation.entity";
import { AdminPermission } from "@/database/enums/admin-permission.enum";
import { orm } from "@/database/orm";
import type { TransportationMapper } from "@/modules/transportation/transportation.mapper";
import {
@@ -29,6 +32,7 @@ export class TransportationController extends Controller {
public constructor(
private readonly mapper: TransportationMapper,
private readonly fileStorage: AbstractFileStorage,
private readonly jwtService: AbstractJwtService,
) {
super();
}
@@ -526,26 +530,64 @@ export class TransportationController extends Controller {
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.post(
"/",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [
AdminPermission.createTransportation,
]),
this.create.bind(this),
);
router.get("/", createOrmContextMiddleware, this.list.bind(this));
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
router.put(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [
AdminPermission.updateTransportation,
]),
this.update.bind(this),
);
router.delete(
"/:id",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [
AdminPermission.deleteTransportation,
]),
this.delete.bind(this),
);
router.post(
"/:id/classes",
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [
AdminPermission.createTransportationClass,
]),
this.createClass.bind(this),
);
router.get(
"/:id/classes",
createOrmContextMiddleware,
this.listClasses.bind(this),
);
router.get(
"/:transportation_id/classes/:id",
ormMiddleware,
createOrmContextMiddleware,
this.viewClass.bind(this),
);
router.put(
"/:transportation_id/classes/:id",
ormMiddleware,
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [
AdminPermission.updateTransportationClass,
]),
this.updateClass.bind(this),
);
router.delete(
"/:transportation_id/classes/:id",
ormMiddleware,
createOrmContextMiddleware,
isAdminMiddleware(this.jwtService, [
AdminPermission.deleteTransportationClass,
]),
this.deleteClass.bind(this),
);