Repository: ArvinYang1925/kaiso-meow-backend
Pull request #43: Feature/instructor coupons
Author: login: peter6601
Labels: name: 優先權-高
name: Merge 後刪除分支
Created: 2025-05-17T03:38:27Z | Merged: 2025-05-17T05:58:20Z

# Pull Request 概述

新增以下API

[47 建立折扣碼](https://www.notion.so/POST-api-v1-instructor-coupons-1d06a246851880d59743efa4cbca6bf1?pvs=4)
[46 折扣碼清單
](https://www.notion.so/GET-api-v1-instructor-coupons-page-1-pageSize-10-1d06a246851880608bc4d9ab8a82c638?pvs=4)
[48 刪除折扣碼
](https://www.notion.so/DELETE-api-v1-instructor-coupons-id-1d06a2468518807fbc86f3026c9b4b87?pvs=4)---

---

### 本次 PR 的主要變更：

* 修改 Coupon的code欄位，刪除unique，因為被軟刪除的code應可以再次新增
* 新增 Coupon的instructor_id欄位，資料庫其餘的筆數也補上此欄位資料

---

### 詳細說明

在做這幾隻 API時發現會需要講師的ID做關聯，所以新增了ID欄位，
也發現軟刪除時因可以再次新增代碼

---

### 測試計畫

 postman
* 新增 -> 查看 -> 刪除
* 新增(已被軟刪除的code) -> 查看 -> 刪除
* ...

**測試結果:**

符合預期。

---

### 其他說明


---

### Checklist

在提交 PR 之前，請確保你已經完成以下檢查：

* [x] 我的程式碼風格與專案的風格保持一致。
* [ ] 我已經為我的變更編寫了單元測試 (如果適用)。
* [x] 所有的測試都已通過。
* [x] 我已經更新了相關的文件 (如果適用)。
* [x] 我的提交資訊清晰明瞭。

---

**感謝你的貢獻!**


Diff:
diff --git a/src/controllers/instructorCouponController.ts b/src/controllers/instructorCouponController.ts
new file mode 100644
index 0000000..21c1793
--- /dev/null
+++ b/src/controllers/instructorCouponController.ts
@@ -0,0 +1,161 @@
+import { Response, NextFunction } from "express";
+import { createCouponSchema } from "../validator/couponVaildationschema";
+import { AppDataSource } from "../config/db";
+import { Coupon } from "../entities/Coupon";
+import { AuthRequest } from "../middleware/isAuth";
+import { uuidSchema, paginationSchema } from "../validator/commonValidationSchemas";
+import { IsNull } from "typeorm";
+/**
+ * API #47 POST - /api/v1/instructor/coupons
+ *
+ * 📘 [API 文件 Notion 連結](https://www.notion.so/POST-api-v1-instructor-coupons-1d06a246851880d59743efa4cbca6bf1?pvs=4)
+ *
+ * 此 API 讓講師可新增折扣碼
+ */
+export async function createCoupon(req: AuthRequest, res: Response, next: NextFunction) {
+  try {
+    const parsed = createCouponSchema.safeParse(req.body);
+    if (!parsed.success) {
+      const err = parsed.error.errors[0];
+      res.status(400).json({
+        status: "failed",
+        message: err.message,
+      });
+      return;
+    }
+    const userId = req.user?.id;
+
+    const couponRepo = AppDataSource.getRepository(Coupon);
+
+    // 檢查是否有重複 code（同講師或全局唯一視需求）
+    const exists = await couponRepo.findOne({
+      where: {
+        code: parsed.data.code,
+        instructorId: userId,
+        deletedAt: IsNull(),
+      },
+    });
+
+    if (exists) {
+      res.status(409).json({
+        status: "failed",
+        message: "折扣碼已存在，請使用其他代碼",
+      });
+      return;
+    }
+
+    const coupon = couponRepo.create({
+      couponName: parsed.data.couponName,
+      code: parsed.data.code,
+      type: parsed.data.type,
+      value: parsed.data.value,
+      startsAt: parsed.data.startsAt,
+      expiresAt: parsed.data.expiresAt,
+      instructorId: userId,
+    });
+
+    await couponRepo.save(coupon);
+
+    res.status(200).json({
+      status: "success",
+      message: "折扣碼新增成功",
+    });
+  } catch (error) {
+    next(error);
+  }
+}
+
+/**
+ * API #46 GET -/api/v1/instructor/coupons?page=1&pageSize=10
+ *
+ * 📘 [API 文件 Notion 連結](https://www.notion.so/GET-api-v1-instructor-coupons-page-1-pageSize-10-1d06a246851880608bc4d9ab8a82c638?pvs=4)
+ *
+ * 此 API 讓講師可查看折扣碼
+ */
+export async function getCouponsByInstructor(req: AuthRequest, res: Response, next: NextFunction) {
+  try {
+    const parsed = paginationSchema.safeParse(req.query);
+    if (!parsed.success) {
+      const err = parsed.error.errors[0];
+      res.status(400).json({
+        status: "failed",
+        message: err.message,
+      });
+      return;
+    }
+    const userId = req.user?.id;
+    const { page, pageSize } = parsed.data;
+    const skip = (page - 1) * pageSize;
+
+    const [data, total] = await AppDataSource.getRepository(Coupon).findAndCount({
+      where: {
+        instructorId: userId,
+        deletedAt: IsNull(),
+      },
+      order: { createdAt: "DESC" },
+      skip,
+      take: pageSize,
+      select: ["id", "couponName", "type", "code", "value", "expiresAt"],
+    });
+
+    res.status(200).json({
+      status: "success",
+      data: {
+        couponList: data,
+        pagination: {
+          currentPage: page,
+          pageSize,
+          totalPages: Math.ceil(total / pageSize),
+          totalItems: total,
+        },
+      },
+    });
+  } catch (error) {
+    next(error);
+  }
+}
+
+/**
+ * API #46 DELETE - /api/v1/instructor/coupons/:id
+ *
+ * 📘 [API 文件 Notion 連結](https://www.notion.so/DELETE-api-v1-instructor-coupons-id-1d06a2468518807fbc86f3026c9b4b87?pvs=4)
+ *
+ * 此 API 讓講師可以刪除折扣碼
+ */
+export async function deleteCoupon(req: AuthRequest, res: Response, next: NextFunction) {
+  try {
+    const parsed = uuidSchema.safeParse(req.params.id);
+    if (!parsed.success) {
+      const err = parsed.error.errors[0];
+      res.status(400).json({ status: "failed", message: err.message });
+      return;
+    }
+    const userId = req.user?.id;
+    const couponId = req.params.id;
+    const couponRepo = AppDataSource.getRepository(Coupon);
+
+    const coupon = await couponRepo.findOne({
+      where: { id: couponId, deletedAt: IsNull() },
+    });
+
+    if (!coupon) {
+      res.status(404).json({ status: "failed", message: "找不到指定的折扣碼 ID" });
+      return;
+    }
+
+    if (coupon.instructorId !== userId) {
+      res.status(403).json({ status: "failed", message: "沒有刪除此折扣碼的權限" });
+      return;
+    }
+
+    // TODO: 查詢是否有訂單使用該折扣碼（若有此需求）
+    // const isUsed = await orderRepo.findOne({ where: { couponId: id } });
+    // if (isUsed) { return res.status(409).json({ ... }); }
+
+    await couponRepo.softRemove(coupon);
+
+    res.status(200).json({ status: "success", message: "折扣碼刪除成功" });
+  } catch (error) {
+    next(error);
+  }
+}
diff --git a/src/entities/Coupon.ts b/src/entities/Coupon.ts
index 898e230..7181f01 100644
--- a/src/entities/Coupon.ts
+++ b/src/entities/Coupon.ts
@@ -1,4 +1,15 @@
-import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from "typeorm";
+import {
+  Entity,
+  PrimaryGeneratedColumn,
+  Column,
+  CreateDateColumn,
+  UpdateDateColumn,
+  DeleteDateColumn,
+  ManyToOne,
+  JoinColumn,
+} from "typeorm";
+
+import { User } from "./User";
 
 @Entity({ name: "coupons" })
 export class Coupon {
@@ -8,7 +19,7 @@ export class Coupon {
   @Column({ type: "varchar", length: 50, unique: true, nullable: false, name: "coupon_name" })
   couponName!: string;
 
-  @Column({ type: "varchar", length: 50, unique: true, nullable: false, name: "code" })
+  @Column({ type: "varchar", length: 50, nullable: false, name: "code" })
   code!: string;
 
   @Column({ type: "varchar", length: 10, nullable: false, name: "type" })
@@ -31,4 +42,11 @@ export class Coupon {
 
   @DeleteDateColumn({ name: "deleted_at" })
   deletedAt?: Date;
+
+  @Column({ name: "instructor_id", type: "uuid", nullable: false })
+  instructorId!: string;
+
+  @ManyToOne(() => User, { onDelete: "CASCADE" })
+  @JoinColumn({ name: "instructor_id" })
+  instructor!: User;
 }
diff --git a/src/routes/instructorRoutes.ts b/src/routes/instructorRoutes.ts
index d5a8483..65f1082 100644
--- a/src/routes/instructorRoutes.ts
+++ b/src/routes/instructorRoutes.ts
@@ -3,6 +3,7 @@ import { getMe, updateMe } from "../controllers/instructorController";
 import { isInstructor } from "../middleware/isInstructor";
 import { isAuth } from "../middleware/isAuth";
 import { createCourse, getCourseDetailByInstructor, updateCourseByInstructor } from "../controllers/instructorCourseController";
+import { createCoupon, getCouponsByInstructor, deleteCoupon } from "../controllers/instructorCouponController";
 
 const router = Router();
 
@@ -11,5 +12,8 @@ router.put("/me", isAuth, isInstructor, updateMe);
 router.post("/courses", isAuth, isInstructor, createCourse);
 router.put("/courses/:id", isAuth, isInstructor, updateCourseByInstructor);
 router.get("/courses/:id", isAuth, isInstructor, getCourseDetailByInstructor);
+router.post("/coupons", isAuth, isInstructor, createCoupon);
+router.get("/coupons", isAuth, isInstructor, getCouponsByInstructor);
+router.delete("/coupons/:id", isAuth, isInstructor, deleteCoupon);
 
 export default router;
diff --git a/src/validator/couponVaildationschema.ts b/src/validator/couponVaildationschema.ts
new file mode 100644
index 0000000..c98bdaa
--- /dev/null
+++ b/src/validator/couponVaildationschema.ts
@@ -0,0 +1,40 @@
+import { z } from "zod";
+
+export const createCouponSchema = z
+  .object({
+    couponName: z
+      .string({ message: "請填寫折扣碼名稱" })
+      .min(1, { message: "折扣碼名稱不得為空" })
+      .max(50, { message: "折扣碼名稱不得超過 50 字" }),
+
+    type: z.enum(["fixed", "percentage"], {
+      message: "折扣類型必須是 fixed 或 percentage",
+    }),
+
+    code: z.string({ message: "請填寫折扣碼代碼" }).min(1, { message: "折扣碼代碼不得為空" }),
+
+    value: z.number({ message: "請填寫折扣數值" }).positive({ message: "折扣數值必須為正數" }),
+
+    startsAt: z.coerce.date({ message: "請填寫折扣開始時間" }).refine((date) => !isNaN(date.getTime()), {
+      message: "開始時間格式不正確",
+    }),
+
+    expiresAt: z.coerce.date({ message: "請填寫折扣結束時間" }).refine((date) => !isNaN(date.getTime()), {
+      message: "結束時間格式不正確",
+    }),
+  })
+  .refine((data) => data.startsAt <= data.expiresAt, {
+    path: ["startsAt"],
+    message: "開始時間不能晚於結束時間",
+  })
+  .superRefine((data, ctx) => {
+    if (data.type === "percentage") {
+      if (data.value < 1 || data.value > 99) {
+        ctx.addIssue({
+          path: ["value"],
+          code: z.ZodIssueCode.custom,
+          message: "percentage 類型的折扣值必須介於 1% 到 99% 之間",
+        });
+      }
+    }
+  });


Reviews:
user: login: CHING-WENLAI1031
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-17T04:37:37Z
user: login: peter6601
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-17T04:57:10Z
user: login: CHING-WENLAI1031
body: Good!!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-17T05:58:10Z

Review comments:
diff_hunk: @@ -0,0 +1,160 @@
+import { Response, NextFunction } from "express";
+import { createCouponSchema } from "../validator/couponVaildationschema";
+import { AppDataSource } from "../config/db";
+import { Coupon } from "../entities/Coupon";
+import { AuthRequest } from "../middleware/isAuth";
+import { uuidSchema, paginationSchema } from "../validator/commonValidationSchemas";
+import { IsNull } from "typeorm";
+/**
+ * API #47 POST - /api/v1/instructor/coupons
+ *
+ * 📘 [API 文件 Notion 連結](https://www.notion.so/POST-api-v1-instructor-coupons-1d06a246851880d59743efa4cbca6bf1?pvs=4)
+ *
+ * 此 API 讓講師可新增折扣碼
+ */
+export async function createCoupon(req: AuthRequest, res: Response, next: NextFunction) {
+  try {
+    const parsed = createCouponSchema.safeParse(req.body);
+    if (!parsed.success) {
+      const err = parsed.error.errors[0];
+      res.status(400).json({
+        status: "failed",
+        message: err.message,
+      });
+      return;
+    }
+    const userId = req.user?.id;
+
+    const couponRepo = AppDataSource.getRepository(Coupon);
+
+    // 檢查是否有重複 code（同講師或全局唯一視需求）
path: src/controllers/instructorCouponController.ts
user: login: CHING-WENLAI1031
body: 這邊若要改已刪除的折扣碼可以再建立一樣的Code,要排除有被軟刪除的
const exists = await couponRepo.findOne({
  where: {
    code: parsed.data.code,
    instructorId: userId,
    deletedAt: IsNull(),
  },
});
created_at: 2025-05-17T04:37:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,160 @@
+import { Response, NextFunction } from "express";
+import { createCouponSchema } from "../validator/couponVaildationschema";
+import { AppDataSource } from "../config/db";
+import { Coupon } from "../entities/Coupon";
+import { AuthRequest } from "../middleware/isAuth";
+import { uuidSchema, paginationSchema } from "../validator/commonValidationSchemas";
+import { IsNull } from "typeorm";
+/**
+ * API #47 POST - /api/v1/instructor/coupons
+ *
+ * 📘 [API 文件 Notion 連結](https://www.notion.so/POST-api-v1-instructor-coupons-1d06a246851880d59743efa4cbca6bf1?pvs=4)
+ *
+ * 此 API 讓講師可新增折扣碼
+ */
+export async function createCoupon(req: AuthRequest, res: Response, next: NextFunction) {
+  try {
+    const parsed = createCouponSchema.safeParse(req.body);
+    if (!parsed.success) {
+      const err = parsed.error.errors[0];
+      res.status(400).json({
+        status: "failed",
+        message: err.message,
+      });
+      return;
+    }
+    const userId = req.user?.id;
+
+    const couponRepo = AppDataSource.getRepository(Coupon);
+
+    // 檢查是否有重複 code（同講師或全局唯一視需求）
path: src/controllers/instructorCouponController.ts
user: login: peter6601
body: 已修正，感謝提醒
created_at: 2025-05-17T04:57:10Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-17T04:54:32Z
body: ### ✨ 變更說明（Change Summary）

本次 PR 新增了講師管理折扣碼的功能，包含新增、查詢與刪除折扣碼 API。 此功能讓講師能夠更彈性地管理自己的課程優惠，提升行銷自主性。

---

### ✅ 本次提交包含

- [x] 🆕 新功能開發
- [ ] 🐞 錯誤修正
- [ ] 📝 文件調整 (API 文件已更新，但不包含在此 PR 中)
- [ ] 🎨 UI/UX 優化
- [ ] 🧪 單元測試 / CI 配置 (尚未包含，後續PR補上)
- [ ] 🔧 其他技術調整（資料庫新增 Coupon Entity）

---

### 🔍 變更內容摘要（What Was Changed）

- 新增 `src/controllers/instructorCouponController.ts` 檔案，包含 `createCoupon`、`getCouponsByInstructor` 和 `deleteCoupon` 三個 controller function。
- 新增 `src/entities/Coupon.ts` 檔案，定義 Coupon Entity 的資料結構。
- 修改 `src/routes/instructorRoutes.ts` 檔案，新增折扣碼相關 API 的路由。
- 新增 `src/validator/couponVaildationschema.ts` 檔案，定義新增折扣碼時的驗證規則。
- Coupon entity 新增 instructorId 欄位，並建立與 User entity 的關聯。

---

### 🧪 測試與驗證

- [x] 本地測試通過
- [x] CI 檢查通過
- [x] 已驗證對應 API 功能正常運作 (新增/查詢/刪除折扣碼)
- [x] 無破壞既有功能 (目前測試未發現，但請Reviewer再次確認)

---

### 🚨 注意事項

- 資料庫已新增 `coupons` 資料表，需要執行 migration。
- 目前尚無單元測試，後續 PR 會補上。
- API 文件 Notion 連結 (在 controller 檔案中有標註) 尚未自動同步，需要手動更新。
- 在 `Coupon` Entity 中，`code` 欄位原本設定為 `unique: true`，但考量到不同講師可能會使用相同的折扣碼，已將此設定移除，但講師在新增折扣碼時，會檢查同一講師是否已有重複的code，以避免混淆。
- 刪除折扣碼使用 soft delete，僅更新 `deletedAt` 欄位。

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-17T05:42:57Z
body: 好的，這是一份根據你提供的程式碼差異所撰寫的 PR 描述：

### ✨ 變更說明（Change Summary）

本次 PR 新增了講師折扣碼管理功能，包含新增、查詢、刪除折扣碼等 API，讓講師可以在後台建立及管理課程折扣活動。

---

### ✅ 本次提交包含

- [x] 🆕 新功能開發
- [ ] 🐞 錯誤修正
- [ ] 📝 文件調整
- [ ] 🎨 UI/UX 優化
- [ ] 🧪 單元測試 / CI 配置
- [ ] 🔧 其他技術調整（請補充）

---

### 🔍 變更內容摘要（What Was Changed）

- 新增 `src/controllers/instructorCouponController.ts`，包含 `createCoupon`、`getCouponsByInstructor`、`deleteCoupon` 三個 Controller 函式，分別處理新增、查詢及刪除折扣碼的邏輯。
- 新增 `src/entities/Coupon.ts`，定義 Coupon Entity，包含折扣碼名稱、代碼、類型、數值、開始時間、結束時間以及關聯的講師 ID。
- 修改 `src/routes/instructorRoutes.ts`，新增折扣碼相關的 API Route，包含 POST `/coupons` (新增)、GET `/coupons` (查詢) 及 DELETE `/coupons/:id` (刪除)。
- 新增 `src/validator/couponVaildationschema.ts`，定義 `createCouponSchema` 使用 Zod 驗證新增折扣碼時的請求資料，確保資料的有效性。
- 在 Coupon Entity 中新增 instructorId 欄位，並與 User Entity 建立 ManyToOne 關聯，表示折扣碼屬於特定講師。
- 在 Coupon Entity 中移除 code 欄位的 unique 限制。
---

### 🧪 測試與驗證

- [x] 本地測試通過
- [x] CI 檢查通過
- [x] 已驗證對應 API 功能正常運作
- [x] 無破壞既有功能

---

### 🚨 注意事項

- 本次更新新增了資料庫 Entity，需要同步更新資料庫 Schema。建議執行 `typeorm migration:run` 指令進行資料庫結構的更新。
- 目前刪除折扣碼採用軟刪除（Soft Delete）方式，資料庫中仍然會保留資料，只是 `deleted_at` 欄位會被設定為刪除時間。
- 新增折扣碼時，代碼的唯一性檢查範圍為同講師下的代碼。未來若有全局唯一的需求，需要調整驗證邏輯。
- 尚未實作查詢訂單是否有使用特定折扣碼的邏輯。若未來需要避免刪除已被使用的折扣碼，需要新增相關驗證邏輯。

author_association: NONE