add auth and payment api
This commit is contained in:
5
src/common/errors/invalid-jwt.error.ts
Normal file
5
src/common/errors/invalid-jwt.error.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class InvalidJwtError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
5
src/common/errors/payment.error.ts
Normal file
5
src/common/errors/payment.error.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class PaymentError extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
51
src/common/middlewares/is-admin.middleware.ts
Normal file
51
src/common/middlewares/is-admin.middleware.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
34
src/common/middlewares/is-partner.middleware.ts
Normal file
34
src/common/middlewares/is-partner.middleware.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export abstract class AbstractEmailService {
|
||||
public abstract sendVerificationEmail(
|
||||
to: string,
|
||||
code: string,
|
||||
): Promise<void>;
|
||||
}
|
||||
30
src/common/services/email-service/library.email-service.ts
Normal file
30
src/common/services/email-service/library.email-service.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
29
src/common/services/jwt-service/abstract.jwt-service.ts
Normal file
29
src/common/services/jwt-service/abstract.jwt-service.ts
Normal 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>;
|
||||
}
|
||||
149
src/common/services/jwt-service/library.jwt-service.ts
Normal file
149
src/common/services/jwt-service/library.jwt-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Order } from "@/database/entities/order.entity";
|
||||
|
||||
export abstract class AbstractPaymentService {
|
||||
public abstract createPaymentUrl(order: Order): Promise<string>;
|
||||
}
|
||||
141
src/common/services/payment-service/midtrans.payment-service.ts
Normal file
141
src/common/services/payment-service/midtrans.payment-service.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
11
src/common/utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user