diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..8a553be0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "node-training-postgresql", + "lockfileVersion": 2, + "requires": true, + "packages": {} +} diff --git a/week4/README.md b/week4/README.md index 745efc80..6faee182 100644 --- a/week4/README.md +++ b/week4/README.md @@ -13,3 +13,16 @@ - `npm run restart` - 重新啟動伺服器與資料庫 - `npm run stop` - 關閉啟動伺服器與資料庫 - `npm run clean` - 關閉伺服器與資料庫並清除所有資料 + +## 環境安裝、NPM 指令 + +- 先 fork repo 後,再 clone 下來 +- npm install:安裝套件 +- 檢視 .env 設定,調整為 localhost +- npm run start: 運作 Docker,將資料庫環境在本地端運作 +- npm run dev:開啟 Node 應用程式 +- 使用 DBeaver 觀看資料庫狀態 + - Host:localhost + - Database:test + - DB_USERNAME:testHexschool + - DB_PASSWORD:pgStartkit4test diff --git a/week4/db.js b/week4/db.js index 061e7989..71bcaabe 100644 --- a/week4/db.js +++ b/week4/db.js @@ -1,4 +1,4 @@ -const { DataSource, EntitySchema } = require("typeorm") +const { DataSource, EntitySchema } = require("typeorm"); const CreditPackage = new EntitySchema({ name: "CreditPackage", @@ -10,8 +10,48 @@ const CreditPackage = new EntitySchema({ generated: "uuid", nullable: false, }, + name: { + type: "varchar", + length: 50, + nullable: false, + }, + credit_amount: { + type: "int", + nullable: false, + }, + price: { + type: "numeric", + nullable: false, + precision: 10, + scale: 2, + }, + create_at: { + type: "timestamp", + createDate: true, + }, + }, +}); +const Skill = new EntitySchema({ + name: "Skill", + tableName: "SKILL", + columns: { + id: { + primary: true, + type: "uuid", + generated: "uuid", + nullable: false, + }, + name: { + type: "varchar", + length: 50, + nullable: false, + }, + create_at: { + type: "timestamp", + createDate: true, + }, }, -}) +}); const AppDataSource = new DataSource({ type: "postgres", @@ -20,8 +60,8 @@ const AppDataSource = new DataSource({ username: process.env.DB_USERNAME || "root", password: process.env.DB_PASSWORD || "test", database: process.env.DB_DATABASE || "test", - entities: [CreditPackage], + entities: [CreditPackage, Skill], synchronize: true, -}) +}); -module.exports = AppDataSource +module.exports = AppDataSource; diff --git a/week4/server.js b/week4/server.js index 6e34e9a6..d19e9b94 100644 --- a/week4/server.js +++ b/week4/server.js @@ -1,41 +1,223 @@ -require("dotenv").config() -const http = require("http") -const AppDataSource = require("./db") +require("dotenv").config(); +const http = require("http"); +const AppDataSource = require("./db"); + +const headers = { + "Access-Control-Allow-Headers": + "Content-Type, Authorization, Content-Length, X-Requested-With", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "PATCH, POST, GET,OPTIONS,DELETE", + "Content-Type": "application/json", +}; + +function isUndefined(value) { + return value === undefined; +} + +function isNotValidSting(value) { + return typeof value !== "string" || value.trim().length === 0 || value === ""; +} + +function isNotValidInteger(value) { + return typeof value !== "number" || value < 0 || value % 1 !== 0; +} const requestListener = async (req, res) => { - const headers = {} - let body = "" + let body = ""; req.on("data", (chunk) => { - body += chunk - }) + body += chunk; + }); if (req.url === "/api/credit-package" && req.method === "GET") { - + try { + const packages = await AppDataSource.getRepository("CreditPackage").find({ + select: ["id", "name", "credit_amount", "price"], + }); + responseSuccessHandler(res, packages); + } catch (error) { + errorResponseHandler(res); + } } else if (req.url === "/api/credit-package" && req.method === "POST") { - - } else if (req.url.startsWith("/api/credit-package/") && req.method === "DELETE") { - + req.on("end", async () => { + try { + const data = JSON.parse(body); + if ( + isUndefined(data.name) || + isNotValidSting(data.name) || + isUndefined(data.credit_amount) || + isNotValidInteger(data.credit_amount) || + isUndefined(data.price) || + isNotValidInteger(data.price) + ) { + errorResponseHandler(res, 400, "欄位未填寫正確"); + return; + } + const creditPackage = AppDataSource.getRepository("CreditPackage"); + const existPackage = await creditPackage.find({ + where: { + name: data.name, + }, + }); + if (existPackage.length > 0) { + errorResponseHandler(res, 409, "資料重複"); + return; + } + const newPackage = creditPackage.create({ + name: data.name, + credit_amount: data.credit_amount, + price: data.price, + }); + const result = await creditPackage.save(newPackage); + responseSuccessHandler(res, result); + } catch (error) { + errorResponseHandler(res); + } + }); + } else if ( + req.url.startsWith("/api/credit-package/") && + req.method === "DELETE" + ) { + try { + const packageId = req.url.split("/")[3]; + if (isUndefined(packageId) || isNotValidSting(packageId)) { + errorResponseHandler(res, 400, "ID錯誤"); + return; + } + const result = await AppDataSource.getRepository("CreditPackage").delete( + packageId + ); + if (result.affected === 0) { + errorResponseHandler(res, 400, "ID錯誤"); + return; + } + responseSuccessHandler(res); + } catch (error) { + errorResponseHandler(res); + } + } else if (req.url === "/api/coaches/skill" && req.method === "GET") { + try { + const Skills = await AppDataSource.getRepository("Skill").find({ + select: ["id", "name"], + }); + responseSuccessHandler(res, Skills); + } catch (error) { + console.log(error); + errorResponseHandler(res); + } + } else if (req.url === "/api/coaches/skill" && req.method === "POST") { + req.on("end", async () => { + try { + const data = JSON.parse(body); + if (isUndefined(data.name) || isNotValidSting(data.name)) { + errorResponseHandler(res, 400, "欄位未填寫正確"); + return; + } + const Skills = AppDataSource.getRepository("Skill"); + const existSkills = await Skills.find({ + where: { + name: data.name, + }, + }); + if (existSkills.length > 0) { + errorResponseHandler(res, 409, "資料重複"); + return; + } + const newSkills = Skills.create({ + name: data.name, + }); + const result = await Skills.save(newSkills); + responseSuccessHandler(res, result); + } catch (error) { + errorResponseHandler(res); + } + }); + } else if ( + req.url.startsWith("/api/coaches/skill/") && + req.method === "DELETE" + ) { + try { + const skillId = req.url.split("/")[4]; + if (isUndefined(skillId) || isNotValidSting(skillId)) { + errorResponseHandler(res, 400, "ID錯誤"); + return; + } + const result = await AppDataSource.getRepository("Skill").delete(skillId); + if (result.affected === 0) { + errorResponseHandler(res, 400, "ID錯誤"); + return; + } + responseSuccessHandler(res); + } catch (error) { + console.log("error", error); + + errorResponseHandler(res); + } } else if (req.method === "OPTIONS") { - res.writeHead(200, headers) - res.end() + res.writeHead(200, headers); + res.end(); } else { - res.writeHead(404, headers) - res.write(JSON.stringify({ - status: "failed", - message: "無此網站路由", - })) - res.end() + errorResponseHandler(res, 404); } -} +}; -const server = http.createServer(requestListener) +const server = http.createServer(requestListener); async function startServer() { - await AppDataSource.initialize() - console.log("資料庫連接成功") - server.listen(process.env.PORT) - console.log(`伺服器啟動成功, port: ${process.env.PORT}`) + await AppDataSource.initialize(); + console.log("資料庫連接成功"); + server.listen(process.env.PORT); + console.log(`伺服器啟動成功, port: ${process.env.PORT}`); return server; } module.exports = startServer(); + +function responseSuccessHandler(res, data = null) { + res.writeHead(200, headers); + res.write( + JSON.stringify({ + status: "success", + data, + }) + ); + res.end(); +} + +function errorResponseHandler(res, statusCode = 500, message = "伺服器錯誤") { + res.writeHead(statusCode, headers); + switch (statusCode) { + case 400: + res.write( + JSON.stringify({ + status: "failed", + message, + }) + ); + break; + case 404: + res.write( + JSON.stringify({ + status: "failed", + message: "無此網站路由", + }) + ); + break; + case 409: + res.write( + JSON.stringify({ + status: "failed", + message: "資料重複", + }) + ); + break; + default: + res.write( + JSON.stringify({ + status: "error", + message: message, + }) + ); + } + + res.end(); +} diff --git a/week5/app.js b/week5/app.js index c3faee55..4ee673a6 100644 --- a/week5/app.js +++ b/week5/app.js @@ -1,39 +1,61 @@ -const express = require('express') -const cors = require('cors') -const path = require('path') -const pinoHttp = require('pino-http') +const express = require("express"); +const cors = require("cors"); +const path = require("path"); +const pinoHttp = require("pino-http"); +const { errorResponse } = require("./utils/response"); -const logger = require('./utils/logger')('App') -const creditPackageRouter = require('./routes/creditPackage') +const logger = require("./utils/logger")("App"); +const creditPackageRouter = require("./routes/creditPackage"); +const skillRouter = require("./routes/skill"); +const userRouter = require("./routes/user"); +const adminRouter = require("./routes/admin"); +const coachesRouter = require("./routes/coaches"); +const coursesRouter = require("./routes/courses"); -const app = express() -app.use(cors()) -app.use(express.json()) -app.use(express.urlencoded({ extended: false })) -app.use(pinoHttp({ - logger, - serializers: { - req (req) { - req.body = req.raw.body - return req - } - } -})) -app.use(express.static(path.join(__dirname, 'public'))) +const app = express(); +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: false })); +app.use( + pinoHttp({ + logger, + serializers: { + req(req) { + req.body = req.raw.body; + return req; + }, + }, + }) +); +app.use(express.static(path.join(__dirname, "public"))); -app.get('/healthcheck', (req, res) => { - res.status(200) - res.send('OK') -}) -app.use('/api/credit-package', creditPackageRouter) +app.get("/healthcheck", (req, res) => { + res.status(200); + res.send("OK"); +}); +app.use("/api/credit-package", creditPackageRouter); +app.use("/api/coaches/skill", skillRouter); +app.use("/api/users", userRouter); +app.use("/api/admin", adminRouter); +app.use("/api/coaches", coachesRouter); +app.use("/api/courses", coursesRouter); +app.use((req, res, next) => { + res.status(404).json({ + status: "error", + message: "找不到網頁", + }); +}); // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { - req.log.error(err) + if (err.status) { + errorResponse(res, err.status, err.message); + return; + } res.status(500).json({ - status: 'error', - message: '伺服器錯誤' - }) -}) + status: "error", + message: "伺服器錯誤", + }); +}); -module.exports = app +module.exports = app; diff --git a/week5/bin/www.js b/week5/bin/www.js index 6f1084e5..29572ad9 100644 --- a/week5/bin/www.js +++ b/week5/bin/www.js @@ -3,49 +3,47 @@ /** * Module dependencies. */ -const http = require('http') -const config = require('../config/index') -const app = require('../app') // 導入 app.js -const logger = require('../utils/logger')('www') -const { dataSource } = require('../db/data-source') +const http = require("http"); +const config = require("../config/index"); +const app = require("../app"); // 導入 app.js +const logger = require("../utils/logger")("www"); +const { dataSource } = require("../db/data-source"); -const port = config.get('web.port') +const port = config.get("web.port"); -app.set('port', port) +app.set("port", port); -const server = http.createServer(app) +const server = http.createServer(app); -function onError (error) { - if (error.syscall !== 'listen') { - throw error +function onError(error) { + if (error.syscall !== "listen") { + throw error; } - const bind = typeof port === 'string' - ? `Pipe ${port}` - : `Port ${port}` + const bind = typeof port === "string" ? `Pipe ${port}` : `Port ${port}`; // handle specific listen errors switch (error.code) { - case 'EACCES': - logger.error(`${bind} requires elevated privileges`) - process.exit(1) - break - case 'EADDRINUSE': - logger.error(`${bind} is already in use`) - process.exit(1) - break + case "EACCES": + logger.error(`${bind} requires elevated privileges`); + process.exit(1); + break; + case "EADDRINUSE": + logger.error(`${bind} is already in use`); + process.exit(1); + break; default: - logger.error(`exception on ${bind}: ${error.code}`) - process.exit(1) + logger.error(`exception on ${bind}: ${error.code}`); + process.exit(1); } } -server.on('error', onError) +server.on("error", onError); server.listen(port, async () => { try { - await dataSource.initialize() - logger.info('資料庫連線成功') - logger.info(`伺服器運作中. port: ${port}`) + await dataSource.initialize(); + logger.info("資料庫連線成功"); + logger.info(`伺服器運作中. port: ${port}`); } catch (error) { - logger.error(`資料庫連線失敗: ${error.message}`) - process.exit(1) + logger.error(`資料庫連線失敗: ${error.message}`); + process.exit(1); } -}) +}); diff --git a/week5/config/index.js b/week5/config/index.js index 06d93d71..c418ed5c 100644 --- a/week5/config/index.js +++ b/week5/config/index.js @@ -1,16 +1,18 @@ -const dotenv = require('dotenv') +const dotenv = require("dotenv"); -const result = dotenv.config() -const db = require('./db') -const web = require('./web') +const result = dotenv.config(); +const db = require("./db"); +const web = require("./web"); +const secret = require("./secret"); if (result.error) { - throw result.error + throw result.error; } const config = { db, - web -} + web, + secret, +}; class ConfigManager { /** @@ -22,20 +24,20 @@ class ConfigManager { * @throws Will throw an error if the configuration path is not found. */ - static get (path) { - if (!path || typeof path !== 'string') { - throw new Error(`incorrect path: ${path}`) + static get(path) { + if (!path || typeof path !== "string") { + throw new Error(`incorrect path: ${path}`); } - const keys = path.split('.') - let configValue = config + const keys = path.split("."); + let configValue = config; keys.forEach((key) => { if (!Object.prototype.hasOwnProperty.call(configValue, key)) { - throw new Error(`config ${path} not found`) + throw new Error(`config ${path} not found`); } - configValue = configValue[key] - }) - return configValue + configValue = configValue[key]; + }); + return configValue; } } -module.exports = ConfigManager +module.exports = ConfigManager; diff --git a/week5/config/secret.js b/week5/config/secret.js new file mode 100644 index 00000000..3eac6696 --- /dev/null +++ b/week5/config/secret.js @@ -0,0 +1,4 @@ +module.exports = { + jwtSecret: process.env.JWT_SECRET, + jwtExpiresDay: process.env.JWT_EXPIRES_DAY, +}; diff --git a/week5/constant/StatusCode.js b/week5/constant/StatusCode.js new file mode 100644 index 00000000..647f4eb7 --- /dev/null +++ b/week5/constant/StatusCode.js @@ -0,0 +1,9 @@ +const StatusCode = { + SUCCESS: 200, + BAD_REQUEST: 400, + PERMISSION_DENIED: 401, + SERVER_ERROR: 500, + CREATED: 201, + CONFLICT: 409, +}; +module.exports = StatusCode; diff --git a/week5/controllers/admin.js b/week5/controllers/admin.js new file mode 100644 index 00000000..69b2f55f --- /dev/null +++ b/week5/controllers/admin.js @@ -0,0 +1,208 @@ +const catchAsync = require("../utils/catchAsync"); +const logger = require("../utils/logger")("admin-coaches"); +const { successResponse, errorResponse } = require("../utils/response"); +const { validateString, validatedInteger } = require("../utils/validation"); +const StatusCode = require("../constant/StatusCode"); +const { dataSource } = require("../db/data-source"); + +// 新增教練課程資料 +const createCoachCourse = catchAsync(async (req, res, next) => { + console.log("statu", StatusCode.BAD_REQUEST); + const { + user_id, + skill_id, + name, + description, + start_at, + end_at, + max_participants, + meeting_url, + } = req.body; + + if ( + !validateString(user_id) || + !validateString(skill_id) || + !validateString(name) || + !validateString(description) || + !validateString(start_at) || + !validateString(end_at) || + !validatedInteger(max_participants) || + !validateString(meeting_url) || + !meeting_url.startsWith("https") + ) { + errorResponse(res, StatusCode.BAD_REQUEST, "欄位未正確填寫"); + return; + } + const UserRepo = dataSource.getRepository("User"); + const existingUser = await UserRepo.findOne({ + select: ["id", "name", "role"], + where: { id: user_id }, + }); + if (!existingUser) { + errorResponse(res, StatusCode.BAD_REQUEST, "使用者不存在"); + return; + } else if (existingUser.role !== "COACH") { + errorResponse(res, StatusCode.BAD_REQUEST, "使用者不是教練"); + return; + } + const skillRepo = dataSource.getRepository("Skill"); + const existingSkill = await skillRepo.findOne({ + select: ["id"], + where: { id: skill_id }, + }); + if (!existingSkill) { + errorResponse(res, StatusCode.BAD_REQUEST, "技能不存在"); + return; + } + const CourseRepo = dataSource.getRepository("Course"); + + const newCourse = CourseRepo.create({ + user_id, + skill_id, + name, + description, + start_at, + end_at, + max_participants, + meeting_url, + }); + const course = await CourseRepo.save(newCourse); + successResponse( + res, + { + data: { course }, + }, + StatusCode.CREATED + ); +}, logger); + +// 更新教練課程資料 +const updateCoachCourse = catchAsync(async (req, res, next) => { + const { courseId } = req.params; + const { + skill_id, + name, + description, + start_at, + end_at, + max_participants, + meeting_url, + } = req.body; + + if ( + !validateString(courseId) || + !validateString(skill_id) || + !validateString(name) || + !validateString(description) || + !validateString(start_at) || + !validateString(end_at) || + !validatedInteger(max_participants) || + !validateString(meeting_url) || + !meeting_url.startsWith("https") + ) { + errorResponse(res, StatusCode.BAD_REQUEST, "欄位未正確填寫"); + return; + } + + const skillRepo = dataSource.getRepository("Skill"); + const existingSkill = await skillRepo.findOne({ + select: ["id"], + where: { id: skill_id }, + }); + if (!existingSkill) { + errorResponse(res, StatusCode.BAD_REQUEST, "技能不存在"); + return; + } + const CourseRepo = dataSource.getRepository("Course"); + const existingCourse = await CourseRepo.findOne({ + where: { id: courseId }, + }); + if (!existingCourse) { + errorResponse(res, StatusCode.BAD_REQUEST, "課程不存在"); + return; + } + const updateCourseResult = await CourseRepo.update( + { id: courseId }, + { + skill_id, + name, + description, + start_at, + end_at, + max_participants, + meeting_url, + } + ); + if (updateCourseResult.affected === 0) { + errorResponse(res, StatusCode.BAD_REQUEST, "更新失敗"); + return; + } + const course = await CourseRepo.findOne({ + where: { id: courseId }, + }); + successResponse(res, { course }); +}, logger); + +const createUserToCoach = catchAsync(async (req, res, next) => { + const { userId } = req.params; + const { experience_years, description, profile_image_url } = req.body; + if (!validateString(description) || !validatedInteger(experience_years)) { + errorResponse(res, StatusCode.BAD_REQUEST, "欄位未正確填寫"); + return; + } + if ( + profile_image_url && + !validateString(profile_image_url) && + !profile_image_url.startsWith("https") + ) { + logger.warn("大頭貼網址錯誤"); + errorResponse(res, StatusCode.BAD_REQUEST, "欄位未正確填寫"); + return; + } + const UserRepo = dataSource.getRepository("User"); + const getUser = await UserRepo.findOne({ + select: ["id", "role"], + where: { id: userId }, + }); + if (!getUser) { + errorResponse(res, StatusCode.BAD_REQUEST, "使用者不存在"); + return; + } + if (getUser.role === "coach") { + errorResponse(res, StatusCode.BAD_REQUEST, "使用者已經是教練"); + return; + } + const CoachRepo = dataSource.getRepository("Coach"); + const newCoach = CoachRepo.create({ + user_id: userId, + experience_years, + description, + profile_image_url, + }); + + const userUpdateResult = await UserRepo.update( + { id: userId }, + { role: "COACH" } + ); + if (userUpdateResult.affected === 0) { + errorResponse(res, StatusCode.BAD_REQUEST, "更新使用者失敗"); + return; + } + const coachResult = await CoachRepo.save(newCoach); + const getNewUser = await UserRepo.findOne({ + select: ["name", "role"], + where: { id: userId }, + }); + + successResponse( + res, + { + data: { + user: getNewUser, + coach: coachResult, + }, + }, + StatusCode.CREATED + ); +}, logger); +module.exports = { createCoachCourse, updateCoachCourse, createUserToCoach }; diff --git a/week5/controllers/coaches.js b/week5/controllers/coaches.js new file mode 100644 index 00000000..6474d86b --- /dev/null +++ b/week5/controllers/coaches.js @@ -0,0 +1,43 @@ +const catchAsync = require("../utils/catchAsync"); +const { dataSource } = require("../db/data-source"); +const StatusCode = require("../constant/StatusCode"); +const { errorResponse, successResponse } = require("../utils/response"); +const logger = require("../utils/logger")("coaches"); + +const getCoachList = catchAsync(async (req, res, next) => { + const { page, per } = req.query; + const CoachRepo = dataSource.getRepository("Coach"); + + const coachList = await dataSource + .getRepository("Coach") + .createQueryBuilder("coach") + .innerJoin("USER", "user", "coach.user_id = user.id") + .select(["coach.id AS id", "user.name AS name"]) + .skip((page - 1) * per) + .take(per) + .getRawMany(); + + successResponse(res, coachList); +}, logger); + +const getCoachDetails = catchAsync(async (req, res, next) => { + const { coachId } = req.params; + const CoachRepo = dataSource.getRepository("Coach"); + const coach = await CoachRepo.findOne({ where: { id: coachId } }); + if (!coach) { + errorResponse(res, StatusCode.BAD_REQUEST, "ID錯誤"); + return; + } + const userId = coach.user_id; + const UserRepo = dataSource.getRepository("User"); + const user = await UserRepo.findOne({ + select: ["name", "role"], + where: { id: userId }, + }); + successResponse(res, { coach, user }); +}, logger); + +module.exports = { + getCoachList, + getCoachDetails, +}; diff --git a/week5/controllers/courses.js b/week5/controllers/courses.js new file mode 100644 index 00000000..daef92e6 --- /dev/null +++ b/week5/controllers/courses.js @@ -0,0 +1,97 @@ +const catchAsync = require("../utils/catchAsync"); +const logger = require("../utils/logger")("course"); +const { successResponse, errorResponse } = require("../utils/response"); +const StatusCode = require("../constant/StatusCode"); +const { dataSource } = require("../db/data-source"); + +const getCourseList = catchAsync(async (req, res, next) => { + const coursesRepo = dataSource.getRepository("Course"); + const courseList = await dataSource.getRepository("Course").find({ + select: { + id: true, + name: true, + description: true, + start_at: true, + end_at: true, + max_participants: true, + User: { + name: true, + }, + Skill: { + name: true, + }, + }, + relations: { + User: true, + Skill: true, + }, + }); + + const data = courseList.map((course) => ({ + id: course.id, + coach_name: course.User.name, + skill_name: course.Skill.name, + name: course.name, + description: course.description, + start_at: course.start_at, + end_at: course.end_at, + max_participants: course.max_participants, + })); + successResponse(res, data); +}, logger); + +const signUpCourse = catchAsync(async (req, res, next) => { + const { id } = req.user; + const { courseId } = req.params; + const CourseRepo = dataSource.getRepository("Course"); + const course = await CourseRepo.findOne({ + where: { + id: courseId, + }, + }); + if (!course) { + errorResponse(res, StatusCode.BAD_REQUEST, "課程不存在"); + } + const CourseBookingRepo = dataSource.getRepository("CourseBooking"); + const existedBooking = await CourseBookingRepo.findOne({ + where: { + user_id: id, + course_id: courseId, + }, + }); + if (existedBooking) { + errorResponse(res, StatusCode.BAD_REQUEST, "已報名過此課程"); + } + const newBooking = CourseBookingRepo.create({ + user_id: id, + course_id: courseId, + }); + const result = await CourseBookingRepo.save(newBooking); + successResponse(res, result); +}, logger); + +const cancelCourse = catchAsync(async (req, res, next) => { + const { id } = req.user; + const { courseId } = req.params; + const CourseBookingRepo = dataSource.getRepository("CourseBooking"); + const existedBooking = await CourseBookingRepo.findOne({ + where: { + user_id: id, + course_id: courseId, + }, + }); + if (!existedBooking) { + errorResponse(res, StatusCode.BAD_REQUEST, "未報名此課程"); + } + const result = await CourseBookingRepo.remove(existedBooking); + if (result.affected === 0) { + errorResponse(res, StatusCode.BAD_REQUEST, "取消報名失敗"); + } + successResponse(res, null); +}, logger); + +module.exports = { + getCourseList, + signUpCourse, + cancelCourse, +}; diff --git a/week5/controllers/creditPackage.js b/week5/controllers/creditPackage.js new file mode 100644 index 00000000..35064e12 --- /dev/null +++ b/week5/controllers/creditPackage.js @@ -0,0 +1,84 @@ +const catchAsync = require("../utils/catchAsync"); +const logger = require("../utils/logger")("creditPackage"); +const { successResponse, errorResponse } = require("../utils/response"); +const { validateString, validatedInteger } = require("../utils/validation"); +const StatusCode = require("../constant/StatusCode"); +const { dataSource } = require("../db/data-source"); + +const getCreditPackageList = catchAsync(async (req, res, next) => { + const packages = await dataSource.getRepository("CreditPackage").find({ + select: ["id", "name", "credit_amount", "price"], + }); + successResponse(res, packages); +}, logger); + +const createCreditPackage = catchAsync(async (req, res, next) => { + const { name, credit_amount: creditAmount, price } = req.body; + if ( + !validateString(name) || + !validatedInteger(creditAmount) || + !validatedInteger(price) + ) { + errorResponse(res, StatusCode.BAD_REQUEST, "欄位未填寫正確"); + return; + } + const creditPackage = dataSource.getRepository("CreditPackage"); + const existPackage = await creditPackage.find({ + where: { + name, + }, + }); + if (existPackage.length > 0) { + errorResponse(res, 409, "資料重複"); + return; + } + const newPackage = creditPackage.create({ + name, + credit_amount: creditAmount, + price, + }); + const result = await creditPackage.save(newPackage); + successResponse(res); +}, logger); + +const buyCreditPackage = catchAsync(async (req, res, next) => { + const { creditPackageId } = req.params; + // user id is from auth + const { id } = req.user; + const creditPackageRepo = dataSource.getRepository("CreditPackage"); + const creditPackage = await creditPackageRepo.findOne({ + where: { id: creditPackageId }, + }); + if (!creditPackage) { + errorResponse(res, StatusCode.BAD_REQUEST, "ID錯誤"); + } + const creditPurchaseRepo = dataSource.getRepository("CreditPurchase"); + const newCreditPurchase = creditPurchaseRepo.create({ + user_id: id, + credit_package_id: creditPackageId, + purchased_credits: creditPackage.credit_amount, + price_paid: creditPackage.price, + purchaseAt: new Date().toDateString(), + }); + await creditPurchaseRepo.save(newCreditPurchase); + successResponse(res); +}, logger); + +const deleteCreditPackage = catchAsync(async (req, res, next) => { + const { creditPackageId } = req.params; + if (!validateString(creditPackageId)) { + errorResponse(res, StatusCode.BAD_REQUEST, "ID錯誤"); + return; + } + const result = await dataSource + .getRepository("CreditPackage") + .delete(creditPackageId); + successResponse(res, result); +}, logger); + +module.exports = { + getCreditPackageList, + createCreditPackage, + buyCreditPackage, + deleteCreditPackage, +}; diff --git a/week5/controllers/skill.js b/week5/controllers/skill.js new file mode 100644 index 00000000..5c931a1c --- /dev/null +++ b/week5/controllers/skill.js @@ -0,0 +1,52 @@ +const catchAsync = require("../utils/catchAsync"); +const logger = require("../utils/logger")("skill"); +const { successResponse, errorResponse } = require("../utils/response"); +const { validateString, validatedInteger } = require("../utils/validation"); +const StatusCode = require("../constant/StatusCode"); +const { dataSource } = require("../db/data-source"); + +const getCoachesSkillList = catchAsync(async (req, res, next) => { + const skills = await dataSource.getRepository("Skill").find({ + select: ["id", "name"], + }); + successResponse(res, skills); +}, logger); + +const createCoachesSkill = catchAsync(async (req, res, next) => { + const { name } = req.body; + if (!validateString(name)) { + errorResponse(res, StatusCode.BAD_REQUEST, "欄位未填寫正確"); + return; + } + const skill = dataSource.getRepository("Skill"); + const existSkill = await skill.find({ + where: { + name, + }, + }); + if (existSkill.length > 0) { + errorResponse(res, 409, "資料重複"); + return; + } + const newSkill = skill.create({ + name, + }); + const result = await skill.save(newSkill); + successResponse(res); +}, logger); + +const deleteCoachesSkill = catchAsync(async (req, res, next) => { + const { skillId } = req.params; + if (!validateString(skillId)) { + errorResponse(res, StatusCode.BAD_REQUEST, "ID錯誤"); + return; + } + const result = await dataSource.getRepository("Skill").delete(skillId); + successResponse(res, result); +}, logger); + +module.exports = { + getCoachesSkillList, + createCoachesSkill, + deleteCoachesSkill, +}; diff --git a/week5/controllers/user.js b/week5/controllers/user.js new file mode 100644 index 00000000..26d32303 --- /dev/null +++ b/week5/controllers/user.js @@ -0,0 +1,136 @@ +const bcrypt = require("bcrypt"); + +const config = require("../config/index"); +const generateJWT = require("../utils/generateJWT"); +const catchAsync = require("../utils/catchAsync"); +const logger = require("../utils/logger")("user"); +const { successResponse, errorResponse } = require("../utils/response"); +const { validateString, validatedInteger } = require("../utils/validation"); +const StatusCode = require("../constant/StatusCode"); +const { dataSource } = require("../db/data-source"); + +const passwordPattern = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,16}/; +const pwdPatternError = + "密碼不符合規則,需要包含英文數字大小寫,最短8個字,最長16個字"; + +const userSignup = catchAsync(async (req, res, next) => { + const { name, email, password } = req.body; + if (!validateString(name) || !validateString(email)) { + errorResponse(res, StatusCode.BAD_REQUEST, "欄位未填寫正確"); + return; + } + if (!validateString(password) || !passwordPattern.test(password)) { + errorResponse(res, StatusCode.BAD_REQUEST, pwdPatternError); + return; + } + + const User = dataSource.getRepository("User"); + + const existUser = await User.findOne({ + where: { + email, + }, + }); + if (existUser) { + errorResponse(res, StatusCode.CONFLICT, "Email 已被使用"); + return; + } + // 建立新使用者 + const salt = await bcrypt.genSalt(10); + const hashPassword = await bcrypt.hash(password, salt); + const newUser = User.create({ + name, + email, + role: "USER", + password: hashPassword, + }); + const savedUser = await User.save(newUser); + const { password: pwd, ...user } = savedUser; + logger.info("新建立的使用者ID:", savedUser.id); + successResponse(res, user, StatusCode.CREATED); +}, logger); + +const userLogin = catchAsync(async (req, res, next) => { + const { email, password } = req.body; + if (!validateString(email)) { + errorResponse(res, StatusCode.BAD_REQUEST, "欄位未填寫正確"); + return; + } + if (!validateString(password) || !passwordPattern.test(password)) { + logger.warn(pwdPatternError); + errorResponse(res, StatusCode.BAD_REQUEST, pwdPatternError); + return; + } + const userRepository = dataSource.getRepository("User"); + const existingUser = await userRepository.findOne({ + select: ["id", "name", "password"], + where: { email }, + }); + + if (!existingUser) { + errorResponse(res, StatusCode.BAD_REQUEST, "使用者不存在或密碼輸入錯誤"); + return; + } + logger.info(`使用者資料: ${JSON.stringify(existingUser)}`); + const isMatch = await bcrypt.compare(password, existingUser.password); + if (!isMatch) { + errorResponse(res, StatusCode.BAD_REQUEST, "使用者不存在或密碼輸入錯誤"); + return; + } + const token = await generateJWT( + { id: existingUser.id, role: existingUser.role }, + config.get("secret.jwtSecret"), + { expiresIn: `${config.get("secret.jwtExpiresDay")}` } + ); + successResponse(res, { token, user: { name: existingUser.name } }); +}, logger); + +const getUserProfile = catchAsync(async (req, res, next) => { + // req.user 是 auth middleware 加入的 + const { user } = req; + const respData = { + user: { + name: user.name, + email: user.email, + }, + }; + successResponse(res, respData); +}, logger); + +const updateUserProfile = catchAsync(async (req, res, next) => { + const { id } = req.user; + const { name } = req.body; + if (!validateString(name)) { + logger.warn("欄位未填寫正確"); + errorResponse(res, StatusCode.BAD_REQUEST, "欄位未填寫正確"); + return; + } + const userRepository = dataSource.getRepository("User"); + const user = await userRepository.findOne({ + select: ["name"], + where: { + id, + }, + }); + if (user.name === name) { + errorResponse(res, StatusCode.BAD_REQUEST, "使用者名稱未變更"); + return; + } + const updatedResult = await userRepository.update( + { id, name: user.name }, + { name } + ); + if (updatedResult.affected === 0) { + errorResponse(res, StatusCode.BAD_REQUEST, "更新使用者資料失敗"); + return; + } + const result = await userRepository.findOne({ + select: ["name"], + where: { + id, + }, + }); + successResponse(res, { user: result }); +}, logger); + +module.exports = { userSignup, userLogin, getUserProfile, updateUserProfile }; diff --git a/week5/db/data-source.js b/week5/db/data-source.js index 8dd316f4..a43e218b 100644 --- a/week5/db/data-source.js +++ b/week5/db/data-source.js @@ -1,21 +1,33 @@ -const { DataSource } = require('typeorm') -const config = require('../config/index') +const { DataSource } = require("typeorm"); +const config = require("../config/index"); -const CreditPackage = require('../entities/CreditPackages') +const CreditPackage = require("../entities/CreditPackages"); +const Skill = require("../entities/Skill"); +const User = require("../entities/User"); +const Coach = require("../entities/Coach"); +const Course = require("../entities/Course"); +const CourseBooking = require("../entities/CourseBooking"); +const CreditPurchase = require("../entities/CreditPurchase"); const dataSource = new DataSource({ - type: 'postgres', - host: config.get('db.host'), - port: config.get('db.port'), - username: config.get('db.username'), - password: config.get('db.password'), - database: config.get('db.database'), - synchronize: config.get('db.synchronize'), + type: "postgres", + host: config.get("db.host"), + port: config.get("db.port"), + username: config.get("db.username"), + password: config.get("db.password"), + database: config.get("db.database"), + synchronize: config.get("db.synchronize"), poolSize: 10, entities: [ - CreditPackage + CreditPackage, + Skill, + Coach, + User, + Course, + CourseBooking, + CreditPurchase, ], - ssl: config.get('db.ssl') -}) + ssl: config.get("db.ssl"), +}); -module.exports = { dataSource } +module.exports = { dataSource }; diff --git a/week5/entities/Coach.js b/week5/entities/Coach.js new file mode 100644 index 00000000..93233022 --- /dev/null +++ b/week5/entities/Coach.js @@ -0,0 +1,46 @@ +const { EntitySchema } = require("typeorm"); + +module.exports = new EntitySchema({ + name: "Coach", + tableName: "COACH", + columns: { + id: { + primary: true, + type: "uuid", + generated: "uuid", + }, + user_id: { + type: "uuid", + unique: true, + nullable: false, + foreignKey: { + name: "coach_user_id_fkey", + columnNames: ["user_id"], + referencedTableName: "USER", + referencedColumnNames: ["id"], + }, + }, + experience_years: { + type: "integer", + nullable: false, + }, + description: { + type: "text", + nullable: false, + }, + profile_image_url: { + type: "varchar", + length: 2048, + }, + create_at: { + type: "timestamp", + createDate: true, + nullable: false, + }, + update_at: { + type: "timestamp", + updateDate: true, + nullable: false, + }, + }, +}); diff --git a/week5/entities/Course.js b/week5/entities/Course.js new file mode 100644 index 00000000..ca0c4ffe --- /dev/null +++ b/week5/entities/Course.js @@ -0,0 +1,77 @@ +const { EntitySchema } = require("typeorm"); + +module.exports = new EntitySchema({ + name: "Course", + tableName: "COURSE", + columns: { + id: { + primary: true, + type: "uuid", + generated: "uuid", + }, + user_id: { + type: "uuid", + nullable: false, + }, + skill_id: { + type: "uuid", + nullable: false, + }, + name: { + type: "varchar", + length: 100, + nullable: false, + }, + description: { + type: "text", + nullable: false, + }, + start_at: { + type: "timestamp", + nullable: false, + }, + end_at: { + type: "timestamp", + nullable: false, + }, + max_participants: { + type: "integer", + nullable: false, + }, + meeting_url: { + type: "varchar", + length: 2048, + nullable: false, + }, + created_at: { + type: "timestamp", + createDate: true, + nullable: false, + }, + updated_at: { + type: "timestamp", + updateDate: true, + nullable: false, + }, + }, + relations: { + User: { + target: "User", + type: "many-to-one", + joinColumn: { + name: "user_id", + referencedColumnName: "id", + foreignKeyConstraintName: "courses_user_id_fk", + }, + }, + Skill: { + target: "Skill", + type: "many-to-one", + joinColumn: { + name: "skill_id", + referencedColumnName: "id", + foreignKeyConstraintName: "courses_skill_id_fk", + }, + }, + }, +}); diff --git a/week5/entities/CourseBooking.js b/week5/entities/CourseBooking.js new file mode 100644 index 00000000..d0753a2f --- /dev/null +++ b/week5/entities/CourseBooking.js @@ -0,0 +1,74 @@ +const { EntitySchema } = require("typeorm"); + +module.exports = new EntitySchema({ + name: "CourseBooking", + tableName: "COURSE_BOOKING", + columns: { + id: { + primary: true, + type: "uuid", + generated: "uuid", + nullable: false, + }, + user_id: { + type: "uuid", + nullable: false, + }, + course_id: { + type: "uuid", + nullable: false, + }, + bookingAt: { + type: "timestamp", + createDate: true, + name: "booking_at", + nullable: false, + }, + joinAt: { + type: "timestamp", + name: "join_at", + nullable: true, + }, + leaveAt: { + type: "timestamp", + name: "leave_at", + nullable: true, + }, + cancelledAt: { + type: "timestamp", + name: "cancelled_at", + nullable: true, + }, + cancellation_reason: { + type: "varchar", + length: 255, + nullable: true, + }, + createdAt: { + type: "timestamp", + createDate: true, + name: "created_at", + nullable: false, + }, + }, + relations: { + User: { + target: "User", + type: "many-to-one", + joinColumn: { + name: "user_id", + referencedColumnName: "id", + foreignKeyConstraintName: "course_booking_user_id_fk", + }, + }, + CreditPackage: { + target: "Course", + type: "many-to-one", + joinColumn: { + name: "course_id", + referencedColumnName: "id", + foreignKeyConstraintName: "course_booking_course_id_fk", + }, + }, + }, +}); diff --git a/week5/entities/CreditPurchase.js b/week5/entities/CreditPurchase.js new file mode 100644 index 00000000..5e9f443c --- /dev/null +++ b/week5/entities/CreditPurchase.js @@ -0,0 +1,63 @@ +const { EntitySchema } = require("typeorm"); + +module.exports = new EntitySchema({ + name: "CreditPurchase", + tableName: "CREDIT_PURCHASE", + columns: { + id: { + primary: true, + type: "uuid", + generated: "uuid", + nullable: false, + }, + user_id: { + type: "uuid", + nullable: false, + }, + credit_package_id: { + type: "uuid", + nullable: false, + }, + purchased_credits: { + type: "integer", + nullable: false, + }, + price_paid: { + type: "numeric", + precision: 10, + scale: 2, + nullable: false, + }, + createdAt: { + type: "timestamp", + createDate: true, + name: "created_at", + nullable: false, + }, + purchaseAt: { + type: "timestamp", + name: "purchase_at", + nullable: false, + }, + }, + relations: { + User: { + target: "User", + type: "many-to-one", + joinColumn: { + name: "user_id", + referencedColumnName: "id", + foreignKeyConstraintName: "credit_purchase_user_id_fk", + }, + }, + CreditPackage: { + target: "CreditPackage", + type: "many-to-one", + joinColumn: { + name: "credit_package_id", + referencedColumnName: "id", + foreignKeyConstraintName: "credit_purchase_credit_package_id_fk", + }, + }, + }, +}); diff --git a/week5/entities/Skill.js b/week5/entities/Skill.js new file mode 100644 index 00000000..e6dcde64 --- /dev/null +++ b/week5/entities/Skill.js @@ -0,0 +1,22 @@ +const { EntitySchema } = require("typeorm"); + +module.exports = new EntitySchema({ + name: "Skill", + tableName: "SKILL", + columns: { + id: { + primary: true, + type: "uuid", + generated: "uuid", + nullable: false, + }, + name: { + type: "varchar", + length: 50, + }, + create_at: { + type: "timestamp", + createDate: true, + }, + }, +}); diff --git a/week5/entities/User.js b/week5/entities/User.js new file mode 100644 index 00000000..a6277b7f --- /dev/null +++ b/week5/entities/User.js @@ -0,0 +1,45 @@ +const { EntitySchema } = require("typeorm"); + +module.exports = new EntitySchema({ + name: "User", + tableName: "USER", + columns: { + id: { + primary: true, + type: "uuid", + generated: "uuid", + }, + name: { + type: "varchar", + length: 50, + nullable: false, + }, + email: { + type: "varchar", + length: 320, + nullable: false, + unique: true, + }, + role: { + type: "varchar", + length: 20, + nullable: false, + }, + password: { + type: "varchar", + length: 72, + nullable: false, + select: false, + }, + create_at: { + type: "timestamp", + createDate: true, + nullable: false, + }, + update_at: { + type: "timestamp", + updateDate: true, + nullable: false, + }, + }, +}); diff --git a/week5/middlewares/auth.js b/week5/middlewares/auth.js new file mode 100644 index 00000000..123582c9 --- /dev/null +++ b/week5/middlewares/auth.js @@ -0,0 +1,98 @@ +const jwt = require("jsonwebtoken"); +const StatusCode = require("../constant/StatusCode"); + +const FailedMessageMap = { + expired: "Token 已過期", + invalid: "無效的 token", + missing: "請先登入", +}; + +function generateError(status, message) { + const error = new Error(message); + error.status = status; + return error; +} + +function formatVerifyError(jwtError) { + let result; + switch (jwtError.name) { + case "TokenExpiredError": + result = generateError( + StatusCode.PERMISSION_DENIED, + FailedMessageMap.expired + ); + break; + default: + result = generateError( + StatusCode.PERMISSION_DENIED, + FailedMessageMap.invalid + ); + break; + } + return result; +} + +function verifyJWT(token, secret) { + return new Promise((resolve, reject) => { + jwt.verify(token, secret, (error, decoded) => { + if (error) { + reject(formatVerifyError(error)); + } else { + resolve(decoded); + } + }); + }); +} + +module.exports = ({ secret, userRepository, logger = console }) => { + if (!secret || typeof secret !== "string") { + logger.error("[AuthV2] secret is required and must be a string."); + throw new Error("[AuthV2] secret is required and must be a string."); + } + if ( + !userRepository || + typeof userRepository !== "object" || + typeof userRepository.findOneBy !== "function" + ) { + logger.error("[AuthV2] userRepository is required and must be a function."); + throw new Error( + "[AuthV2] userRepository is required and must be a function." + ); + } + return async (req, res, next) => { + if ( + !req.headers || + !req.headers.authorization || + !req.headers.authorization.startsWith("Bearer") + ) { + logger.warn("[AuthV2] Missing authorization header."); + next( + generateError(StatusCode.PERMISSION_DENIED, FailedMessageMap.missing) + ); + return; + } + const [, token] = req.headers.authorization.split(" "); + if (!token) { + logger.warn("[AuthV2] Missing token."); + next( + generateError(StatusCode.PERMISSION_DENIED, FailedMessageMap.missing) + ); + return; + } + try { + const verifyResult = await verifyJWT(token, secret); + const user = await userRepository.findOneBy({ id: verifyResult.id }); + if (!user) { + next( + generateError(StatusCode.PERMISSION_DENIED, FailedMessageMap.invalid) + ); + return; + } + req.user = user; + next(); + } catch (error) { + logger.error(`[AuthV2] ${error.message}`); + next(error); + } + }; +}; diff --git a/week5/middlewares/isCoach.js b/week5/middlewares/isCoach.js new file mode 100644 index 00000000..25efaa4f --- /dev/null +++ b/week5/middlewares/isCoach.js @@ -0,0 +1,20 @@ +const StatusCode = require("../constant/StatusCode"); + +const FORBIDDEN_MESSAGE = "使用者尚未成為教練"; + +function generateError( + status = StatusCode.PERMISSION_DENIED, + message = FORBIDDEN_MESSAGE +) { + const error = new Error(message); + error.status = status; + return error; +} + +module.exports = (req, res, next) => { + if (!req.user || req.user.role !== "COACH") { + next(generateError()); + return; + } + next(); +}; diff --git a/week5/package-lock.json b/week5/package-lock.json index 34f6a0ae..bfc3f2af 100644 --- a/week5/package-lock.json +++ b/week5/package-lock.json @@ -8,10 +8,12 @@ "name": "bootcamp-fitness", "version": "1.0.0", "dependencies": { + "bcrypt": "^5.1.1", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.13.1", "pino": "^9.6.0", @@ -174,6 +176,25 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -249,6 +270,11 @@ "dev": true, "license": "ISC" }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -285,6 +311,17 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -355,6 +392,24 @@ "node": ">= 6.0.0" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -556,6 +611,19 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -612,7 +680,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -656,6 +723,11 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -787,6 +859,14 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/cli-highlight": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", @@ -874,6 +954,14 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -884,9 +972,13 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1092,6 +1184,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1111,6 +1208,14 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -1156,6 +1261,14 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2021,11 +2134,32 @@ "node": ">= 0.6" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -2083,6 +2217,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "node_modules/gauge/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2348,6 +2525,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2391,6 +2573,18 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -2472,7 +2666,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -3018,6 +3211,46 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3058,6 +3291,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3065,12 +3328,39 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3144,7 +3434,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -3171,6 +3460,29 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/mkdirp": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz", @@ -3262,6 +3574,30 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/nodemon": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", @@ -3314,6 +3650,20 @@ "node": ">=4" } }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3324,6 +3674,18 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3599,7 +3961,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3989,6 +4350,19 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4127,7 +4501,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -4144,7 +4517,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -4285,7 +4657,6 @@ "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4357,6 +4728,11 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -4570,6 +4946,14 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -4766,6 +5150,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4835,6 +5254,11 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -5200,6 +5624,11 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -5231,6 +5660,20 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5334,6 +5777,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5462,6 +5931,11 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/week5/package.json b/week5/package.json index d5ef3ddc..f6c3d2ff 100644 --- a/week5/package.json +++ b/week5/package.json @@ -13,10 +13,12 @@ "init:schema": "typeorm schema:sync -d ./db/data-source.js" }, "dependencies": { + "bcrypt": "^5.1.1", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.13.1", "pino": "^9.6.0", @@ -26,11 +28,11 @@ "typeorm": "^0.3.20" }, "devDependencies": { - "nodemon": "^3.1.9", "eslint": "^8.57.1", "eslint-config-standard": "^17.1.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-n": "^16.6.2", - "eslint-plugin-promise": "^6.6.0" + "eslint-plugin-promise": "^6.6.0", + "nodemon": "^3.1.9" } } diff --git a/week5/routes/admin.js b/week5/routes/admin.js new file mode 100644 index 00000000..e65f5ff9 --- /dev/null +++ b/week5/routes/admin.js @@ -0,0 +1,8 @@ +const express = require("express"); +const adminRouter = express.Router(); +const { dataSource } = require("../db/data-source"); +const coachesRouter = require("./admin/coaches"); + +adminRouter.use("/coaches", coachesRouter); + +module.exports = adminRouter; diff --git a/week5/routes/admin/coaches.js b/week5/routes/admin/coaches.js new file mode 100644 index 00000000..ce91801e --- /dev/null +++ b/week5/routes/admin/coaches.js @@ -0,0 +1,41 @@ +const express = require("express"); +const router = express.Router(); +const config = require("../../config/index"); +const { dataSource } = require("../../db/data-source"); +const logger = require("../../utils/logger")("admin-coaches"); + +const auth = require("../../middlewares/auth")({ + secret: config.get("secret").jwtSecret, + userRepository: dataSource.getRepository("User"), + logger, +}); +const isCoach = require("../../middlewares/isCoach"); +const { + createCoachCourse, + updateCoachCourse, + createUserToCoach, +} = require("../../controllers/admin"); + +// 新增教練課程資料 +/** + * body + * { + "user_id" : "1c8da31a-5fd2-44f3-897e-4a259e7ec62b", + "skill_id" : "1c8da31a-5fd2-44f3-897e-4a259e7ec62b", + "name" : "瑜伽課程", + "description" : "瑜伽課程介紹", + "start_at" : "2025-01-01 16:00:00", + "end_at" : "2025-01-01 18:00:00", + "max_participants" : 10, + "meeting_url" : "https://...." +} + */ +router.post("/courses", auth, isCoach, createCoachCourse); + +// 更新教練課程資料 +router.put("/courses/:courseId", auth, isCoach, updateCoachCourse); + +// 將使用者新增為教練 +router.post("/:userId", createUserToCoach); + +module.exports = router; diff --git a/week5/routes/admin/course.js b/week5/routes/admin/course.js new file mode 100644 index 00000000..2d26df1b --- /dev/null +++ b/week5/routes/admin/course.js @@ -0,0 +1,17 @@ +const express = require("express"); +const router = express.Router(); +const { dataSource } = require("../../db/data-source"); +const logger = require("../../utils/logger")("coaches"); +const { errorResponse, successResponse } = require("../../utils/response"); +const { validateString, validatedInteger } = require("../../utils/validation"); + +router.get("/:userId", async (req, res, next) => { + try { + successResponse(res); + } catch (error) { + logger.error(error); + next(error); + } +}); + +module.exports = router; diff --git a/week5/routes/coaches.js b/week5/routes/coaches.js new file mode 100644 index 00000000..6205b5bb --- /dev/null +++ b/week5/routes/coaches.js @@ -0,0 +1,13 @@ +const express = require("express"); +const router = express.Router(); +const { dataSource } = require("../db/data-source"); +const logger = require("../utils/logger")("coaches"); +const { errorResponse, successResponse } = require("../utils/response"); +const { validateString, validatedInteger } = require("../utils/validation"); +const { getCoachDetails, getCoachList } = require("../controllers/coaches"); +const StatusCode = require("../constant/StatusCode"); + +router.get("/:coachId", getCoachDetails); +router.get("/", getCoachList); + +module.exports = router; diff --git a/week5/routes/courses.js b/week5/routes/courses.js new file mode 100644 index 00000000..0b50af29 --- /dev/null +++ b/week5/routes/courses.js @@ -0,0 +1,21 @@ +const express = require("express"); +const router = express.Router(); +const { dataSource } = require("../db/data-source"); +const config = require("../config/index"); +const logger = require("../utils/logger")("course"); +const auth = require("../middlewares/auth")({ + secret: config.get("secret").jwtSecret, + userRepository: dataSource.getRepository("User"), + logger, +}); + +const { + getCourseList, + signUpCourse, + cancelCourse, +} = require("../controllers/courses"); + +router.get("/", getCourseList); +router.route("/:courseId").post(auth, signUpCourse).delete(auth, cancelCourse); + +module.exports = router; diff --git a/week5/routes/creditPackage.js b/week5/routes/creditPackage.js index 90f8f3f2..1c6b5bd2 100644 --- a/week5/routes/creditPackage.js +++ b/week5/routes/creditPackage.js @@ -1,17 +1,28 @@ -const express = require('express') +const express = require("express"); +const router = express.Router(); +const { dataSource } = require("../db/data-source"); +const logger = require("../utils/logger")("CreditPackage"); +const config = require("../config/index"); -const router = express.Router() -const { dataSource } = require('../db/data-source') -const logger = require('../utils/logger')('CreditPackage') +const auth = require("../middlewares/auth")({ + secret: config.get("secret").jwtSecret, + userRepository: dataSource.getRepository("User"), + logger, +}); -router.get('/', async (req, res, next) => { +const { + getCreditPackageList, + createCreditPackage, + buyCreditPackage, + deleteCreditPackage, +} = require("../controllers/creditPackage"); -}) +router.get("/", getCreditPackageList); -router.post('/', async (req, res, next) => { -}) +router.post("/", createCreditPackage); -router.delete('/:creditPackageId', async (req, res, next) => { -}) +router + .post("/:creditPackageId", auth, buyCreditPackage) + .delete("/:creditPackageId", deleteCreditPackage); -module.exports = router +module.exports = router; diff --git a/week5/routes/skill.js b/week5/routes/skill.js new file mode 100644 index 00000000..7729f28c --- /dev/null +++ b/week5/routes/skill.js @@ -0,0 +1,17 @@ +const express = require("express"); +const router = express.Router(); +const { dataSource } = require("../db/data-source"); +const logger = require("../utils/logger")("Skill"); + +const { + getCoachesSkillList, + createCoachesSkill, + deleteCoachesSkill, +} = require("../controllers/skill"); + +router.get("/", getCoachesSkillList); + +router.post("/", createCoachesSkill); +router.delete("/:skillId", deleteCoachesSkill); + +module.exports = router; diff --git a/week5/routes/user.js b/week5/routes/user.js new file mode 100644 index 00000000..065079f1 --- /dev/null +++ b/week5/routes/user.js @@ -0,0 +1,28 @@ +const express = require("express"); +const router = express.Router(); + +const { dataSource } = require("../db/data-source"); +const config = require("../config/index"); + +const logger = require("../utils/logger")("user"); + +const auth = require("../middlewares/auth")({ + secret: config.get("secret").jwtSecret, + userRepository: dataSource.getRepository("User"), + logger, +}); + +const { + userSignup, + userLogin, + getUserProfile, + updateUserProfile, +} = require("../controllers/user"); + +router.post("/signup", userSignup); + +router.post("/login", userLogin); + +router.route("/profile").get(auth, getUserProfile).put(auth, updateUserProfile); + +module.exports = router; diff --git a/week5/utils/catchAsync.js b/week5/utils/catchAsync.js new file mode 100644 index 00000000..9c1968de --- /dev/null +++ b/week5/utils/catchAsync.js @@ -0,0 +1,11 @@ +const catchAsync = + (fn, logger = "App") => + (req, res, next) => { + Promise.resolve(fn(req, res, next, logger)).catch((err) => { + if (logger) logger.error(err); + console.log("err", err); + return next(err); + }); + }; + +module.exports = catchAsync; diff --git a/week5/utils/generateJWT.js b/week5/utils/generateJWT.js new file mode 100644 index 00000000..2186ef26 --- /dev/null +++ b/week5/utils/generateJWT.js @@ -0,0 +1,19 @@ +const jwt = require("jsonwebtoken"); + +/** + * create JSON Web Token + * @param {Object} payload token content + * @param {String} secret token secret + * @param {Object} [option] same to npm package - jsonwebtoken + * @returns {String} + */ +module.exports = (payload, secret, option = {}) => + new Promise((resolve, reject) => { + jwt.sign(payload, secret, option, (err, token) => { + if (err) { + reject(err); + return; + } + resolve(token); + }); + }); diff --git a/week5/utils/response.js b/week5/utils/response.js new file mode 100644 index 00000000..f5fe761b --- /dev/null +++ b/week5/utils/response.js @@ -0,0 +1,18 @@ +function successResponse(res, data = null, statusCode = 200) { + res.status(statusCode).json({ + status: "success", + data, + }); +} + +function errorResponse(res, statusCode = 500, message = "伺服器錯誤") { + res.status(statusCode).json({ + status: "failed", + message, + }); +} + +module.exports = { + successResponse, + errorResponse, +}; diff --git a/week5/utils/validation.js b/week5/utils/validation.js new file mode 100644 index 00000000..97c8a813 --- /dev/null +++ b/week5/utils/validation.js @@ -0,0 +1,12 @@ +const validation = { + validateString: (str) => { + return ( + str !== undefined && typeof str === "string" && str.trim().length !== 0 + ); + }, + validatedInteger: (num) => { + return num !== undefined && typeof num === "number" && num % 1 === 0; + }, +}; + +module.exports = validation;