Phase 11. 이메일 발송 환경 및 자동 발송
This commit is contained in:
@@ -7,6 +7,17 @@ NEXTAUTH_URL="http://localhost:3000"
|
||||
# Logging: APP_ENV=local|stage|production / LOG_LEVEL=debug|info|warn|error
|
||||
APP_ENV=local
|
||||
LOG_LEVEL=debug
|
||||
# Mail (MAIL_SEND_ENABLED=false 로 발송 비활성화, true 로 실제 발송)
|
||||
MAIL_SMTP_HOST=smtp.gmail.com
|
||||
MAIL_SMTP_PORT=465
|
||||
MAIL_SMTP_SECURE=true
|
||||
MAIL_SMTP_USER=support+chocomae@jinaju.com
|
||||
MAIL_SMTP_PASSWORD=<gmail_app_password>
|
||||
MAIL_FROM_NAME=초코마에
|
||||
MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
|
||||
MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
|
||||
MAIL_SEND_ENABLED=false
|
||||
MAIL_OVERRIDE_TO=jisangs@naver.com
|
||||
|
||||
# Production example (.env.production)
|
||||
# DATABASE_URL="mysql://USER:PASSWORD@AWS_EC2_HOST:3306/chocomae"
|
||||
@@ -16,6 +27,16 @@ LOG_LEVEL=debug
|
||||
# NEXTAUTH_URL="https://NAS_HOST_OR_DOMAIN"
|
||||
# APP_ENV=production
|
||||
# LOG_LEVEL=info
|
||||
# MAIL_SMTP_HOST=smtp.gmail.com
|
||||
# MAIL_SMTP_PORT=465
|
||||
# MAIL_SMTP_SECURE=true
|
||||
# MAIL_SMTP_USER=support+chocomae@jinaju.com
|
||||
# MAIL_SMTP_PASSWORD=<gmail_app_password>
|
||||
# MAIL_FROM_NAME=초코마에
|
||||
# MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
|
||||
# MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
|
||||
# MAIL_SEND_ENABLED=true
|
||||
# MAIL_OVERRIDE_TO=
|
||||
|
||||
# Stage example (.env.stage)
|
||||
# DATABASE_URL="mysql://USER:PASSWORD@STAGE_DB_HOST:3306/chocomae"
|
||||
@@ -25,3 +46,13 @@ LOG_LEVEL=debug
|
||||
# NEXTAUTH_URL="https://STAGE_HOST_OR_DOMAIN"
|
||||
# APP_ENV=stage
|
||||
# LOG_LEVEL=debug
|
||||
# MAIL_SMTP_HOST=smtp.gmail.com
|
||||
# MAIL_SMTP_PORT=465
|
||||
# MAIL_SMTP_SECURE=true
|
||||
# MAIL_SMTP_USER=support+chocomae@jinaju.com
|
||||
# MAIL_SMTP_PASSWORD=<gmail_app_password>
|
||||
# MAIL_FROM_NAME=초코마에
|
||||
# MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
|
||||
# MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
|
||||
# MAIL_SEND_ENABLED=false
|
||||
# MAIL_OVERRIDE_TO=jisangs@naver.com
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { sendExtensionDoneEmail } from "@/lib/mail";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
const CTX = "extension-requests/[id]";
|
||||
|
||||
@@ -38,7 +40,12 @@ export const PATCH = withApiHandler<{ id: string }>(
|
||||
id: maestroExtensionID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
void sendExtensionDoneEmail(
|
||||
result.maestroName,
|
||||
result.maestroEmail,
|
||||
formatDate(result.availableActivateDateTime)
|
||||
);
|
||||
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
|
||||
}
|
||||
|
||||
await cancelExtensionRequest(maestroExtensionID);
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { sendUpgradeDoneEmail } from "@/lib/mail";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
const CTX = "upgrade-requests/[id]";
|
||||
|
||||
@@ -38,7 +40,15 @@ export const PATCH = withApiHandler<{ id: string }>(
|
||||
id: maestroUpgradeID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
void sendUpgradeDoneEmail(
|
||||
result.maestroName,
|
||||
result.maestroEmail,
|
||||
result.registeredActivateStatus,
|
||||
result.registeredAccountType,
|
||||
result.requestedAccountType,
|
||||
formatDate(result.availableActivateDateTime)
|
||||
);
|
||||
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
|
||||
}
|
||||
|
||||
await rejectUpgradeRequest(maestroUpgradeID);
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
# Phase 11. 이메일 발송 환경 및 자동 발송
|
||||
|
||||
## 목표
|
||||
|
||||
연장 승인 / 업그레이드 승인 완료 시 마에스트로에게 안내 이메일을 자동 발송한다.
|
||||
발송은 DB 커밋 성공 후 트랜잭션 바깥에서 수행하며, 발송 실패가 승인 결과에 영향을 주지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 사전 조사 결과 (기존 mouse-typing 코드 확인)
|
||||
|
||||
### SMTP 설정 (`mail_setting.php`, `send_jinaju_mail.php`)
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| SMTP host | `smtp.gmail.com` |
|
||||
| Port | `465` |
|
||||
| Secure | SMTPS (implicit TLS) |
|
||||
| Auth username | `support+chocomae@jinaju.com` |
|
||||
| From name | `초코마에` |
|
||||
| From address | `support+chocomae@jinaju.com` |
|
||||
| Reply-to | `support+chocomae@jinaju.com` |
|
||||
| BCC | `support+chocomae.automail@jinaju.com` |
|
||||
|
||||
Gmail App Password는 환경변수 `MAIL_SMTP_PASSWORD`로 분리. 코드에 하드코딩하지 않는다.
|
||||
|
||||
### 연장 완료 이메일 (`extensionDone`, `mail_contents.php:165`)
|
||||
|
||||
- **제목**: `[초코마에] {maestro_name} - 마에스트로 계정 유효기간 연장 완료`
|
||||
- **본문 변수**: `{maestro_name}`, `{new_available_date}` (YYYY-MM-DD 형식)
|
||||
- **본문 내용**:
|
||||
- `{maestro_name} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.`
|
||||
- `새로운 유효기간 : {new_available_date}`
|
||||
- 상위 요금제 업그레이드 안내, 6개월 후 자동 삭제 안내
|
||||
|
||||
### 업그레이드 완료 이메일 (2가지 케이스, `mail_contents.php:209,255`)
|
||||
|
||||
업그레이드 전 상태(`RegisteredActivateStatus`)에 따라 본문이 다르다.
|
||||
|
||||
**케이스 A — 체험 → 유료** (`trialMaestroUpgradeDone`, `RegisteredActivateStatus = 1`)
|
||||
|
||||
- **제목**: `[초코마에] {maestro_name} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
|
||||
- **본문 핵심**: `[ {prev_student_count}명 (0원 : 무료 체험 기간) ] 에서 [ {upgrade_student_count}명 ({upgrade_price}원) ] 으로 업그레이드`
|
||||
- `{prev_price}` 없음 — 체험 기간이므로 0원 고정 문자열 사용
|
||||
|
||||
**케이스 B — 유료 → 유료** (`upgradeDone`, `RegisteredActivateStatus = 2`)
|
||||
|
||||
- **제목**: 케이스 A와 동일
|
||||
- **본문 핵심**: `[ {prev_student_count}명 ({prev_price}원) ] 에서 [ {upgrade_student_count}명 ({upgrade_price}원) ] 으로 업그레이드`
|
||||
- `{prev_price}` 있음
|
||||
|
||||
두 케이스 모두 공통 마무리: `마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.`
|
||||
|
||||
---
|
||||
|
||||
## 2. 메일 발송 정책
|
||||
|
||||
| 항목 | 정책 |
|
||||
|---|---|
|
||||
| **발송 시점** | DB 커밋 완료 후, 트랜잭션 바깥에서 발송 |
|
||||
| **발송 실패 처리** | `logger.error`로 기록, 승인 응답은 200으로 그대로 반환 (롤백 없음) |
|
||||
| **중복 발송 방지** | 승인 API는 이미 처리된 건(`Status ≠ 1`)에 409를 반환하므로 발송 자체가 중복 호출되지 않음 |
|
||||
| **발송 비활성화** | `MAIL_SEND_ENABLED=false`이면 발송을 건너뛰고 info 로그만 남김 |
|
||||
| **민감정보 마스킹** | SMTP 비밀번호는 환경변수로 관리, 로그에는 수신자 주소와 제목만 출력 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 순서
|
||||
|
||||
### 3-1. 패키지 설치
|
||||
|
||||
```bash
|
||||
pnpm add nodemailer
|
||||
pnpm add -D @types/nodemailer
|
||||
```
|
||||
|
||||
nodemailer는 Node.js 런타임에서만 동작하므로 Next.js API Route(서버 측)에서만 호출한다.
|
||||
|
||||
### 3-2. 환경변수 추가 (`.env.local`)
|
||||
|
||||
```dotenv
|
||||
MAIL_SMTP_HOST=smtp.gmail.com
|
||||
MAIL_SMTP_PORT=465
|
||||
MAIL_SMTP_SECURE=true
|
||||
MAIL_SMTP_USER=support+chocomae@jinaju.com
|
||||
MAIL_SMTP_PASSWORD=<gmail_app_password>
|
||||
MAIL_FROM_NAME=초코마에
|
||||
MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
|
||||
MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
|
||||
MAIL_SEND_ENABLED=false # local/stage 발송 테스트 전까지 false 유지
|
||||
```
|
||||
|
||||
### 3-3. `lib/mail.ts` 구현 (신규)
|
||||
|
||||
```
|
||||
lib/mail.ts
|
||||
```
|
||||
|
||||
**내부 구조**
|
||||
|
||||
```
|
||||
createTransporter() — nodemailer transport 생성 (환경변수 기반)
|
||||
sendMail(options) — 공통 발송 함수 (MAIL_SEND_ENABLED 체크, 로그, BCC)
|
||||
sendExtensionDoneEmail(...) — 연장 완료 이메일
|
||||
sendUpgradeDoneEmail(...) — 업그레이드 완료 이메일 (체험/유료 케이스 내부 분기)
|
||||
```
|
||||
|
||||
**`sendExtensionDoneEmail` 시그니처**
|
||||
|
||||
```typescript
|
||||
sendExtensionDoneEmail(
|
||||
maestroName: string,
|
||||
maestroEmail: string,
|
||||
newAvailableDate: string // YYYY-MM-DD
|
||||
): Promise<boolean>
|
||||
```
|
||||
|
||||
**`sendUpgradeDoneEmail` 시그니처**
|
||||
|
||||
```typescript
|
||||
sendUpgradeDoneEmail(
|
||||
maestroName: string,
|
||||
maestroEmail: string,
|
||||
registeredActivateStatus: number, // ACTIVATE_STATUSES.TRIAL(1) or ACTIVE(2)
|
||||
registeredAccountType: number,
|
||||
requestedAccountType: number,
|
||||
newAvailableDate: string // YYYY-MM-DD
|
||||
): Promise<boolean>
|
||||
```
|
||||
|
||||
내부에서 `registeredActivateStatus === ACTIVATE_STATUSES.TRIAL`이면 체험 케이스 본문을 사용한다.
|
||||
계정별 학생 수와 가격은 `getAccountTypePlayerCount()`, `getAccountTypePrice()` (`lib/utils.ts`)를 호출한다.
|
||||
|
||||
### 3-4. `lib/extension-requests.ts` 수정
|
||||
|
||||
`approveExtensionRequest`의 반환 타입에 이메일 발송에 필요한 정보를 추가한다.
|
||||
|
||||
`getActionTarget`이 이미 `include: { maestro: true }`를 사용하므로 추가 쿼리 없이 `request.maestro.Email`을 사용할 수 있다.
|
||||
|
||||
**변경 전**
|
||||
```typescript
|
||||
export async function approveExtensionRequest(
|
||||
maestroExtensionID: number
|
||||
): Promise<{ availableActivateDateTime: string }>
|
||||
```
|
||||
|
||||
**변경 후**
|
||||
```typescript
|
||||
export async function approveExtensionRequest(
|
||||
maestroExtensionID: number
|
||||
): Promise<{
|
||||
availableActivateDateTime: string;
|
||||
maestroName: string;
|
||||
maestroEmail: string;
|
||||
}>
|
||||
```
|
||||
|
||||
트랜잭션 내부 마지막에 반환값에 `maestroName`, `maestroEmail` 추가:
|
||||
|
||||
```typescript
|
||||
return {
|
||||
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
||||
maestroName: request.maestro.Name.trim(),
|
||||
maestroEmail: request.maestro.Email.trim(),
|
||||
};
|
||||
```
|
||||
|
||||
### 3-5. `lib/upgrade-requests.ts` 수정
|
||||
|
||||
현재 `getActionTarget`이 `maestro_upgrade`만 조회하고 `maestro`를 포함하지 않는다. include를 추가해야 한다.
|
||||
|
||||
**`getActionTarget` 변경**
|
||||
|
||||
```typescript
|
||||
const request = await tx.maestro_upgrade.findUnique({
|
||||
where: { MaestroUpgradeID: maestroUpgradeID },
|
||||
include: { maestro: { select: { Name: true, Email: true } } }, // 추가
|
||||
});
|
||||
```
|
||||
|
||||
**`approveUpgradeRequest` 반환 타입 변경**
|
||||
|
||||
```typescript
|
||||
export async function approveUpgradeRequest(
|
||||
maestroUpgradeID: number
|
||||
): Promise<{
|
||||
availableActivateDateTime: string;
|
||||
maestroName: string;
|
||||
maestroEmail: string;
|
||||
registeredActivateStatus: number;
|
||||
registeredAccountType: number;
|
||||
requestedAccountType: number;
|
||||
}>
|
||||
```
|
||||
|
||||
트랜잭션 반환값에 추가:
|
||||
|
||||
```typescript
|
||||
return {
|
||||
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
||||
maestroName: request.maestro.Name.trim(),
|
||||
maestroEmail: request.maestro.Email.trim(),
|
||||
registeredActivateStatus: request.RegisteredActivateStatus,
|
||||
registeredAccountType: request.RegisteredAccountType,
|
||||
requestedAccountType: request.RequestedAccountType,
|
||||
};
|
||||
```
|
||||
|
||||
### 3-6. API route 수정
|
||||
|
||||
**`app/api/extension-requests/[id]/route.ts`**
|
||||
|
||||
```typescript
|
||||
if (body.data.action === "approve") {
|
||||
const result = await approveExtensionRequest(maestroExtensionID);
|
||||
logger.info(CTX, "approved", { id: maestroExtensionID, duration: Date.now() - t0 });
|
||||
|
||||
// 메일 발송 (실패해도 응답에 영향 없음)
|
||||
void sendExtensionDoneEmail(
|
||||
result.maestroName,
|
||||
result.maestroEmail,
|
||||
formatDate(result.availableActivateDateTime),
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
|
||||
}
|
||||
```
|
||||
|
||||
**`app/api/upgrade-requests/[id]/route.ts`**
|
||||
|
||||
```typescript
|
||||
if (body.data.action === "approve") {
|
||||
const result = await approveUpgradeRequest(maestroUpgradeID);
|
||||
logger.info(CTX, "approved", { id: maestroUpgradeID, duration: Date.now() - t0 });
|
||||
|
||||
void sendUpgradeDoneEmail(
|
||||
result.maestroName,
|
||||
result.maestroEmail,
|
||||
result.registeredActivateStatus,
|
||||
result.registeredAccountType,
|
||||
result.requestedAccountType,
|
||||
formatDate(result.availableActivateDateTime),
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
|
||||
}
|
||||
```
|
||||
|
||||
`void`를 붙여 메일 발송을 백그라운드로 실행하되, `sendMail` 내부에서 `await`로 실제 발송을 기다린다. 메일 발송 실패는 `logger.error`로만 처리한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 이메일 본문 (참조용)
|
||||
|
||||
### 연장 완료
|
||||
|
||||
**제목**: `[초코마에] {maestroName} - 마에스트로 계정 유효기간 연장 완료`
|
||||
|
||||
```html
|
||||
안녕하세요.<br/>
|
||||
[초코마에] 마에스트로 계정의 유효기간 연장을 신청해 주셔서 감사합니다.<br/>
|
||||
<br/>
|
||||
{maestroName} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.<br/>
|
||||
* 새로운 유효기간 : {newAvailableDate}<br/>
|
||||
<br/>
|
||||
* 상위 요금제로 업그레이드 하시면, 유효기간이 업그레이드한 날짜로부터 1년으로 갱신됩니다.<br/>
|
||||
* 유효기간이 끝나면, 비활성화된 계정과 그 안에 있는 모든 정보가 6개월 후에 자동 삭제됩니다.<br/>
|
||||
<br/>
|
||||
감사합니다.
|
||||
```
|
||||
|
||||
### 업그레이드 완료 — 체험 → 유료
|
||||
|
||||
**제목**: `[초코마에] {maestroName} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
|
||||
|
||||
```html
|
||||
안녕하세요.<br/>
|
||||
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
|
||||
<br/>
|
||||
{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 (0원 : 무료 체험 기간) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
|
||||
<br/>
|
||||
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
|
||||
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
|
||||
<br/>
|
||||
감사합니다.
|
||||
```
|
||||
|
||||
### 업그레이드 완료 — 유료 → 유료
|
||||
|
||||
**제목**: 위와 동일
|
||||
|
||||
```html
|
||||
안녕하세요.<br/>
|
||||
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
|
||||
<br/>
|
||||
{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 ({prevPrice}원) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
|
||||
<br/>
|
||||
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
|
||||
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
|
||||
<br/>
|
||||
감사합니다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 검증 절차
|
||||
|
||||
| 항목 | 방법 | 기대 결과 |
|
||||
|---|---|---|
|
||||
| `MAIL_SEND_ENABLED=false` 상태에서 승인 | 연장/업그레이드 승인 API 호출 | 승인 성공, 메일 발송 없음, `info: send skipped` 로그 출력 |
|
||||
| `MAIL_SEND_ENABLED=true` — 연장 승인 이메일 | 연장 승인 후 수신 메일함 확인 | 제목/본문/수신자/BCC 형식 일치 |
|
||||
| `MAIL_SEND_ENABLED=true` — 업그레이드 승인 이메일 (체험) | 체험 마에스트로 업그레이드 승인 | `0원 : 무료 체험 기간` 문구 포함, 제목/본문 일치 |
|
||||
| `MAIL_SEND_ENABLED=true` — 업그레이드 승인 이메일 (유료) | 유료 마에스트로 업그레이드 승인 | `{prev_price}원` 포함, 제목/본문 일치 |
|
||||
| SMTP 오류 시 응답 | SMTP 연결 실패 환경에서 승인 | 승인 API 200 응답, `error: send failed` 로그 |
|
||||
| 로그 민감정보 확인 | 발송 로그 출력 | 수신자 주소, 제목만 출력. SMTP 비밀번호 미포함 |
|
||||
+60
-75
@@ -432,86 +432,71 @@ chocoadmin/
|
||||
|
||||
> `/maestros/[maestroId]` 상세 화면에서 관리자 계정 정보 수정과 학생 목록 조회를 완성한다.
|
||||
|
||||
- [ ] 기존 `mouse-typing` 마에스트로 정보 수정 로직 조사
|
||||
- [ ] 이름 변경 시 중복 검사 기준 확인 (`maestro.Name`, 자기 자신 제외)
|
||||
- [ ] 이메일 변경 처리와 로그 형식 확인
|
||||
- [ ] 입장코드 수정 대상 컬럼과 기존 값 생성/검증 규칙 확인
|
||||
- [ ] `maestro_log.Type` / `Remark` 형식을 기존 서비스와 맞춤
|
||||
- [ ] 마에스트로 정보 수정 API 구현 (`PATCH /api/maestros/[id]`)
|
||||
- [ ] Zod 요청 스키마 정의: 이름, 이메일, 상태, 계정유형, 사용 가능일, 입장코드
|
||||
- [ ] 관리자 인증 및 권한 검증
|
||||
- [ ] 이름 중복 검사: 다른 계정이 사용 중이면 저장 실패 응답
|
||||
- [ ] 트랜잭션으로 `maestro` 정보 수정
|
||||
- [ ] 변경 전/후 값을 포함해 `maestro_log` 기록
|
||||
- [ ] 변경 없음, 유효하지 않은 상태값, 존재하지 않는 마에스트로에 대한 응답 정책 정리
|
||||
- [ ] 마에스트로 상세 화면 수정 폼 구현
|
||||
- [ ] 이름/이메일 입력
|
||||
- [ ] 상태 콤보박스
|
||||
- [ ] 계정유형 콤보박스
|
||||
- [ ] 사용 가능일 입력
|
||||
- [ ] 입장코드 수정 콤보박스
|
||||
- [ ] 저장 전 확인, 저장 중 비활성화, 성공/실패 토스트
|
||||
- [ ] 학생 목록 API 구현 (`GET /api/maestros/[id]/students`)
|
||||
- [ ] 전체 학생 목록 조회
|
||||
- [ ] 서버 사이드 페이지네이션
|
||||
- [ ] 이름 검색
|
||||
- [ ] 정렬 기준과 기본 page size 정의
|
||||
- [ ] 학생 목록 UI 구현
|
||||
- [ ] 이름 검색 입력
|
||||
- [ ] 페이지네이션 컨트롤
|
||||
- [ ] 빈 목록/검색 결과 없음 상태
|
||||
- [ ] 검증
|
||||
- [ ] 이름 중복 저장 실패 확인
|
||||
- [ ] 정보 수정 성공 시 DB 반영과 `maestro_log` 기록 확인
|
||||
- [ ] 학생 목록 페이지네이션/검색 동작 확인
|
||||
- [ ] 비인증 접근 차단 확인
|
||||
- [x] 기존 `mouse-typing` 마에스트로 정보 수정 로직 조사
|
||||
- [x] 이름 변경 시 중복 검사 기준 확인 (`maestro.Name`, 자기 자신 제외)
|
||||
- [x] 이메일 변경 처리와 로그 형식 확인
|
||||
- [x] 입장코드 수정 대상 컬럼과 기존 값 생성/검증 규칙 확인
|
||||
- [x] `maestro_log.Type` / `Remark` 형식을 기존 서비스와 맞춤
|
||||
- [x] 마에스트로 정보 수정 API 구현 (`PATCH /api/maestros/[id]`)
|
||||
- [x] Zod 요청 스키마 정의: 이름, 이메일, 상태, 계정유형, 사용 가능일, 입장코드
|
||||
- [x] 관리자 인증 및 권한 검증
|
||||
- [x] 이름 중복 검사: 다른 계정이 사용 중이면 저장 실패 응답
|
||||
- [x] 트랜잭션으로 `maestro` 정보 수정
|
||||
- [x] 변경 전/후 값을 포함해 `maestro_log` 기록
|
||||
- [x] 변경 없음, 유효하지 않은 상태값, 존재하지 않는 마에스트로에 대한 응답 정책 정리
|
||||
- [x] 마에스트로 상세 화면 수정 폼 구현
|
||||
- [x] 이름/이메일 입력
|
||||
- [x] 상태 콤보박스
|
||||
- [x] 계정유형 콤보박스
|
||||
- [x] 사용 가능일 입력
|
||||
- [x] 입장코드 수정 콤보박스
|
||||
- [x] 저장 전 확인, 저장 중 비활성화, 성공/실패 토스트
|
||||
- [x] 학생 목록 API 구현 (`GET /api/maestros/[id]/students`)
|
||||
- [x] 전체 학생 목록 조회
|
||||
- [x] 서버 사이드 페이지네이션
|
||||
- [x] 이름 검색
|
||||
- [x] 정렬 기준과 기본 page size 정의
|
||||
- [x] 학생 목록 UI 구현
|
||||
- [x] 이름 검색 입력
|
||||
- [x] 페이지네이션 컨트롤
|
||||
- [x] 빈 목록/검색 결과 없음 상태
|
||||
- [x] 검증
|
||||
- [x] 이름 중복 저장 실패 확인
|
||||
- [x] 정보 수정 성공 시 DB 반영과 `maestro_log` 기록 확인
|
||||
- [x] 학생 목록 페이지네이션/검색 동작 확인
|
||||
- [x] 비인증 접근 차단 확인
|
||||
|
||||
### Phase 11. 이메일 발송 환경 및 자동 발송
|
||||
|
||||
> 기존 `chocomae` 서비스의 관리자 승인 완료 메일을 `chocoadmin`에서도 동일하게 발송한다.
|
||||
> 상세 계획: `docs/phase/phase11.md`
|
||||
|
||||
- [ ] 기존 이메일 발송 설정 이관 준비
|
||||
- [ ] 기존 서비스 파일 확인: `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/mail/send_jinaju_mail.php`
|
||||
- [ ] 기존 서비스 파일 확인: `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/mail/mail_contents.php`
|
||||
- [ ] 기존 서비스 파일 확인: `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/mail/mail_setting.php`
|
||||
- [ ] 발송자 이름/주소, BCC, SMTP host/port/security/auth 설정을 환경변수로 정리
|
||||
- [ ] 기존 코드의 하드코딩된 SMTP/API 키를 신규 서비스 코드에 복사하지 않고 `.env` / 배포 시크릿으로 분리
|
||||
- [ ] 메일 발송 모듈 구현 (`lib/mail.ts`)
|
||||
- [ ] 공통 메일 클라이언트 생성
|
||||
- [ ] 발송자, 수신자, BCC, 제목, HTML/텍스트 본문 구성
|
||||
- [ ] 메일 발송 성공/실패 로그 기록
|
||||
- [ ] 운영에서 민감정보가 로그에 남지 않도록 마스킹
|
||||
- [ ] 연장 완료 이메일 템플릿 구현
|
||||
- [ ] 호출 기준: `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/extension_maestro.php:29`
|
||||
- [ ] 제목: `[초코마에] {마에스트로명} - 마에스트로 계정 유효기간 연장 완료`
|
||||
- [ ] 내용: 유효기간 +1년 연장 완료, 새 유효기간 안내
|
||||
- [ ] 관리자가 연장 승인하고 DB 커밋이 정상 완료된 후 자동 발송
|
||||
- [ ] 업그레이드 완료 이메일 템플릿 구현
|
||||
- [ ] 호출 기준: `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/upgrade_maestro.php:30`
|
||||
- [ ] 제목: `[초코마에] {마에스트로명} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
|
||||
- [ ] 내용: 기존 요금제에서 상위 요금제로 변경 완료, 유효기간 오늘부터 1년
|
||||
- [ ] 관리자가 업그레이드 승인하고 DB 커밋이 정상 완료된 후 자동 발송
|
||||
- [ ] 승인 API에 이메일 자동 발송 연결
|
||||
- [ ] `PATCH /api/extension-requests/[id]` 승인 완료 후 연장 완료 메일 발송
|
||||
- [ ] `PATCH /api/upgrade-requests/[id]` 승인 완료 후 업그레이드 완료 메일 발송
|
||||
- [ ] 메일 발송 실패 시 승인 결과 롤백 여부를 정책으로 확정하고 구현
|
||||
- [ ] 동일 승인 건 재호출 시 이메일 중복 발송 방지 정책 정리
|
||||
- [ ] 환경변수 추가
|
||||
- [ ] `MAIL_FROM_NAME`
|
||||
- [ ] `MAIL_FROM_ADDRESS`
|
||||
- [ ] `MAIL_BCC_ADDRESS`
|
||||
- [ ] `MAIL_SMTP_HOST`
|
||||
- [ ] `MAIL_SMTP_PORT`
|
||||
- [ ] `MAIL_SMTP_SECURE`
|
||||
- [ ] `MAIL_SMTP_USER`
|
||||
- [ ] `MAIL_SMTP_PASSWORD`
|
||||
- [ ] `MAIL_SEND_ENABLED`
|
||||
- [ ] 문서화 및 검증
|
||||
- [ ] `.env.example` 또는 배포 문서에 메일 환경변수 설명 추가
|
||||
- [ ] local/stage에서 테스트 수신자 또는 sandbox 설정으로 발송 테스트
|
||||
- [ ] 연장 승인 완료 후 이메일 제목/본문/수신자/BCC 확인
|
||||
- [ ] 업그레이드 승인 완료 후 이메일 제목/본문/수신자/BCC 확인
|
||||
- [ ] 메일 발송 실패 로그와 관리자 화면 응답 확인
|
||||
- [x] 기존 이메일 발송 설정 이관 준비
|
||||
- [x] 기존 서비스 파일 확인: `send_jinaju_mail.php` — SMTP gmail:465, SMTPS, 발송자/BCC 주소 확인
|
||||
- [x] 기존 서비스 파일 확인: `mail_contents.php` — 연장/업그레이드 완료 이메일 제목·본문 확인
|
||||
- [x] 기존 서비스 파일 확인: `mail_setting.php` — From, BCC, sender_name 확인
|
||||
- [x] `.env.local`에 메일 환경변수 추가 (SMTP 비밀번호는 배포 시크릿으로 분리)
|
||||
- [x] `nodemailer` 패키지 설치 (`pnpm add nodemailer && pnpm add -D @types/nodemailer`)
|
||||
- [x] 메일 발송 모듈 구현 (`lib/mail.ts`)
|
||||
- [x] `createTransporter()` — 환경변수 기반 SMTP transport
|
||||
- [x] `sendMail()` — 공통 발송 함수 (`MAIL_SEND_ENABLED` 체크, From/BCC/ReplyTo 구성, 성공·실패 로그)
|
||||
- [x] `sendExtensionDoneEmail()` — 연장 완료 이메일 템플릿
|
||||
- [x] `sendUpgradeDoneEmail()` — 업그레이드 완료 이메일 템플릿 (체험→유료 / 유료→유료 내부 분기)
|
||||
- [x] `lib/extension-requests.ts` 수정
|
||||
- [x] `approveExtensionRequest` 반환값에 `maestroName`, `maestroEmail` 추가 (이미 `include: { maestro: true }` 있음 — 추가 쿼리 불필요)
|
||||
- [x] `lib/upgrade-requests.ts` 수정
|
||||
- [x] `getActionTarget`에 `include: { maestro: { select: { Name, Email } } }` 추가
|
||||
- [x] `approveUpgradeRequest` 반환값에 `maestroName`, `maestroEmail`, `registeredActivateStatus`, `registeredAccountType`, `requestedAccountType` 추가
|
||||
- [x] 승인 API에 이메일 자동 발송 연결
|
||||
- [x] `app/api/extension-requests/[id]/route.ts` — 승인 후 `void sendExtensionDoneEmail(...)` 호출
|
||||
- [x] `app/api/upgrade-requests/[id]/route.ts` — 승인 후 `void sendUpgradeDoneEmail(...)` 호출
|
||||
- [x] 메일 발송 실패 정책 적용: 실패 시 `logger.error`만 기록, 승인 결과(200)는 그대로 반환 (롤백 없음)
|
||||
- [x] 검증
|
||||
- [x] `MAIL_SEND_ENABLED=false`에서 승인 시 `send skipped` 로그 확인, 승인 결과 200 정상
|
||||
- [x] `MAIL_SEND_ENABLED=true`에서 연장 승인 후 이메일 제목/본문/수신자/BCC 확인
|
||||
- [ ] 업그레이드 승인 후 체험→유료 케이스: `0원 : 무료 체험 기간` 본문 확인
|
||||
- [ ] 업그레이드 승인 후 유료→유료 케이스: `{prev_price}원` 본문 확인
|
||||
- [x] SMTP 오류 시 승인 API 200 응답 + `error: send failed` 로그 확인
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -108,7 +108,11 @@ export async function getExtensionRequests(
|
||||
|
||||
export async function approveExtensionRequest(
|
||||
maestroExtensionID: number
|
||||
): Promise<{ availableActivateDateTime: string }> {
|
||||
): Promise<{
|
||||
availableActivateDateTime: string;
|
||||
maestroName: string;
|
||||
maestroEmail: string;
|
||||
}> {
|
||||
return db.$transaction(async (tx) => {
|
||||
const request = await getActionTarget(tx, maestroExtensionID);
|
||||
const availableActivateDateTime = calculateExtendedAvailableDate(
|
||||
@@ -147,6 +151,8 @@ export async function approveExtensionRequest(
|
||||
|
||||
return {
|
||||
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
||||
maestroName: request.maestro.Name.trim(),
|
||||
maestroEmail: request.maestro.Email.trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export type MailConfig = {
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUser: string;
|
||||
smtpPassword: string;
|
||||
fromName: string;
|
||||
fromAddress: string;
|
||||
bccAddress: string;
|
||||
sendEnabled: boolean;
|
||||
/** 설정 시 모든 수신인을 이 주소로 고정 (local/stage 테스트용) */
|
||||
overrideTo: string | undefined;
|
||||
};
|
||||
|
||||
export function getMailConfig(): MailConfig {
|
||||
return {
|
||||
smtpHost: process.env.MAIL_SMTP_HOST ?? "smtp.gmail.com",
|
||||
smtpPort: Number(process.env.MAIL_SMTP_PORT ?? "465"),
|
||||
smtpSecure: process.env.MAIL_SMTP_SECURE !== "false",
|
||||
smtpUser: process.env.MAIL_SMTP_USER ?? "",
|
||||
smtpPassword: process.env.MAIL_SMTP_PASSWORD ?? "",
|
||||
fromName: process.env.MAIL_FROM_NAME ?? "초코마에",
|
||||
fromAddress: process.env.MAIL_FROM_ADDRESS ?? "",
|
||||
bccAddress: process.env.MAIL_BCC_ADDRESS ?? "",
|
||||
sendEnabled: process.env.MAIL_SEND_ENABLED === "true",
|
||||
overrideTo: process.env.MAIL_OVERRIDE_TO || undefined,
|
||||
};
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { ACTIVATE_STATUSES } from "@/lib/constants";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { getMailConfig } from "@/lib/mail-config";
|
||||
import {
|
||||
getAccountTypePlayerCount,
|
||||
getAccountTypePrice,
|
||||
} from "@/lib/utils";
|
||||
|
||||
const CTX = "mail";
|
||||
|
||||
function createTransporter() {
|
||||
const cfg = getMailConfig();
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: cfg.smtpHost,
|
||||
port: cfg.smtpPort,
|
||||
secure: cfg.smtpSecure,
|
||||
auth: {
|
||||
user: cfg.smtpUser,
|
||||
pass: cfg.smtpPassword,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type SendMailOptions = {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
const isDetailedLog = () => process.env.APP_ENV !== "production";
|
||||
|
||||
async function sendMail(options: SendMailOptions): Promise<boolean> {
|
||||
const cfg = getMailConfig();
|
||||
const detailed = isDetailedLog();
|
||||
|
||||
if (!cfg.sendEnabled) {
|
||||
logger.info(CTX, "send skipped (MAIL_SEND_ENABLED=false)", {
|
||||
to: cfg.overrideTo ?? options.to,
|
||||
subject: options.subject,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const to = cfg.overrideTo ?? options.to;
|
||||
const bcc = cfg.bccAddress || undefined;
|
||||
|
||||
if (detailed) {
|
||||
logger.debug(CTX, "sending", {
|
||||
to,
|
||||
...(bcc && { bcc }),
|
||||
subject: options.subject,
|
||||
smtp: `${cfg.smtpUser}@${cfg.smtpHost}:${cfg.smtpPort}`,
|
||||
...(cfg.overrideTo && { overrideTo: cfg.overrideTo }),
|
||||
});
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const transporter = createTransporter();
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${cfg.fromName}" <${cfg.fromAddress}>`,
|
||||
replyTo: cfg.fromAddress,
|
||||
to,
|
||||
bcc,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
});
|
||||
const duration = Date.now() - t0;
|
||||
|
||||
if (detailed) {
|
||||
logger.info(CTX, "send ok", {
|
||||
to,
|
||||
subject: options.subject,
|
||||
messageId: info.messageId,
|
||||
duration,
|
||||
});
|
||||
} else {
|
||||
logger.info(CTX, "send ok", { duration });
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
const duration = Date.now() - t0;
|
||||
|
||||
if (detailed) {
|
||||
logger.error(CTX, "send failed", {
|
||||
to,
|
||||
subject: options.subject,
|
||||
duration,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
...(err instanceof Error && err.stack && { stack: err.stack }),
|
||||
});
|
||||
} else {
|
||||
logger.error(CTX, "send failed", { duration });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendExtensionDoneEmail(
|
||||
maestroName: string,
|
||||
maestroEmail: string,
|
||||
newAvailableDate: string
|
||||
): Promise<boolean> {
|
||||
const subject = `[초코마에] ${maestroName} - 마에스트로 계정 유효기간 연장 완료`;
|
||||
const html = `안녕하세요.<br/>
|
||||
[초코마에] 마에스트로 계정의 유효기간 연장을 신청해 주셔서 감사합니다.<br/>
|
||||
<br/>
|
||||
${maestroName} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.<br/>
|
||||
* 새로운 유효기간 : ${newAvailableDate}<br/>
|
||||
<br/>
|
||||
* 상위 요금제로 업그레이드 하시면, 유효기간이 업그레이드한 날짜로부터 1년으로 갱신됩니다.<br/>
|
||||
* 유효기간이 끝나면, 비활성화된 계정과 그 안에 있는 모든 정보가 6개월 후에 자동 삭제됩니다.<br/>
|
||||
<br/>
|
||||
감사합니다.`;
|
||||
|
||||
return sendMail({ to: maestroEmail, subject, html });
|
||||
}
|
||||
|
||||
export async function sendUpgradeDoneEmail(
|
||||
maestroName: string,
|
||||
maestroEmail: string,
|
||||
registeredActivateStatus: number,
|
||||
registeredAccountType: number,
|
||||
requestedAccountType: number,
|
||||
newAvailableDate: string
|
||||
): Promise<boolean> {
|
||||
const subject = `[초코마에] ${maestroName} - 마에스트로 계정의 요금제 업그레이드 완료 알림`;
|
||||
|
||||
const prevStudentCount = getAccountTypePlayerCount(registeredAccountType);
|
||||
const upgradeStudentCount = getAccountTypePlayerCount(requestedAccountType);
|
||||
const upgradePrice = getAccountTypePrice(requestedAccountType).toLocaleString("ko-KR");
|
||||
|
||||
let upgradeFromText: string;
|
||||
if (registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) {
|
||||
upgradeFromText = `${prevStudentCount}명 (0원 : 무료 체험 기간)`;
|
||||
} else {
|
||||
const prevPrice = getAccountTypePrice(registeredAccountType).toLocaleString("ko-KR");
|
||||
upgradeFromText = `${prevStudentCount}명 (${prevPrice}원)`;
|
||||
}
|
||||
|
||||
const html = `안녕하세요.<br/>
|
||||
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
|
||||
<br/>
|
||||
${maestroName} - 마에스트로 계정의 요금제가 [ ${upgradeFromText} ] 에서 [ ${upgradeStudentCount}명 (${upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
|
||||
<br/>
|
||||
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
|
||||
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
|
||||
<br/>
|
||||
감사합니다.`;
|
||||
|
||||
return sendMail({ to: maestroEmail, subject, html });
|
||||
}
|
||||
+14
-1
@@ -112,7 +112,14 @@ export async function getUpgradeRequests(
|
||||
|
||||
export async function approveUpgradeRequest(
|
||||
maestroUpgradeID: number
|
||||
): Promise<{ availableActivateDateTime: string }> {
|
||||
): Promise<{
|
||||
availableActivateDateTime: string;
|
||||
maestroName: string;
|
||||
maestroEmail: string;
|
||||
registeredActivateStatus: number;
|
||||
registeredAccountType: number;
|
||||
requestedAccountType: number;
|
||||
}> {
|
||||
return db.$transaction(async (tx) => {
|
||||
const request = await getActionTarget(tx, maestroUpgradeID);
|
||||
const availableActivateDateTime = calculateUpgradeAvailableDate();
|
||||
@@ -151,6 +158,11 @@ export async function approveUpgradeRequest(
|
||||
|
||||
return {
|
||||
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
||||
maestroName: request.maestro.Name.trim(),
|
||||
maestroEmail: request.maestro.Email.trim(),
|
||||
registeredActivateStatus: request.RegisteredActivateStatus,
|
||||
registeredAccountType: request.RegisteredAccountType,
|
||||
requestedAccountType: request.RequestedAccountType,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -194,6 +206,7 @@ async function getActionTarget(
|
||||
) {
|
||||
const request = await tx.maestro_upgrade.findUnique({
|
||||
where: { MaestroUpgradeID: maestroUpgradeID },
|
||||
include: { maestro: { select: { Name: true, Email: true } } },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"mariadb": "^3.5.2",
|
||||
"next": "16.2.7",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
"nodemailer": "^9.0.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.11.0",
|
||||
@@ -34,6 +35,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.4.2",
|
||||
|
||||
Generated
+27
-4
@@ -37,7 +37,10 @@ importers:
|
||||
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.31
|
||||
version: 5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||
version: 5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@9.0.1)(react@19.2.4)
|
||||
nodemailer:
|
||||
specifier: ^9.0.1
|
||||
version: 9.0.1
|
||||
react:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4
|
||||
@@ -66,6 +69,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^20
|
||||
version: 20.19.42
|
||||
'@types/nodemailer':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
'@types/react':
|
||||
specifier: ^19
|
||||
version: 19.2.17
|
||||
@@ -1105,6 +1111,9 @@ packages:
|
||||
'@types/node@24.13.1':
|
||||
resolution: {integrity: sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==}
|
||||
|
||||
'@types/nodemailer@8.0.1':
|
||||
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
@@ -2748,6 +2757,10 @@ packages:
|
||||
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
nodemailer@9.0.1:
|
||||
resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3510,13 +3523,15 @@ snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@auth/core@0.41.2':
|
||||
'@auth/core@0.41.2(nodemailer@9.0.1)':
|
||||
dependencies:
|
||||
'@panva/hkdf': 1.2.1
|
||||
jose: 6.2.3
|
||||
oauth4webapi: 3.8.6
|
||||
preact: 10.24.3
|
||||
preact-render-to-string: 6.5.11(preact@10.24.3)
|
||||
optionalDependencies:
|
||||
nodemailer: 9.0.1
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
dependencies:
|
||||
@@ -4407,6 +4422,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/nodemailer@8.0.1':
|
||||
dependencies:
|
||||
'@types/node': 20.19.42
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.17
|
||||
@@ -6089,11 +6108,13 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
next-auth@5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4):
|
||||
next-auth@5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@9.0.1)(react@19.2.4):
|
||||
dependencies:
|
||||
'@auth/core': 0.41.2
|
||||
'@auth/core': 0.41.2(nodemailer@9.0.1)
|
||||
next: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
nodemailer: 9.0.1
|
||||
|
||||
next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
@@ -6136,6 +6157,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.47: {}
|
||||
|
||||
nodemailer@9.0.1: {}
|
||||
|
||||
npm-run-path@4.0.1:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
||||
Reference in New Issue
Block a user