Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_STORAGE_BUCKET_RESULT_CARDS=
SUPABASE_STORAGE_BUCKET_PROFILE_IMAGES=

# Gemini
GEMINI_API_KEY=
Expand Down
76 changes: 58 additions & 18 deletions docs/API_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ YYYY-MM-DDTHH:mm:ssZ
| `GET` | `/api/rooms` | 방 목록 조회 | 🔒 |
| `GET` | `/api/rooms/{roomId}` | 방 상세 조회 | 🔒 |
| `POST` | `/api/rooms/{roomId}/invite` | 초대 링크 발급 | 🔒 |
| `POST` | `/api/rooms/{roomId}/join` | 초대 링크로 참여 | 🔒 |
| `GET` | `/api/rooms/join/{token}` | 초대 토큰으로 방 정보 조회 | 🔒 |
| `POST` | `/api/rooms/join/{token}` | 초대 토큰으로 참여 확정 | 🔒 |
| `POST` | `/api/rooms/{roomId}/close` | 방 종료 | 🔒 |
| `DELETE` | `/api/rooms/{roomId}` | 방 삭제 | 🔒 |

Expand Down Expand Up @@ -576,22 +577,56 @@ YYYY-MM-DDTHH:mm:ssZ
- `room_token` 원문 DB 저장 금지, `room_token_hash`만 저장 (CLAUDE.md §7)
- `room_mode` → `invite_ready` 전환
- **로그 정책:** `AuditLog` — `INVITE_LINK_CREATED` 기록
- **확정 필요:** 초대 링크 만료 시간 정책
- **만료 정책:** 초대 링크 만료 시간 72시간 (PR #69 구현 기준)

---

#### `POST /api/rooms/{roomId}/join`
#### `GET /api/rooms/join/{token}`

- **인증:** 🔒
- **설명:** 초대 토큰 검증 후 상대방이 방에 참여한다. 성공 시 `room_mode` → `one_to_one`, `Dispute` 생성.
- **Request Body:**
- **설명:** 초대 토큰으로 방 정보를 조회한다. 참여 확인 화면에서 초대자 닉네임, 카테고리 등을 표시하기 위해 사용한다.
- **Path Params:** `token` (UUID 형식 초대 토큰)
- **Request Body:** 없음
- **Response 200:**

```json
{
"token": "string"
"success": true,
"data": {
"roomId": "uuid",
"categoryGroup": "string",
"roomMode": "string",
"expiresAt": "ISO8601 | null",
"inviterNickname": "string | null"
},
"error": null
}
```

- **Error Code:**

| 코드 | HTTP | 설명 |
|------|------|------|
| `UNAUTHORIZED` | 401 | 비인증 사용자 |
| `FORBIDDEN` | 403 | 생성자 본인의 조회 시도 |
| `NOT_FOUND` | 404 | 유효하지 않은 토큰 |
| `INVALID_STATUS` | 422 | 만료·삭제·종료된 방 또는 invite_ready 아닌 상태 |

- **처리 정책:**
- `room_token_hash` 검증
- 만료된 초대 링크 차단 (만료 시 `room_mode` → `expired` 자동 전환)
- 생성자 본인 조회 차단
- 삭제/종료/만료 상태 방 차단
- **로그 정책:** 실패 시 `RoomAccessLog(DENIED)` 기록

---

#### `POST /api/rooms/join/{token}`

- **인증:** 🔒
- **설명:** 초대 토큰 검증 후 상대방이 방에 참여한다. 성공 시 `room_mode` → `one_to_one`, 생성자 `role_a` 확정, 참여자 `role_b` 확정.
- **Path Params:** `token` (UUID 형식 초대 토큰)
- **Request Body:** 없음
- **Response 200:**

```json
Expand All @@ -610,19 +645,21 @@ YYYY-MM-DDTHH:mm:ssZ

| 코드 | HTTP | 설명 |
|------|------|------|
| `UNAUTHORIZED` | 401 | 비인증 사용자 |
| `FORBIDDEN` | 403 | 생성자 본인의 참여 시도 |
| `CONFLICT` | 409 | role_b 이미 존재 |
| `NOT_FOUND` | 404 | 유효하지 않은 토큰 |
| `INVALID_STATUS` | 422 | 만료 또는 삭제된 방 |
| `INVALID_STATUS` | 422 | 만료·삭제·종료된 방 또는 invite_ready 아닌 상태 |

- **처리 정책:**
- `room_token_hash` 검증
- 만료된 초대 링크 차단
- 만료된 초대 링크 차단 (만료 시 `room_mode` → `expired` 자동 전환)
- 생성자 본인 참여 차단
- role_b 중복 참여 차단
- 멱등성 처리 필요 (CLAUDE.md §8)
- 참여 성공 시 `room_mode` → `one_to_one`, 생성자 `role_a` 확정, 참여자 `role_b` 확정
- **로그 정책:** `AuditLog` — `USER_JOINED_ROOM`, `RoomAccessLog` 기록
- dispute 미존재 시 트랜잭션 내 자동 생성
- **로그 정책:** `AuditLog` — `USER_JOINED_ROOM`, `RoomAccessLog` — 성공 시 `ALLOWED`, 실패 시 `DENIED` 기록

---

Expand Down Expand Up @@ -1252,14 +1289,15 @@ src/app/api/
│ └── route.ts # GET, PATCH, DELETE(회원탈퇴)
├── rooms/
│ ├── route.ts # GET 목록, POST 생성
│ └── [roomId]/
│ ├── route.ts # GET 상세, DELETE
│ ├── invite/
│ │ └── route.ts # POST
│ ├── join/
│ │ └── route.ts # POST
│ └── close/
│ └── route.ts # POST
│ ├── [roomId]/
│ │ ├── route.ts # GET 상세, DELETE
│ │ ├── invite/
│ │ │ └── route.ts # POST
│ │ └── close/
│ │ └── route.ts # POST
│ └── join/
│ └── [token]/
│ └── route.ts # GET 방 정보 조회, POST 참여 확정
├── disputes/
│ ├── route.ts # POST 생성
│ └── [disputeId]/
Expand Down Expand Up @@ -1316,6 +1354,7 @@ src/app/api/
| 항목 | 문서 A | 문서 B | 상태 |
|------|--------|--------|------|
| 회원탈퇴 경로 | `POST /api/auth/withdraw` (AUTH.md) | `DELETE /api/users/me` (USER.md) | **구현 필수: `DELETE /api/users/me` 채택** |
| 초대 참여 경로 | `/api/rooms/{roomId}/join` (초기 명세) | `/api/rooms/join/{token}` (PR #69 구현) | **확정: `/api/rooms/join/{token}` 채택 — 상대방이 roomId를 알 수 없으므로 토큰 기반 경로로 변경** |
| 판결 요청 경로 | `/judge` (JUDGEMENT.md, 실제 라우트 파일) | `/judgement` (검토 메모) | **확정 필요** |
| 판결 결과 조회 경로 | `/result` (JUDGEMENT.md, 실제 라우트 파일) | `/judgement` (검토 메모) | **확정 필요** |
| 일기 API 경로 | `/api/diary` (스캐폴딩) | `/api/diaries` (DIARY.md) | **확정 필요** |
Expand Down Expand Up @@ -1344,7 +1383,8 @@ src/app/api/
- [ ] 탈퇴 계정 응답 처리 기준 (404 vs 403)

### Room
- [ ] 초대 링크 만료 시간 정책
- [x] 초대 링크 만료 시간 정책: 72시간 (PR #69 구현 기준)
- [x] 초대 참여 경로: `/api/rooms/join/{token}` 채택 (PR #69 — 상대방이 roomId를 알 수 없으므로 토큰 기반 경로로 변경)
- [x] 방 목록 Pagination 구조: 공통 Pagination 구조 적용
- [ ] 방 종료/삭제 가능 조건 및 권한 기준

Expand Down
94 changes: 94 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@mui/material": "^9.1.1",
"@mui/x-date-pickers": "^9.5.0",
"@prisma/client": "^6.0.0",
"@supabase/supabase-js": "^2.108.2",
"@tanstack/react-query": "^5.62.0",
"dayjs": "^1.11.21",
"html-to-image": "^1.11.11",
Expand Down
62 changes: 62 additions & 0 deletions scripts/backfill-user-terms-agreements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* 기존 유저 약관 동의 데이터 backfill 스크립트
*
* 실행: npx tsx scripts/backfill-user-terms-agreements.ts
*
* users.terms_agreed_at이 존재하지만 user_terms_agreements 레코드가 없는 유저 대상으로
* service, privacy 약관 동의 레코드를 일괄 생성합니다.
*/
import { PrismaClient, TermsType } from '@prisma/client'

const prisma = new PrismaClient()

const INITIAL_TERMS_VERSION = '2026-06-18'

async function main() {
const usersWithoutAgreements = await prisma.user.findMany({
where: {
termsAgreedAt: { not: null },
termsAgreements: { none: {} },
},
select: { id: true, termsAgreedAt: true },
})

console.log(`backfill 대상 유저 수: ${usersWithoutAgreements.length}`)

if (usersWithoutAgreements.length === 0) {
console.log('backfill 대상 없음. 종료.')
return
}

const records = usersWithoutAgreements.flatMap((user) => [
{
userId: user.id,
termsType: TermsType.SERVICE,
termsVersion: INITIAL_TERMS_VERSION,
isRequired: true,
agreedAt: user.termsAgreedAt!,
},
{
userId: user.id,
termsType: TermsType.PRIVACY,
termsVersion: INITIAL_TERMS_VERSION,
isRequired: true,
agreedAt: user.termsAgreedAt!,
},
])

const result = await prisma.userTermsAgreement.createMany({
data: records,
skipDuplicates: true,
})

console.log(`생성된 레코드 수: ${result.count}`)
console.log('backfill 완료.')
}

main()
.catch((e) => {
console.error('backfill 실패:', e)
process.exit(1)
})
.finally(() => prisma.$disconnect())
66 changes: 66 additions & 0 deletions src/app/(page)/mypage/edit/page.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
@use '@/styles/abstracts/variables' as v;
@use '@/styles/abstracts/functions' as fn;
@use '@/styles/abstracts/mixins' as m;

.main {
display: flex;
flex-direction: column;
min-height: calc(100dvh - fn.r(56));
padding: fn.r(24) fn.r(20);
padding-bottom: fn.r(100);
}

.avatar {
display: flex;
flex-direction: column;
align-items: center;
gap: fn.r(12);
margin-bottom: fn.r(32);

&__button {
display: flex;
align-items: center;
gap: fn.r(4);
padding: fn.r(6) fn.r(12);
background: none;
border: 1px solid var(--border-default);
border-radius: fn.r(20);
cursor: pointer;
@include m.text-caption;

color: var(--text-secondary);

&:active {
background-color: var(--bg-secondary);
}
}
}

.form {
display: flex;
flex-direction: column;
gap: fn.r(20);
flex: 1;
}

.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: fn.r(16) fn.r(20);
background-color: var(--bg-base);
}

.errorMessage {
@include m.text-body2;

color: var(--text-danger);
}

.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100dvh - fn.r(56));
}
Loading