feat: line (#13)

* add wedhook line

* text message

* add router get employee, request, quotation

* move code

* do not crash application when not set line token

This feature is opt-in

* dot not crash when not set line client id

Main auth method is keycloak

* change dotenv

* fix: wrong env

* refactor: change to get instead of post

* refactor: remove body for employee get endpoint

* feat: add work relation include

* feat: include customer relation in employee

* feat: add line file controller

* add detail flex message and get date requestWork

* chore: update deps lock

* fix: error line token

* fix: redirect head instead if response with body

* feat: add response relation

* fix: route casing

* add userId in customerBranch verifyOTP

* delete consile log

* add is registered endpoint placeholder

* feat: quotation list

* fix: wrong endpoint name

* feat: include relation in get by id request data

* add where userId line

* refactor: adjust parameter for liff app

* delete code

* refactor: remove post quotation endpoint

* refactor: add where userId line for quotation

* feat: add pending only parameter

* refactor: more condition for inProgressOnly

* refactor: update condition

* feat: add line quotation attachment endpoint

* feat: include product in request work line endpoint

* refactor: pending only now cover more condition

* feat: include invoice with payment relation

* chore: update api docs tag

* chore: clean

* feat: check for registered user

* fix: wrong file location

* feat: add email client for sending an otp

* chore: move some deps to dev deps

* add otpCode otpExpires

* add send-otp and verify-otp

---------

Co-authored-by: Kanjana <kanjana@chamomind.com>
Co-authored-by: chamomind <chamomind@localhost>
Co-authored-by: Methapon2001 <61303214+Methapon2001@users.noreply.github.com>
This commit is contained in:
Methapon Metanipat 2025-02-20 16:07:16 +07:00 committed by GitHub
parent 8709a7dcc8
commit 12bf2182dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 10449 additions and 4 deletions

8774
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,9 @@
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/morgan": "^1.9.9",
"@types/node": "^20.17.10",
"@types/nodemailer": "^6.4.17",
"nodemon": "^3.1.9",
"prettier": "^3.4.2",
"prisma": "^6.3.0",
@ -35,9 +37,10 @@
"@prisma/client": "^6.3.0",
"@scalar/express-api-reference": "^0.4.182",
"@tsoa/runtime": "^6.6.0",
"@types/morgan": "^1.9.9",
"cors": "^2.8.5",
"cron": "^3.3.1",
"dayjs": "^1.11.13",
"dayjs-plugin-utc": "^0.1.2",
"docx-templates": "^4.13.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
@ -45,6 +48,7 @@
"kysely": "^0.27.5",
"minio": "^8.0.2",
"morgan": "^1.10.0",
"nodemailer": "^6.10.0",
"prisma-extension-kysely": "^3.0.0",
"promise.any": "^2.0.6",
"thai-baht-text": "^2.0.5",

36
pnpm-lock.yaml generated
View file

@ -23,15 +23,18 @@ importers:
'@tsoa/runtime':
specifier: ^6.6.0
version: 6.6.0
'@types/morgan':
specifier: ^1.9.9
version: 1.9.9
cors:
specifier: ^2.8.5
version: 2.8.5
cron:
specifier: ^3.3.1
version: 3.3.1
dayjs:
specifier: ^1.11.13
version: 1.11.13
dayjs-plugin-utc:
specifier: ^0.1.2
version: 0.1.2
docx-templates:
specifier: ^4.13.0
version: 4.13.0
@ -53,6 +56,9 @@ importers:
morgan:
specifier: ^1.10.0
version: 1.10.0
nodemailer:
specifier: ^6.10.0
version: 6.10.0
prisma-extension-kysely:
specifier: ^3.0.0
version: 3.0.0(@prisma/client@6.3.0(prisma@6.3.0(typescript@5.7.2))(typescript@5.7.2))
@ -81,9 +87,15 @@ importers:
'@types/express':
specifier: ^4.17.21
version: 4.17.21
'@types/morgan':
specifier: ^1.9.9
version: 1.9.9
'@types/node':
specifier: ^20.17.10
version: 20.17.10
'@types/nodemailer':
specifier: ^6.4.17
version: 6.4.17
nodemon:
specifier: ^3.1.9
version: 3.1.9
@ -471,6 +483,9 @@ packages:
'@types/node@20.17.10':
resolution: {integrity: sha512-/jrvh5h6NXhEauFFexRin69nA0uHJ5gwk4iDivp/DeoEua3uwCUto6PC86IpRITBOs4+6i2I56K5x5b6WYGXHA==}
'@types/nodemailer@6.4.17':
resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==}
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@ -853,6 +868,9 @@ packages:
resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==}
engines: {node: '>= 0.4'}
dayjs-plugin-utc@0.1.2:
resolution: {integrity: sha512-ExERH5o3oo6jFOdkvMP3gytTCQ9Ksi5PtylclJWghr7k7m3o2U5QrwtdiJkOxLOH4ghr0EKhpqGefzGz1VvVJg==}
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
@ -1786,6 +1804,10 @@ packages:
encoding:
optional: true
nodemailer@6.10.0:
resolution: {integrity: sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==}
engines: {node: '>=6.0.0'}
nodemon@3.1.9:
resolution: {integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==}
engines: {node: '>=10'}
@ -3266,6 +3288,10 @@ snapshots:
dependencies:
undici-types: 6.19.8
'@types/nodemailer@6.4.17':
dependencies:
'@types/node': 20.17.10
'@types/normalize-package-data@2.4.4': {}
'@types/qs@6.9.17': {}
@ -3732,6 +3758,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
dayjs-plugin-utc@0.1.2: {}
dayjs@1.11.13: {}
debug@2.6.9:
@ -4784,6 +4812,8 @@ snapshots:
optionalDependencies:
encoding: 0.1.13
nodemailer@6.10.0: {}
nodemon@3.1.9:
dependencies:
chokidar: 3.6.0

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "CustomerBranch" ADD COLUMN "userId" TEXT;

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CustomerBranch" ADD COLUMN "otpCode" TEXT,
ADD COLUMN "otpExpires" TEXT;

View file

@ -0,0 +1,9 @@
/*
Warnings:
- The `otpExpires` column on the `CustomerBranch` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "CustomerBranch" DROP COLUMN "otpExpires",
ADD COLUMN "otpExpires" DATE;

View file

@ -0,0 +1,9 @@
/*
Warnings:
- The `otpExpires` column on the `CustomerBranch` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "CustomerBranch" DROP COLUMN "otpExpires",
ADD COLUMN "otpExpires" TIME;

View file

@ -0,0 +1,9 @@
/*
Warnings:
- The `otpExpires` column on the `CustomerBranch` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "CustomerBranch" DROP COLUMN "otpExpires",
ADD COLUMN "otpExpires" TIMESTAMP(3);

View file

@ -534,6 +534,10 @@ model CustomerBranch {
telephoneNo String
userId String?
otpCode String?
otpExpires DateTime?
// NOTE: About (Natural Person)
namePrefix String?
firstName String?

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,125 @@
import { Body, Controller, Get, Post, Request, Route, Security, Tags } from "tsoa";
import prisma from "../db";
import nodemailer from "nodemailer";
import { notFoundError } from "../utils/error";
import { RequestWithLineUser } from "../interfaces/user";
type sendEmail = {
email: string;
} & ({ legalPersonNo: string } | { citizenId: string });
type VerificationPayload = {
email: string;
otp: string;
} & ({ legalPersonNo: string } | { citizenId: string });
@Route("/api/v1/verification")
@Tags("Verification")
export class verificationController extends Controller {
@Get()
@Security("line")
async isRegistered(@Request() req: RequestWithLineUser) {
return !!(await prisma.customerBranch.findFirst({ where: { userId: req.user.sub } }));
}
@Post("/send-otp")
public async sendOTP(@Body() body: sendEmail) {
const generateOTP = Math.floor(100000 + Math.random() * 900000).toString();
const expiresTime = new Date(Date.now() + 5 * 60 * 1000);
const dataCustomerBranch = await prisma.customerBranch.findFirst({
where: {
email: body.email,
...("citizenId" in body
? {
citizenId: body.citizenId,
customer: {
customerType: "PERS",
},
}
: {
legalPersonNo: body.legalPersonNo,
customer: {
customerType: "CORP",
},
}),
},
});
if (!dataCustomerBranch) throw notFoundError("Customer Branch");
const fromData = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 587,
secure: false, // true for port 465, false for other ports
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
await fromData.sendMail({
from: process.env.SMTP_USER,
to: body.email,
subject: "Your OTP Code",
text: `Your OTP Code ${generateOTP}`,
html: `<p>Your OTP code is: <strong>${generateOTP}</strong></p>`,
});
await prisma.customerBranch.update({
where: {
id: dataCustomerBranch.id,
},
data: {
otpCode: generateOTP,
otpExpires: expiresTime,
},
});
return { message: "OTP sent successfully" };
}
@Post("/verify-otp")
@Security("line")
public async verifyOTP(@Request() req: RequestWithLineUser, @Body() body: VerificationPayload) {
const customerBranch = await prisma.customerBranch.findFirst({
where: {
email: body.email,
...("citizenId" in body
? {
citizenId: body.citizenId,
customer: {
customerType: "PERS",
},
}
: {
legalPersonNo: body.legalPersonNo,
customer: {
customerType: "CORP",
},
}),
},
});
if (!customerBranch) throw notFoundError("Customer Branch");
if (
customerBranch.otpCode &&
customerBranch.otpCode === body.otp &&
customerBranch.otpExpires &&
customerBranch.otpExpires >= new Date()
) {
const dataCustomer = await prisma.customerBranch.update({
where: {
id: customerBranch.id,
},
data: {
userId: req.user.sub,
},
});
return dataCustomer;
} else {
return "OTP ไม่ถูกต้อง";
}
}
}

View file

@ -0,0 +1,183 @@
import { Body, Controller, Get, Post, Query, Response, Route, Tags } from "tsoa";
import prisma from "../db";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status";
import { notFoundError } from "../utils/error";
dayjs.extend(utc);
dayjs.extend(timezone);
interface WebhookPayload {
destination?: string;
events: Array<{
mode?: string;
deliveryContext?: Record<string, any>;
webhookEventId?: string;
type: string;
replyToken: string;
source: {
userId: string;
type: string;
};
timestamp: number;
message: {
id?: string;
type: string;
text?: string;
quoteToken?: string;
stickerId?: string;
packageId?: string;
stickerResourceType?: string;
keywords?: string[];
emojis?: string[] | { productId: string; emojiId: string; index: number; length: number }[];
};
}>;
}
interface accessToken {
code: string;
state: string;
}
@Route("api/v1/webhook")
@Tags("Webhook")
export class WebHookController extends Controller {
async #getLineToken() {
if (!process.env.LINE_MESSAGING_API_TOKEN) {
console.warn("Line Webhook Activated but LINE_MESSAGING_API_TOKEN not set.");
throw new HttpError(HttpStatus.NOT_IMPLEMENTED, "NOT IMPLEMENTED", "notImplemented");
}
return process.env.LINE_MESSAGING_API_TOKEN;
}
@Post()
@Response(200, "Webhook received successfully")
public async receiveWebhook(@Body() payload: WebhookPayload) {
const token = await this.#getLineToken();
if (!payload || !payload.events || !Array.isArray(payload.events)) {
this.setStatus(400);
return { message: "Invalid payload structure" };
}
if (payload.events.length > 0) {
const userIdLine = payload.events[0]?.source?.userId;
const dataNow = dayjs().tz("Asia/Bangkok").startOf("day");
// const dataUser = await prisma.customerBranch.findFirst({
// where:{
// userId:userIdLine
// }
// })
const dataEmployee = await prisma.employeePassport.findMany({
select: {
firstName: true,
firstNameEN: true,
lastName: true,
lastNameEN: true,
employeeId: true,
expireDate: true,
employee: {
select: {
firstName: true,
lastName: true,
customerBranch: {
select: {
firstName: true,
firstNameEN: true,
lastName: true,
lastNameEN: true,
customerName: true,
customer: {
select: {
customerType: true,
registeredBranch: {
select: {
telephoneNo: true,
},
},
},
},
},
},
},
},
},
where: {
expireDate: {
lt: dataNow.add(30, "day").toDate(),
},
},
orderBy: {
expireDate: "asc",
},
});
if (payload?.events[0]?.message) {
const message = payload.events[0].message.text;
if (message === "เมนูหลัก > ข้อความ") {
const dataUser = userIdLine;
const textHead = "JWS ALERT:";
let textData = "";
if (dataEmployee.length > 0) {
const customerName =
dataEmployee[0]?.employee?.customerBranch?.customerName ?? "ไม่ระบุ";
const telephoneNo =
dataEmployee[0]?.employee?.customerBranch?.customer.registeredBranch.telephoneNo ??
"ไม่ระบุ";
const textEmployer = `เรียน คุณ${customerName}`;
const textAlert = "ขอแจ้งให้ทราบว่าหนังสือเดินทางของลูกจ้าง";
const textAlert2 = "และจำเป็นต้องดำเนินการต่ออายุในเร็ว ๆ นี้";
const textExpDate =
"🔹 กรุณาตรวจสอบและดำเนินการต่ออายุภายในวันที่กำหนด เพื่อป้องกันปัญหาด้านเอกสารหรือการเดินทางที่อาจเกิดขึ้น";
const textAlert3 = "หากดำเนินการเรียบร้อยแล้ว กรุณาแจ้งให้ฝ่ายที่เกี่ยวข้องทราบ 🙏";
let textFooter = `📞 สอบถามข้อมูลเพิ่มเติม: ${telephoneNo}`;
const textEmployees = dataEmployee
.map((item, index) => {
const dateFormat =
dayjs(item.expireDate).format("DD/MM/") + (dayjs(item.expireDate).year() + 543);
const diffDate = dayjs(item.expireDate).diff(dayjs(), "day");
return `${index + 1}. คุณ${item.firstName} ${item.lastName} วันหมดอายุเอกสาร : ${dateFormat} ใกล้หมดอายุอีก ${diffDate} วัน\n https://taii-cmm.case-collection.com/api/v1/line/employee/${item.employeeId}`;
})
.join("\n");
textData = `${textHead}\n\n${textEmployer}\n\n${textAlert}\n${textEmployees}\n${textAlert2}\n\n${textExpDate}\n\n${textAlert3}\n\n${textFooter}`;
} else {
textData = `${textHead}\n\nขออภัย ไม่พบข้อมูลหนังสือเดินทางที่มีกำหนดหมดอายุภายใน 30 วันข้างหน้า 🙏`;
}
const data = {
to: dataUser,
messages: [
{
type: "text",
text: textData,
},
],
};
await fetch("https://api.line.me/v2/bot/message/push", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
}
}
}
return { message: "Webhook received successfully" };
}
}

View file

@ -11,3 +11,15 @@ export type RequestWithUser = Request & {
roles: string[];
};
};
export type RequestWithLineUser = Request & {
user: {
iss: string;
sub: string;
aud: string;
exp: number;
iat: number;
name: string;
picture: string;
};
};

View file

@ -0,0 +1,47 @@
import Express from "express";
import HttpError from "../../interfaces/http-error";
import HttpStatus from "../../interfaces/http-status";
export async function lineAuth(request: Express.Request) {
const data = new URLSearchParams();
const token = request.headers["authorization"];
if (!process.env.LINE_CLIENT_ID) {
console.warn("Line related endpoint was called but LINE_CLIENT_ID not set.");
throw new HttpError(HttpStatus.NOT_IMPLEMENTED, "NOT IMPLEMENTED", "notImplemented");
}
const LINE_CLIENT_ID = process.env.LINE_CLIENT_ID;
if (!token || typeof token !== "string") {
throw new HttpError(
HttpStatus.UNAUTHORIZED,
"authorization data not found.",
"authDataNotFound",
);
}
data.append("id_token", token);
data.append("client_id", LINE_CLIENT_ID);
const dataUser = await fetch("https://api.line.me/oauth2/v2.1/verify", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: data.toString(),
});
if (!dataUser)
throw new HttpError(
HttpStatus.INTERNAL_SERVER_ERROR,
"Error authentication service.",
"authFailedFatal",
);
if (!dataUser.ok) {
throw new HttpError(HttpStatus.UNAUTHORIZED, "Unauthorized.", "authFailed");
}
return await dataUser.json();
}

View file

@ -2,6 +2,7 @@ import Express from "express";
import HttpError from "../interfaces/http-error";
import HttpStatus from "../interfaces/http-status";
import { keycloakAuth } from "./auth-provider/keycloak";
import { lineAuth } from "./auth-provider/line";
export async function expressAuthentication(
request: Express.Request,
@ -17,6 +18,9 @@ export async function expressAuthentication(
request.app.locals.logData.userName = authData.name;
request.app.locals.logData.userId = authData.sub;
return authData;
case "line":
const authLineData = await lineAuth(request);
return authLineData;
default:
throw new HttpError(
HttpStatus.NOT_IMPLEMENTED,