55 Commits

Author SHA1 Message Date
ttipo 5458c449f8 docs(rules): 리뷰 반영에 맞춰 보안·환경변수·에러 코드·백엔드 스킬 갱신
- 코드 조회 한도는 회원 단위 한 바구니, 일정 코드는 한 번 쓰면 교체, 상세히 명함 공유 7일
- 개발용 로그인·서명 키 allowlist(development·test), 그 밖은 JWT_SECRET 필수
- CARD_004/005, 스로틀 가드 순서·db-lock·탈퇴 정리·파일 정리 위치

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:21:04 +09:00
ttipo aba3d801fc feat(flutter): 함께 관리 화면을 보는 동안 내 초대 코드를 새로 읽기
일정 연동 코드는 한 번 쓰이면 서버가 바꾸므로, 함께 관리 화면을 열어 둔 동안 15초마다(들어올 때 한 번) 다시 읽어
보여 주는 코드·QR 이 낡지 않게 한다. 안내에 "초대 코드는 한 번 쓰면 새 코드로 바뀌어요."를 더한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:21:04 +09:00
ttipo 02c7ec0079 fix(backend): 리뷰 반영 — 코드 남용 막기·인증 기본 닫힘·한도 동시성·파일 정리
브랜치 리뷰(main..14e2bdd)에서 나온 서버 쪽 문제를 고친다.
- 일정 연동 코드는 한 번 연동에 쓰이면(코드 주인), 나가면(나간 사람) 바꾼다 — 예전에 받은 코드로 다시 들어올 수 없다
- 코드 조회·참여 7개 엔드포인트가 회원 단위 한 바구니(분당 30회)를 같이 쓴다 (@InviteCodeLookup, 인증 뒤에 세는 가드)
- 펫 명함 상세히 공유는 마지막으로 공유한 뒤 7일만 — 그 뒤에는 간략히로 보여 주고 저장한다
- 개발용 로그인·개발용 서명 키는 NODE_ENV 가 development·test 일 때만 (그 밖은 JWT_SECRET 필수, 기동 차단)
- 다이어리 6권·명함 10장·받은 명함 한도를 회원별 advisory lock 으로 세어 동시 요청에 뚫리지 않게 한다
- 받은 명함: 같은 명함 중복(CARD_004)·보관함 100장(CARD_005), 사본 복사 실패 시 만든 사본 정리
- 탈퇴하면 내 다이어리의 다른 멤버 사진도 정리하고, 24시간 넘게 쓰이지 않는 파일을 매시간 지운다
- 대상 id 소문자 저장, GET /calendar-sharing 은 쓰지 않음(코드는 가입 때 만들고 기존 회원은 채움), 파일·작성자 FK 인덱스

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:21:04 +09:00
ttipo 043e14a3fa fix(flutter): 토큰 갱신 429 로 로그아웃되지 않게, 갱신 뒤 사진 다시 보내기
리뷰에서 나온 앱 쪽 문제를 고친다.
- 토큰 갱신이 429 를 받으면 세션을 끊던 문제 — 거절(401·400)만 세션을 끊고, 429 는 REQ_001·그 밖은 SYS_001 로 알린다
  (서버는 갱신을 IP 당 분당 10회로 막아 통신사 망처럼 여럿이 한 IP 를 쓰면 실제로 생긴다)
- 액세스 토큰이 만료된 뒤 사진을 올리면 다시 보낼 때 FormData 가 이미 쓰여 실패하던 문제 — 복사본으로 다시 보낸다
- 에러 사전 계약 테스트에 앱이 직접 쓰는 도메인 코드(FILE·DIARY·CARD·SCHED)를 더한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:57:34 +09:00
ttipo 3f84d7b747 feat(flutter): 앱 링크 — 기기 카메라로 찍은 QR 로 바로 받기·참여·연동
QR 을 앱 스캐너가 아니라 기기 카메라로 찍어도 앱이 열려 이어지게 한다.
- rup://app/<경로> 링크 (Android intent-filter · iOS URL scheme) — Android 는 호스트를 버리고 경로만 넘기므로 경로로 구분한다
- 명함 → 저장 확인 화면, 다이어리 초대 → 참여 확인, 일정 초대 → 연동 확인 (끝나면 그 기능의 탭으로)
- QR 에 담는 주소를 새 형식으로 바꾸고, 예전 형식(rup://pet-card/…)도 계속 읽는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:50:19 +09:00
ttipo b10e90fe5c feat(flutter): 함께 쓰는 기록을 앱을 보는 동안 다시 읽기
다른 멤버가 남긴 글·일정과 내 코드로 새로 연동한 사람이 앱을 다시 켜지 않아도 보이게 한다.
실시간 알림이 붙기 전까지의 대신이다.
- 앱으로 돌아올 때와 앱을 보는 동안 3분마다 공유 다이어리·연동 상태(연동 중이면 일정도)를 다시 읽는다
- 뒤에서 읽을 때는 실패해도 알림을 띄우지 않는다 (목록 읽기·저장소 fetch 에 quiet)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:50:19 +09:00
ttipo 14e2bddb96 docs(rules): 공유 기능에 맞춰 에러 코드·보안·스킬 문서 갱신
- 도메인 에러 코드 표에 명함 받기·공유 다이어리·일정 연동 코드를 더한다
- 파일 내려받기는 본인 + 열람 정책, 초대 코드 강도·rate limit·코드 교체 규칙
- 백엔드 스킬: 파일 열람 정책·초대 코드 공통 유틸 / 앱 스킬: server-url 사진·공용 QR 스캐너·함께 쓰는 기록 패턴

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:31:58 +09:00
ttipo 61cfc4461f feat(flutter): 일정 함께 관리 — 초대 코드로 달력 연동
가족·친구와 같은 달력을 함께 쓰는 화면을 연다.
- 함께 관리: 일정 연동 켜기·끄기, 연동된 사람, 내 초대 QR(앨범 저장)·코드 복사, 코드로 연동, 연동 해제 확인
- 코드 입력 시트에서 초대 QR 스캔으로도 연동할 수 있다 (공용 QR 스캐너)
- 연동 중이면 일정마다 만든 사람을 보여 주고, 남의 일정은 그 사람의 아이 이름·사진으로 그린다
- 남이 만든 일정은 이름·분류·시간·장소·색만 고친다 (대상·반복·기간은 만든 사람이 바꾼다)
- 연동·해제하면 일정을 다시 읽는다
- 기기 저장 모드에서는 함께 관리를 쓸 수 없다고 안내한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:31:58 +09:00
ttipo b6c5496a05 feat(backend): 일정 함께 관리 API — 초대 코드로 달력 연동
가족·친구가 한 달력을 함께 쓸 수 있게 한다.
- calendar_groups · calendar_group_members (한 사람 한 그룹, 최대 4명), 회원마다 개인 초대 코드(RUP-6자)
- 연동 상태·초대 미리보기·코드로 연동·연동 해제 — 해제하면 내 일정만 남고, 1명 남은 그룹은 지운다
- 일정·반복·완료는 그룹 전체가 보고 고칠 수 있다. 응답에 만든 사람·대상 요약을 담고,
  남의 일정을 고칠 때 대상은 그 사람의 아이로 확인한다
- 연동·해제는 advisory lock 으로 줄 세워 인원 초과·중복 그룹을 막는다
- 파일 열람 정책 — 연동된 사람의 프로필·아이 사진
- 초대 코드 생성기·고유 충돌 재시도·대상 요약을 공통으로 옮겨 다이어리와 함께 쓴다
- 에러 코드 SCHED_001~005

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:31:57 +09:00
ttipo f50ab85a75 feat(flutter): 공유 다이어리 — 초대·참여·멤버와 함께 쓰기
다이어리를 가족·지인과 함께 쓰는 화면을 연다.
- 다이어리 설정: 개인↔공유 전환(멤버가 있으면 확인), 멤버 목록·내보내기, 초대 QR·코드 복사, 다이어리 나가기
- 참여: 코드 입력 시트 · 초대 QR 스캐너 · 참여 확인 (명함 스캐너를 공용 QR 스캐너로 바꿔 함께 쓴다)
- 만들 때 공유 다이어리를 고를 수 있다
- 다른 멤버의 기록·댓글은 그 사람 이름·사진으로, 수정·삭제는 내 것만
- 참여한 다이어리 표지는 서버가 준 주인의 아이들로 그린다
- 다른 멤버의 새 글·내 기록의 댓글을 알림으로 보여 주고, 공유 다이어리에 들어오면 다시 읽는다
- 알림 설정은 멤버마다 따로 저장한다
- 기기 저장 모드에서는 공유 다이어리를 쓸 수 없다고 안내한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:50:13 +09:00
ttipo 688e72bf04 feat(backend): 공유 다이어리 API — 초대 코드로 함께 쓰기
다이어리를 가족·지인과 함께 쓸 수 있게 한다.
- diary_members 로 함께 쓰는 사람을 두고(주인 포함 최대 4명), 알림 설정을 사람별로 옮긴다
- 초대 코드(RUP-6자) 미리보기·참여, 개인↔공유 전환, 내보내기·나가기 — 내보내거나 나가면 코드를 바꾼다
- 목록·쓰기는 멤버, 수정·삭제는 쓴 사람, 다이어리 정보·삭제는 주인만
- 응답에 멤버·대상 요약·작성자를 담는다
- 파일 열람 정책 — 함께 쓰는 다이어리의 표지·기록 사진·아이 사진·멤버 프로필 사진은 멤버도 받을 수 있다
- 에러 코드 DIARY_003(코드 없음)·004(가득 참)·005(이미 참여)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:34:18 +09:00
ttipo 7701be3795 feat(flutter): 명함 받기 — QR·코드로 받아 보관함에 저장
다른 회원이 공유한 펫 명함을 받아 보관할 수 있게 한다.
- 스캐너(mobile_scanner) 또는 명함 코드 입력으로 명함을 찾고, 저장 전 확인 화면에서 메모를 남긴다
- 보관함 목록·검색·상세(보낸 사람이 공유한 모양 그대로)·삭제
- 공유 시트를 열 때 지금 보이는 모양(간략히·상세히)을 서버에 알린다
- 받기 전 사진은 명함 코드 경로(server-url:)로, 받은 뒤에는 내 소유 사본(server-file:)으로 그린다
- 기기 저장 모드에서는 받을 명함이 없으므로 안내만 띄운다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:55:39 +09:00
ttipo f045e1599f feat(backend): 명함 받기 API — 코드로 미리보기·보관함 저장·삭제
다른 회원의 펫 명함을 코드(QR)로 받아 보관함에 두는 기능이다.

- PUT /pet-cards/:id/share-mode: 공유 시트를 열 때 앱이 공유 모양(간략히·상세히)을 알린다.
  받는 사람에게는 그 모양의 정보만 간다 — 간략히로 공유했는데 병원·건강 정보가 새지 않게. 기본은 간략히
- GET /shared-pet-cards/:code (+ /images/:kind): 받기 전 미리보기. 사진은 코드를 아는 회원에게 그 명함 사진만
- POST·GET·DELETE /received-cards: 보관함. 받는 순간의 사본을 저장하고 사진도 받는 사람 소유로 복사한다
  (보낸 사람이 아이를 지우거나 사진을 바꿔도 받은 명함은 그대로)
- 코드로 찾는 엔드포인트는 분당 30회로 막는다 (코드 추측 방지)
- CARD_002(코드에 맞는 명함 없음)·CARD_003(내 명함) 추가, 프론트·Flutter 사전 재생성

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:29:17 +09:00
ttipo 5331362215 docs(rules): 서버화에 맞춰 규칙·스킬 문서 갱신
구현한 구조를 규칙 SSOT 에 반영한다 — 다음 작업이 같은 패턴을 쓰도록.

- security: 모바일 리프레시 토큰은 secure storage(쿠키 저장소가 없다) — 대신 해시 저장·회전·재사용 탐지.
  소셜 토큰은 서버가 제공자에게 확인, 개발용 로그인은 운영에서 코드로 닫힘, 업로드 매직 바이트 검사·소유자 확인
- nestjs 스킬: 인증·회원·도메인 모듈·커서 헬퍼·공통 검증·snake_case 이름 규칙·아이 삭제 정리 리스너,
  새 라우트는 기본 로그인 필요(@Public 예외)·새 엔티티는 소유자 조건 조회
- flutter 스킬: DATA_MODE, 세션·토큰 갱신, 실패 알림·succeeded(), 서버 사진, 저장소 계약(Local·Api) 패턴
- env·응답 형식·에러 코드·테스트·체크리스트: DATA_MODE·소셜 키, keysetPaginate·fetchAllPages,
  도메인 코드 목록, 앱의 AUTH_002 흐름, 가짜 서버·라이브 점검 테스트
- vue3 스킬: 웹 로그인 연동 시 필요한 것 (지금 인증은 모바일용)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:43:48 +09:00
ttipo 373d50576a test(flutter): 서버 모드 화면 흐름 테스트
기존 흐름 테스트는 기기 저장 모드로 돈다. 가짜 서버를 붙여 서버 모드에서도
로그인 → 온보딩 → 홈(서버의 아이) 흐름, 세션 만료 시 로그인 화면 복귀(AUTH_002),
로그인 전에는 서버에 묻지 않는 것을 화면 단위로 확인한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:40:36 +09:00
ttipo b9c351d7cf fix(flutter): 라이브 점검의 테스트 전용 API 경고 정리
test_live/ 는 분석기가 테스트 폴더로 보지 않아 setMockInitialValues 가 경고로 잡혔다.
CI 의 flutter analyze 는 경고 0 을 요구하므로 이유를 적고 이 줄만 예외로 둔다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:37:30 +09:00
ttipo 42abefcc66 test(flutter): 서버 계약 라이브 점검 추가
단위 테스트는 응답 모양을 가정한 가짜 서버로 돈다. 그 가정이 실제 서버와 맞는지
개발 서버에 앱의 서버 저장소를 그대로 붙여 확인한다 (로그인 → 프로필 → 아이 → 일정 → 다이어리 → 명함 →
보관·삭제 때 서버 정리 → 탈퇴).

서버가 필요하므로 test/ 가 아닌 test_live/ 에 두어 CI 의 flutter test 에서는 돌지 않는다.
  cd flutter && flutter test test_live

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:36:33 +09:00
ttipo 9cb660a0ac fix(flutter): 추억 보관함·순서 바꾸기도 서버 실패를 견디게
서버에 저장하면서 실패할 수 있게 된 남은 흐름을 정리했다.

- 보관하기·다시 불러오기는 성공했을 때만 화면을 옮기고 토스트를 띄운다
- 우리 아이 순서 바꾸기는 화면에 먼저 반영하고, 저장하지 못하면 원래 순서로 되돌린다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:35:11 +09:00
ttipo 2c1d1cc97d feat(flutter): 펫 명함을 서버에 저장 (ApiPetCardRepository)
- 명함 목록·만들기·꾸미기·삭제·대표 명함을 /pet-cards, /pets/:id/representative-card 로 옮겼다
- 명함과 대표 지정을 한 번에 불러오도록 저장소가 PetCardBook 을 돌려준다
- 배경은 앱 그림(assets/…)이면 경로만, 기기 사진이면 올린 뒤 파일 id 로 보낸다
- 공유 화면은 서버가 발급한 명함 코드를 보여 준다 (기기 저장 모드는 지금처럼 명함 id 로 만든다)
- 명함을 지울 때 대표 지정 정리, 아이를 지울 때 명함 정리는 저장소가 맡는다 — 서버 모드는 서버가 이미 했다
- 명함 화면은 실패하면 꾸민 모양을 그대로 둔다 (알림은 인터셉터)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:32:38 +09:00
ttipo a09086daf7 feat(flutter): 다이어리를 서버에 저장 (ApiDiaryRepository)
다이어리 저장소도 "장부 전체 저장" 에서 건별 계약(다이어리·기록·댓글 만들기·고치기·지우기)으로 바꿨다.

- 서버 저장소: 다이어리·기록·댓글 목록을 합쳐 DiaryBook 으로 만들고, 표지·기록 사진은 저장할 때 올린다
- 기록·댓글의 작성자가 로그인한 나이면 앱의 "나"로 담는다 — 하루 한 번 쓰기 판단이 그대로 맞는다.
  로그인할 때 회원 id 를 기기에 둔다
- 책장이 가득 찬 경우(DIARY_001)는 null 로 받아 화면이 직접 안내하고 전역 알림은 끈다
- 아이 삭제 때의 다이어리 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다
- 다이어리 화면은 실패하면 쓴 기록·댓글을 그대로 둔다 (알림은 인터셉터)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:28:20 +09:00
ttipo 60efc65ee0 feat(flutter): 일정을 서버에 저장 (ApiScheduleRepository)
일정 저장소가 "목록 전체 저장" 이라 서버에 맞지 않아 건별 계약으로 바꿨다.

- 저장소: 날짜 일정 여러 건 만들기·내용 바꾸기·삭제, 반복 규칙 만들기·바꾸기·삭제, 완료 표시, 대상 떼기.
  돌려받은 결과(id 가 붙은 일정·규칙)를 알림기가 ScheduleBook 에 반영한다
- 일반↔반복 전환은 알림기가 저장소 호출로 풀고, 새 것을 먼저 만든 뒤 옛것을 지운다(중간 실패에도 일정이 남는다).
  ScheduleBook.update 는 지우고 같은 규칙을 알림기 테스트로 옮겼다
- 서버 저장소: 불러올 때 날짜 일정·반복 규칙·완료 표시를 합치고, 기간 설정은 500건씩 나눠 보낸다.
  아이 삭제·보관 때의 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다
- 완료 체크는 화면에 먼저 반영하고 저장에 실패하면 되돌린다
- 일정·아이 저장 화면은 실패하면(알림은 인터셉터) 입력을 둔 채 머문다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:23:55 +09:00
ttipo 100dbe3a35 feat(flutter): 우리 아이를 서버에 저장 (ApiPetRepository)
- 목록·등록·수정·삭제·순서·추억 보관함을 /pets 로 옮겼다. 목록은 마지막 페이지까지 이어 읽는다
- 아이 사진은 저장할 때 올리고, 이미 올린 사진(server-file:)은 다시 올리지 않는다
- 고유색은 알림기가 등록 순서대로 정해 넘긴다 — 기기·서버 저장소가 같은 규칙을 쓴다
- 보관한 때는 서버가 정하고, 회원이 바뀌면 목록을 다시 읽는다
- DATA_MODE=local 이면 지금까지처럼 기기에 저장한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:17:43 +09:00
ttipo 00660c7d62 feat(flutter): 로그인·프로필·탈퇴를 서버에 연결
- 로그인: 소셜 앱 키가 생기기 전까지 기기 키로 개발용 로그인(POST /auth/dev-sessions)을 한다.
  기기 키는 로그인 수단이라 secure storage 에 둔다. SDK 를 붙이면 signIn 만 /auth/sessions 로 바꾼다
- 로그아웃은 리프레시 토큰을 폐기하고(실패해도 기기에서는 로그아웃), 탈퇴는 서버에서 회원을 지운 뒤
  기기 기록을 정리한다 — 서버 탈퇴가 실패하면 기기 기록도 그대로 둔다
- 서버가 세션을 끊으면(AUTH_002) 진입 상태가 로그인 화면으로 보낸다
- 프로필: GET/PATCH /users/me. 마지막 값을 기기에 두어 앱을 열자마자 보여 주고, 사진은 저장할 때 올린다
- 회원이 바뀌면(로그인·로그아웃) 기록을 다시 읽도록 sessionEpochProvider 를 두었다
- 화면은 succeeded() 로 성공 여부만 보고 멈춘다 (실패 알림은 인터셉터가 이미 띄웠다)
- 템플릿 예제 features/user(없는 /users/:id 를 부르던 코드)를 지웠다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:14:33 +09:00
ttipo cdd4856300 feat(flutter): 서버 연동 기반 — 세션·토큰 갱신·사진 업로드·서버 사진 표시
기기 저장소를 API 로 바꾸기 전에, 모든 도메인이 함께 쓸 네트워크 계층을 먼저 갖춘다.

- DATA_MODE(api|local): 기본은 서버. .env 를 읽지 않는 테스트·화면 확인은 기기 저장소로 돈다
- SessionStore: 액세스·리프레시 토큰을 secure storage 에 두고 앱을 열 때 미리 읽는다
  (모바일에는 쿠키 저장소가 없어 리프레시 토큰도 secure storage 에 둔다)
- DioClient: 401 이면 토큰을 한 번 갱신해 다시 보낸다(동시 요청도 갱신은 한 번).
  서버가 갱신을 거절하면 세션을 끊고 AUTH_002, 서버에 닿지 못하면 세션은 두고 SYS_001
- 실패 알림은 사전 문구로 토스트 한 곳에서 띄우고 같은 코드는 잠깐 한 번만 — 호출부가 처리하는 코드는 끌 수 있다
- 사진: 경로 자리에 server-file:<id> 를 담고, 기기 사진은 저장할 때 형식을 내용으로 확인해 올린다.
  서버 사진은 Dio 로 받아 토큰 갱신을 그대로 쓴다 (PetPhotoImage·PersonAvatar)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:07:49 +09:00
ttipo 607ff5c474 fix(backend): 명함 배경 그림 경로에 앱 내장 svg 허용
앱의 기본 명함 배경(assets/svg/card_bg/*.svg)이 svg 라 검증에 걸렸다.
경로 문자열만 저장하고 서버가 파일을 내려주지 않으므로 svg 를 막을 이유가 없다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:59:28 +09:00
ttipo fdfc659512 chore(backend): npm audit fix 와 swagger js-yaml override
템플릿을 만든 뒤 새로 나온 권고로 npm audit 이 29건(critical 4)을 보고했다.

- npm audit fix (--force 없이) 로 critical·high 를 모두 정리했다 (piscina·proxy-addr 등)
- @nestjs/swagger@11 이 고정한 js-yaml@5.3.0 은 override 로 5.4.x 로 올렸다
  (v12 가 선언하는 버전과 같은 메이저 — multer 와 같은 방식)
- 남은 moderate 20건은 jest 커버리지 도구 체인(개발 전용)이라 유예하고 판단 근거를 security.md 에 남겼다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:56:04 +09:00
ttipo 377c9d2f9a feat(backend): 펫 명함 API — 만들기·꾸미기·삭제·대표 명함
앱의 펫 명함(PetCardRepository)을 서버로 옮긴다.

- /pet-cards: 목록·만들기·꾸미기 바꾸기·삭제, 한 사람 최대 10장 → CARD_001
- /pets/:petId/representative-card: 대표 명함 정하기·풀기 (아이마다 하나 — 부분 유니크 인덱스)
- 명함 코드("0000-000")를 서버가 무작위로 발급한다 — 지금은 앱이 카드 id 로 계산해 기기마다 달라질 수 있었다.
  명함 받기에서 이 코드로 찾을 수 있다
- 배경은 색·앱 그림(assets/…)·올린 사진 중 하나만 저장한다. 그림 경로는 경로 탈출을 막는 형식만 받는다
- 아이를 지우면 명함은 FK 로 함께 지워지고 배경 사진은 커밋 뒤 정리한다
- 에러 코드 추가에 따라 프론트·Flutter 사전을 다시 생성했다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:41:20 +09:00
ttipo 77862249ee feat(backend): 다이어리 API — 다이어리·기록·댓글
앱의 다이어리(DiaryBook: 다이어리 + 기록 + 댓글)를 서버로 옮긴다.

- /diaries: 목록·만들기·수정(이름·대상·리본·표지·알림 끄기)·삭제, /diaries/:id/posts 로 기록 남기기
- /diary-posts: 목록·고치기·삭제, /diary-posts/:id/comments 로 댓글 쓰기
- /diary-comments: 목록·고치기·삭제
- 책장은 최대 6권 (추억 보관함의 아이만 담은 다이어리는 세지 않는다) → DIARY_001
- 한 사람이 한 다이어리에 하루 한 번 (유니크 제약) → DIARY_002
- 아이를 지우면 다이어리 대상에서만 떼고 다이어리·기록은 남긴다
- 지금은 주인만 읽고 쓴다 — 기록·댓글에 작성자를 두어 공유 다이어리로 넓힐 수 있게 했다
- 에러 코드 추가에 따라 프론트·Flutter 사전을 다시 생성했다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:35:08 +09:00
ttipo 8380e35277 feat(backend): 일정 API — 날짜 일정·반복 일정·완료 표시
앱의 일정(ScheduleBook: 날짜 일정 + 반복 규칙 + 완료 표시)을 서버로 옮긴다.
어느 날에 무엇이 놓이는지는 지금처럼 앱이 계산하고, 서버는 원본을 보관한다.

- /schedule-events: 목록·여러 건 만들기(기간 설정, 요청당 500건)·내용 바꾸기·삭제·완료 표시
- /repeat-rules: 목록·만들기·주기/내용 바꾸기·삭제, 날짜별 완료 표시(/completions/:date)
- /repeat-rule-completions: 반복 일정 완료 표시 목록
- 대상은 "self" 또는 내 아이만 받는다. 아이를 지우거나 보관하면 그 아이만 떼고 대상이 빈 일정은 지운다
- 날짜 일정과 반복 규칙은 같은 내용 컬럼(ScheduleDetails)을 쓴다
- @nestjs/schedule 의 ScheduleModule 과 이름이 겹쳐 크론 쪽을 별칭으로 부른다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:26:14 +09:00
ttipo 720b2cd858 feat(backend): 반려동물 API — 등록·수정·삭제·순서·추억 보관함
앱의 우리 아이 기록(PetRepository)을 서버로 옮긴다.

- GET/POST /pets, PATCH/DELETE /pets/:id, PUT /pets/order, PUT/DELETE /pets/:id/archive
- 목록은 정한 순서(sort_order) 기준 커서 페이지네이션 — 공통 keysetPaginate 로 뽑았다
- 아이를 지우거나 보관할 때 일정·다이어리·명함이 따라 정리할 수 있게 PetLifecycleListener 를 두었다
  (반려동물 모듈은 다른 도메인을 모르고, 정리는 같은 트랜잭션에서 한다)
- 사진을 바꾸면 쓰지 않는 파일만 지운다 — 다른 기록이 참조하면 FK 가 막는다
- 목록 응답 Swagger 를 실제 { items, pageInfo } 모양으로 그리는 ApiPaginatedResponse 추가
- e2e 에 목록 pageInfo 계약·미정의 쿼리 거부 검사를 되살렸다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:16:38 +09:00
ttipo 7e0e4c36b5 feat(backend): 소셜 로그인·JWT 인증과 회원·파일 API 추가
앱의 기록을 서버로 옮기려면 회원과 인증이 먼저 있어야 한다.

- 소셜 로그인: 앱이 SDK 로 받은 토큰을 서버가 제공자에게 직접 확인한다
  (카카오·네이버는 사용자 정보 API, 구글·Apple 은 id_token JWKS 검증)
- 소셜 앱 키가 생기기 전까지 쓰는 개발용 로그인(기기 키) — 운영에서는 닫힌다
- 액세스 JWT(1시간) + 리프레시 토큰(해시 저장·회전·재사용 탐지)
- 전역 JwtAuthGuard — 기본으로 모든 라우트가 로그인을 요구하고 @Public() 으로만 연다
- 내 정보 조회·수정·탈퇴(모든 기록과 파일 정리), 파일 소유자 기록과 내려받기,
  업로드 시 매직 바이트로 내용 형식 검사
- 컬럼은 snake_case 이름 규칙, 테스트 환경은 기동 시 마이그레이션 실행 (CI e2e 는 빈 DB)
- 템플릿 예제 사용자 API(in-memory)를 실제 회원 API 로 교체하고 e2e 계약 테스트를 옮겼다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:06:43 +09:00
ttipo 1f7693898c fix(flutter): 마이페이지·아이 정보·로그인 시안 점검 반영
- 반려동물 정보에서 뒤로 가면 마이페이지 첫 화면으로 간다 (시안 동작).
  명함 칩은 명함이 있으면 펫 명함 탭으로, 없으면 명함 관리로 간다.
- 사진이 없는 칸은 시안 바탕색으로 채우고, 아이 이름은 제목 서체 800 으로 맞췄다 (보관함 화면 포함).
- 아이 정보를 수정하면 그 아이를 선택한 채로 돌아온다. 삭제하기는 시안처럼 글자 버튼으로.
- 로그인: 인사말은 화면 위에서 96, 버튼 묶음은 아래에서 76 (상태바·홈 인디케이터가 크면 비켜 준다).
  소셜 로그인 버튼은 누르면 크기는 그대로 두고 어두워진다.
- 온보딩 그림, 프로필 수정·몸무게 단위 시트 간격을 시안 값으로 맞췄다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:25 +09:00
ttipo 0c16763943 fix(flutter): 다이어리 시안 점검 반영
- 기록을 남기고 돌아오면 달력이 아니라 피드로 보여 준다.
- 기록 카드: 폴라로이드 위치, 테이프 양옆 흰 점선, 댓글 입력·수정칸 여백을 시안대로 맞추고
  입력 중이면 주황 테두리를 둔다. 등록 버튼은 제목 서체 800 으로.
- 기록 수정 시트: 글칸 최소 높이 120(작성 화면과 같게), 라벨 간격, 종류 선택에 아이콘을 붙였다.
- 다이어리에 참여하면 시트를 닫고, 표지 리본·그림자와 무지개 색 시작 위치(12시)를 맞췄다.
- 검색창 포커스 테두리, 다이어리 머리 여백을 시안 값으로. 다이어리 제거는 전용 확인창으로.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:25 +09:00
ttipo e6f5cd0720 feat(flutter): 펫 명함 이미지를 앨범에 저장
공유 시트의 '이미지로 저장'과 QR 창의 'QR 이미지 저장'이 시안처럼 휴대폰 앨범에 저장한다.
지금까지는 공유 창을 띄웠다. 웹은 앨범이 없으므로 공유 창을 그대로 쓴다.

- 저장은 AlbumSaver 뒤에 두고(gal), 테스트에서는 가짜 저장기로 바꿔 끼운다.
- 사진 접근을 거절해 저장하지 못하면 권한을 확인하라고 알린다.
- QR 창은 배경 흐림·테두리를 시안대로, 저장하면 QR 창과 공유 시트를 함께 닫는다.
- 코드 복사 안내 문구를 시안에 맞췄다.

권한: Android 는 10 미만에서만 저장 권한을 요청하고(maxSdkVersion 29), iOS 는 사진 추가 안내 문구를 넣었다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:24 +09:00
ttipo 4173c64af8 fix(flutter): 펫 명함 화면 시안 점검 반영
- 새 명함은 그 아이의 고유색 한 가지로 시작한다 (CardStyle.forPet — 직접 선택 칸이 켜진다).
- 명함 이름이 길어도 윗줄(이름·성별·공유 버튼)이 깨지지 않게 하고, 공유 버튼에 누름 효과를 넣었다.
- 꾸미기에서 저장하고 돌아오면 상세히 보기로 돌아온다.
- 빈 명함 칸 누름 바탕, '기본' 칸은 골라도 그림자 없음, 명함 받기 안내 아이콘(QR) 등
  상세·관리·목록 화면의 간격과 줄 높이를 시안 값으로 맞췄다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:24 +09:00
ttipo 81af93a813 fix(flutter): 홈·일정 시안 점검 반영과 우리 아이 순서 바꾸기
홈
- 우리 아이를 꾹 눌러(320ms) 끌면 순서가 바뀌고 저장된다 (PetRepository.reorder).
  보관함으로 간 아이는 목록에서 빠지므로 원래 순서대로 뒤에 둔다.
- 오늘 할 일 진행 막대 끝을 둥글게, 아이 사진 겹침의 글자 아래 여백(inline-flex)을 반영하고
  긴 일정 제목은 줄을 바꾼다.
- 최근 다이어리 칸을 누르면 시안처럼 일정·기록 탭의 다이어리 목록으로 간다.

일정
- 하루 종일이 켜진 채 기간을 하루 2회로 정하면 회차 시각(08:00·20:00)이 저장되지 않던 문제를 고쳤다.
- 예약 분류를 고른 뒤 같은 일반 일정 칩을 다시 누르면 분류 선택이 풀린다.
- 주기 직접 설정은 2주마다로 시작하고, 매일·평일을 골랐다 돌아와도 단위·횟수가 남는다.
- 12개월 보기에서는 일정 목록을 숨기고, 달력 점 묶음을 칸 가운데에 둔다.
- 저장 버튼 흐림 .45, 일정 제거는 전용 확인창으로.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:24 +09:00
ttipo d9a4eba9a3 feat(flutter): 공통 위젯 시안 맞춤 — 모달 배경 흐림·누름 효과·휠 직접 입력·제거 확인창
시안 전체 점검에서 여러 화면에 걸쳐 나온 차이를 공통 위젯에서 한 번에 고쳤다.

- 시트·확인창 뒤 화면을 시안(backdrop-filter blur)처럼 흐린다. showModalBottomSheet·showDialog
  대신 BlurredSheetRoute·showBlurredDialog 로 띄우고, 흐림은 등장 애니메이션을 따라간다.
- 탭바 배경 흐림에 채도(saturate 1.6)를 더하고, 탭 라벨은 본문 서체 12/800 으로 맞췄다.
- 누름 효과: Pressable 에 누름 밝기를 두어 인트로 흰 버튼은 살짝 줄고 어두워진다(.99·밝기 .94).
  하트 토글은 누르는 동안 손잡이가 움직이고 안쪽 그림자가 생긴다.
- 시간·날짜 휠의 가운데 숫자를 누르면 직접 입력할 수 있다 (범위를 넘으면 가장 가까운 값).
- 일정·다이어리 제거용 확인창(showRupRemoveConfirm)과 추억 보관함 토스트 색(ToastTone.memory) 추가.
- 버튼 줄 높이를 제목 서체 normal(1.10)로, 빈 상태 여백을 .rup-empty(위아래 40·좌우 20)로 맞추고,
  화면별로 필요한 선택지(작은 섹션 링크, 라벨 간격, 아이콘 달린 선택 버튼, 흐림 불투명도)를 열었다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:24 +09:00
ttipo 03cb0224d6 feat(flutter): 추억 보관함 — 보관하기·다시 보기·다시 불러오기
반려동물 정보에서 아이를 추억 보관함으로 옮기고(함께한 기간·메모), 마이페이지의 보관함에서
메모리얼 사진·추억 기록과 원래 정보·펫 명함·다이어리(읽기 전용)를 다시 보며, 다시 불러올 수 있게 했다.

보관 기록은 반려동물에 붙여 저장한다(Pet.archive) — 서버가 생기면 보관/복구 요청으로 바꾼다.
보관하면 그 아이의 일정은 지우고(여러 아이 일정은 그 아이만 뗀다), 홈·나의 반려동물·펫 명함·일정 대상에서
빼며, 그 아이만 담은 다이어리는 책장·홈 최근 기록·기록 알림과 다이어리 수 한도에서 뺀다.

화면은 petListProvider 를 직접 보고 목록 확장(active/archived)으로 거른다 — 걸러 낸 목록을
파생 프로바이더로 두면 Riverpod 3.3 의 일시정지 해제 때 markNeedsBuild 오류가 난다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:09:45 +09:00
ttipo 795509be1b fix(flutter): Riverpod 3.3.2 로 올리고 파생 프로바이더가 알림기만 보도록 정리
Riverpod 3 은 가려진 화면의 구독을 멈췄다가 다시 보일 때 빌드 도중에 풀면서 파생값을 다시 계산한다.
3.2.1 에서는 이때 멈춤 횟수 assertion 이 깨졌다 (3.3.2 에서 수정 — pubspec.lock 만 바뀐다).

3.3.2 에도 파생 프로바이더가 다른 파생 프로바이더에 알리면 markNeedsBuild 오류가 나는 경로가 남아 있다.
이를 고친 3.4 는 Dart 3.12 가 필요해 올릴 수 없으므로, 명함·알림의 파생값을 알림기에서 바로 계산하게 바꿔
파생 프로바이더끼리 잇지 않는다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:09:09 +09:00
ttipo 877c833f7d fix(flutter): 사진 없는 아바타·다이어리 표지에 시안 바탕색 반영
시안의 사진 칸(image-slot)은 사진이 없을 때 rgba(0,0,0,.04) 바탕 위에 기본 그림을 올린다.
아이 아바타는 바탕이 비어 있었고, 다이어리 표지는 고유색이 시안보다 밝게 보였다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:08:19 +09:00
ttipo 66fb329747 feat(flutter): 알림 화면과 홈 종 새 알림 표시 구현
시안의 알림 화면(오늘·어제·최근 7일 묶음)과 홈 종 버튼의 새 알림 점을 옮겼다.
서버가 아직 없어 공유 다이어리의 새 글·댓글 알림은 구조만 두고,
기기에서 만들 수 있는 기록 알림(저녁 8시까지 오늘 기록이 없는 다이어리)부터 보여 준다.
다이어리별 기록 알림 스위치와 마이페이지 일정·다이어리 알림 설정을 따른다.

시각에 따라 달라지는 동작을 테스트할 수 있게 clockProvider 로 현재 시각을 주입하고,
앱을 켜 둔 채 8시·자정이 지나도 알림이 맞게 바뀌도록 그 시각에 다시 계산한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:26:58 +09:00
ttipo d7f46d174a feat(flutter): 다이어리 책장·기록·댓글·달력 보기와 홈 최근 기록 구현
일정 관리 다음 흐름 — 일정·기록 탭의 다이어리.
- 책장: 표지 그리드(최대 6권), 반투명 책갈피 리본, 새 다이어리 만들기(표지·이름·대상·리본 색)
- 다이어리 안: 기록 피드(모두/사진/글) · 검색 · 달력 보기(날마다 기분 표정) · 하루 한 번 쓰기 버튼
- 기록하기: 사진 · 오늘 기분 6종 · 글 / 기록 수정·삭제 / 댓글 작성·수정·삭제
- 다이어리 설정: 정보 수정, 알림 스위치(값만 저장), 다이어리 제거(기록·댓글 함께)
- 홈 다이어리: 최근 기록 3개를 사진/글 타일로, 누르면 그 다이어리로
- 아이를 지우면 다이어리 대상에서만 떼고, 회원탈퇴는 다이어리도 지운다
- 기록은 기기에 저장한다 (공유 다이어리 서버가 생기면 DiaryRepository 구현을 바꾼다)

시안과 다른 점: 공유 다이어리(초대 코드·QR·멤버·참여)는 서버가 필요해 안내만 둔다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:58:09 +09:00
ttipo 76e9fc1af1 refactor(flutter): 대상 아바타·칩을 반려동물 공용으로 옮기고 사진 칸·토스트 보완
다이어리도 "누구의 기록인지"(아이·나)를 고르므로 일정 전용이던 부품을 공용으로 옮긴다.
- TargetAvatar·PetStack·TargetChip → features/pet (칩에 선택 ✓ 표시 추가), "나" id 는 pet_target.dart
- 사진 칸에 높이·모서리·빈 칸 문구 지정, 앨범에서 골라 저장하는 도우미
- 여러 줄 입력칸 최소 높이 지정, "오늘·어제·N일 전" 날짜 표기
- 긴 토스트 문구가 화면 밖으로 넘치던 문제 — 넘치면 줄을 바꾼다

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:57:30 +09:00
ttipo a32c0d1337 feat(flutter): 일정·기록 탭의 일정 관리와 홈 일정 구현
마이페이지 다음 흐름 — 홈 "일정" 과 일정·기록 탭이 실제 일정을 다룬다.
- 달력: 일정 색 점, 월↔주 접기, 12개월 보기 / 고른 날의 일정 목록과 완료 체크
- 일정 추가·수정: 대상(여러 마리·나), 예약/일반 일정, 시간, 장소, 라벨 색
  - 반복(매일·평일·주말·N일/주/개월마다) — 끝나는 날 없는 반복은 날짜를 만들지 않고 규칙으로 보관
  - 기간 설정 + 하루 횟수 — 기간 안의 날짜(반복 주기에 맞는 날)마다 만든다
- 홈: 다음 일정(도는 테두리), 오늘 할 일(4개까지·밤 11시 하루 정리), 예약 일정 D-day
- 아이를 지우면 일정에서 그 아이만 떼고, 회원탈퇴는 일정도 지운다
- 일정은 기기에 저장한다 (백엔드 API 가 생기면 ScheduleRepository 구현을 바꾼다)

시안과 다른 점: "함께 관리"는 서버가 필요해 안내만, 다이어리 탭은 다음 단계,
기간+반복에 맞는 날이 없으면 저장하지 않고 알린다 (시안은 빈 저장 후 "추가했어요").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:32:37 +09:00
ttipo f2a4451b92 feat(flutter): 시간 선택 시트와 휠·날짜 선택 등 공용 위젯 확장
일정 추가 화면에 필요한 공용 부품을 먼저 넣는다.
- 시간 선택 시트(오전/오후·시·분) — 시·분은 끝없이 돌아가는 휠, 조건이 안 맞으면 시트 안에 안내
- 날짜 선택 시트에 연도 범위 지정 (기간 설정은 올해~2050)
- 큰 알약 토글(일정 관리/다이어리)과 아이콘, 본문 서체 알약 버튼
- "직접 추가" 입력 칩을 공용으로 옮기고, 일반 일정 칩용 큰 × 추가
- HourMinute(시각 값), 주소 등 바깥 값용 날짜 파서

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:32:00 +09:00
ttipo 3e6206630f fix(flutter): 로그인 전에는 앱을 열 때마다 시작하기 화면을 보여 줌
"시작하기"를 한 번 누르면 기기에 저장해 이후 실행에서 인트로를 건너뛰었다.
그래서 시작하기를 누른 뒤 로그인하지 않고 앱을 다시 열면 스플래시 다음
바로 로그인 화면이 나왔다. 시작하기 여부는 이번 실행 동안만 기억한다.

- 로그아웃은 시안대로 곧바로 로그인 화면으로 간다
- 예전 버전이 저장한 entry.introSeen 값은 더 이상 읽지 않는다

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:51:06 +09:00
ttipo 1dba0c80ab feat(flutter): 마이페이지·나의 반려동물·반려동물 정보·설정 구현
시안은 아이를 등록하면 "나의 반려동물" 목록으로 보내므로 명함 다음에 만든다.
- 마이페이지: 프로필(닉네임·사진 편집), 반려동물·명함 바로가기, 권한·알림 스위치
  (스위치는 앱 설정값만 저장한다 — OS 권한·푸시는 해당 기능에서 요청)
- 나의 반려동물 / 반려동물 정보(읽기 전용, 연필로 수정) — 아이마다 고유색을 배정한다
- 설정: 약관 문서, 계정 변경·로그아웃, 회원탈퇴(기기 기록 삭제 후 인트로로)
- 등록·삭제 후와 홈 "아이 상세보기"는 나의 반려동물 목록으로 간다

시안 대조 중 발견한 공통 문제도 고쳤다.
- 줄 높이를 주지 않은 글자가 본문 기본값(1.6)을 물려받아 시안보다 줄이 컸다
  → AppTypography.normalHeight 를 두고 섹션 제목·시트 제목에 적용 (홈 간격 보정 포함)
- 버튼이 옆 제목 글자와 한 의미 단위로 합쳐져 스크린리더가 함께 읽었다
  → Pressable·설정 줄을 별도 의미 노드로

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:50:10 +09:00
ttipo 46bb77df9b feat(flutter): 펫 명함 만들기·상세·관리·공유 구현
홈에서 아이를 등록한 뒤 이어지는 흐름이라 먼저 만든다.
명함은 기기에 저장하고(아이별 대표 명함 지정), 공유는 명함을 이미지로
그려 기기 공유 창과 QR 로 보낸다. 명함 받기는 서버가 필요해 안내만 둔다.

- 아이를 삭제하면 그 아이의 명함도 함께 지운다
- 수정 화면은 아이 목록을 다 불러온 뒤 연다 (새 등록으로 오인 방지)
- 의존성: share_plus, qr_flutter

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:49:21 +09:00
ttipo 2fab3af26d feat(flutter): 홈 화면 구현과 반려동물 등록 연결
시안 screen-home 의 흐름을 옮겼다.
- 인사말(소셜 프로필 이름, 모르면 "집사") · 알림 아이콘
- 우리 아이: 없으면 등록 안내 카드 → 등록 화면, 있으면 아이 고르기 + 펫 명함 빈 슬롯
- 일정(오늘 일정·오늘 할 일·예약 일정)·다이어리는 해당 기능 전까지 시안의 빈 상태로 표시
- 등록·수정 화면을 탭바 위에 덮는 라우트로 연결, 토스트를 모든 화면 위에 표시

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:35:39 +09:00
ttipo 50d1aeea4b feat(flutter): 반려동물 등록·수정 화면과 기기 저장소 구현
시안 screen-register · pet-species 를 옮겼다.
- 사진(앨범 선택 → 앱 폴더로 복사), 이름, 종·품종 검색 시트(12 대분류), 성별·중성화,
  생년월일 휠, 몸무게(kg/g), 건강 정보, 알레르기(직접 추가 항목은 다음 등록에도 유지), 메모
- 필수값이 빠지면 칸마다 오류를 표시하고 첫 빠진 칸으로 스크롤
- 수정 모드에서 삭제(확인창) 지원
- 저장은 PetRepository 계약 뒤의 LocalPetRepository(SharedPreferences JSON) —
  백엔드 /api/pets 가 생기면 Dio 구현으로 바꾼다
- image_picker · path_provider 추가, iOS 사진 접근 문구(NSPhotoLibraryUsageDescription)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:35:39 +09:00
ttipo 544281ed8e feat(flutter): 입력 폼·시트·토스트 등 공통 위젯 추가
등록·일정 등 이후 화면이 함께 쓰는 시안 컴포넌트.
- 입력칸·선택 칸·2지선다·태그 칩 (테두리는 컨테이너가 그려 시안 치수 46 과 일치)
- 바텀시트(바닥까지 채우는 버튼 포함)·가운데 확인창·토스트(전역, 2초)
- 휠 선택 열 + 생년월일 선택 시트, 카드·점선 테두리·섹션 제목·구분 띠·하위 화면 헤더
- 날짜 표기(9월 28일 · 2021.09.14) · 을/를 조사 순수 함수

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:35:23 +09:00
ttipo e880f121b0 fix(flutter): 버튼·탭 라벨 글꼴을 시안 전역 규칙에 맞춤
시안 styles.css 뒷부분의 전역 규칙이 .rup-btn · 선택 버튼 · 하단 탭 라벨을
제목 서체(충북대체) 800 으로 덮어쓴다는 것을 놓쳤다.
- RupButton: 제목 서체 사용, thin(600)·danger 변형 추가 (인트로 CTA 는 본문 서체 유지)
- 탭 라벨: 제목 서체 800, 가운뎃점만 본문 서체로 그려 "일정·기록" 이 벌어져 보이지 않게 함
  (브라우저는 CJK 문장부호 간격을 자동으로 줄이지만 Flutter 는 글리프 폭을 그대로 쓴다)
- 이후 화면에 필요한 색 토큰(오류·시트 배경·빈 명함 등) 추가

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:35:23 +09:00
ttipo 2371dff023 feat(flutter): 진입 흐름(스플래시→인트로→로그인→온보딩)과 하단 탭 셸 구현
시안의 첫 진입 흐름을 순서대로 옮겼다.
- 스플래시: 이미지 디코딩 후 등장 애니메이션 재생, 2.6초 뒤 진입 단계에 맞는 화면으로 이동
- 인트로·온보딩은 설치 후 1회만 노출, 로그아웃 시 로그인으로 복귀 (EntryState → 라우터 redirect)
- 온보딩 일러스트는 블러·마스크 때문에 레이어별 PNG 로 래스터화하고 움직임은 Flutter 로 재현
  (하단 안내문·시작하기 버튼은 비율이 다른 기기에서 잘리지 않도록 위젯으로 분리)
- 로그인은 DemoAuthRepository 로 임시 처리 — 소셜 SDK 앱 키와 백엔드 인증 API 연동 시 교체
- 하단 탭 4개 셸(StatefulShellRoute), 탭 화면은 흐름 순서대로 교체할 자리만 둔다

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:54:14 +09:00
ttipo 8e2fbe79d8 feat(flutter): 시안 기반 테마·폰트·아이콘과 공통 위젯 추가
Claude Design 시안(rup)을 옮기기 위한 공통 기반.
- 색·크기·글꼴 토큰을 시안 styles.css 값 그대로 상수화하고 테마에 연결
- SUITE·충북대체·어비꿍꿍체 번들 (웹폰트만 배포되는 서체는 TTF 로 변환, 출처는 fonts/README.md)
- 시안 아이콘 56종·전용 글리프를 Dart 로 옮긴 RupIcon
- 눌림 축소(Pressable)·pill 버튼·시안 좌표 배치(DesignArtBox)·그림자 이미지 위젯
- CSS 키프레임처럼 구간마다 곡선을 적용하는 보간 유틸과 테스트
- go_router · flutter_svg · shared_preferences 의존성 추가

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:54:03 +09:00
ttipo 9b37235d1e chore: 디자인 시안 폴더(.design) 추적 제외
Claude Design 에서 내보낸 시안은 로컬 참고용이고 개인 사진이 섞여 있다.
앱에 필요한 에셋만 flutter/assets/ 로 옮겨 커밋한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:53:21 +09:00
462 changed files with 49524 additions and 1434 deletions
+3 -1
View File
@@ -10,7 +10,9 @@
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
소셜 로그인을 쓰려면 제공자 콘솔 값(`KAKAO_APP_ID` 등)도 채운다 — 비어 있으면 그 제공자는 거절하고, 개발에서는 개발용 로그인을 쓴다
- [ ] DB 스키마 적용 — `dc exec backend npm run migration:run` ([[nestjs]] "DB 마이그레이션")
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL`·`DATA_MODE` 확인 (Android 에뮬레이터: `10.0.2.2`)
— **이 파일이 없으면 `flutter analyze` 가 asset 경고를 낸다**
- [ ] 프로젝트 식별 값 교체 (`PROJECT_NAME` 등 루트 env 키)
- [ ] 의존성 설치: `cd backend && npm ci`, `cd frontend && npm ci`, `cd flutter && flutter pub get`
+6 -1
View File
@@ -32,12 +32,17 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
`vite.config.ts` 가 루트 `.env.{mode}` 를 `loadEnv` 로 읽어 포트·프록시 대상을 정한다.
포트를 `frontend/.env` 나 `vite.config.ts` 에 중복 작성하지 말 것 — 루트가 SSOT 다.
(읽은 값은 dev 서버 설정에만 쓰고 클라이언트 번들에는 넣지 않는다 — `VITE_` 없는 값의 번들 노출 금지, [[security]])
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL`·`DATA_MODE` 를 독립적으로 관리한다.
`DATA_MODE=api`(기본)는 서버에 저장하고, `local` 은 서버 없이 기기에 저장한다(화면 확인용). `.env` 를 읽지 않는 테스트는 `local` 로 돈다.
- 백엔드 소셜 로그인 값(`KAKAO_APP_ID`·`NAVER_CLIENT_ID`·`GOOGLE_CLIENT_IDS`·`APPLE_CLIENT_IDS`)과 `AUTH_DEV_LOGIN` 은
루트에서 내려주지 않는 백엔드 고유 값이라 `backend/.env.{APP_ENV}` 에 둔다.
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
- **설정이 잘못되면 서버가 뜨지 않아야 한다(fail-fast).** 조용히 기본값으로 굴러가다 운영에서 터지는 것이 더 위험하다.
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
- 숫자 항목은 형식 검사 (모든 환경)
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` 필수
- `NODE_ENV` 가 development·test 가 **아니면**(staging 포함) `JWT_SECRET`(32자 이상, 예시 값 금지) 필수 — 개발용 서명 키로 떨어지지 않는다
- 토큰 만료 기간(`JWT_ACCESS_EXPIRES_IN`·`JWT_REFRESH_EXPIRES_IN`)은 `15m`·`1h`·`14d` 형식 (모든 환경)
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
+15 -1
View File
@@ -38,13 +38,27 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
### 도메인 코드 (현재)
| 코드 | HTTP | 상황 |
| --- | --- | --- |
| FILE_001 / FILE_002 | 400 / 413 | 허용되지 않는 파일 형식(내용 검사 포함) / 크기 초과 |
| DIARY_001 | 409 | 다이어리 책장이 가득 참 (최대 6권 — 추억 보관함의 아이만 담은 다이어리는 세지 않는다) |
| DIARY_002 | 409 | 한 사람이 한 다이어리에 하루 한 번 |
| DIARY_003 / 004 / 005 | 404 / 409 / 409 | 공유 다이어리 초대 코드 없음 / 참여 인원 가득 참(4명) / 이미 참여 중 |
| CARD_001 | 409 | 펫 명함 최대 10장 |
| CARD_002 / 003 | 404 / 400 | 받을 명함 코드 없음 / 내 명함은 받을 수 없음 |
| CARD_004 / 005 | 409 / 409 | 이미 보관함에 있는 명함 / 보관함 가득 참 (최대 100장) |
| SCHED_001 ~ 005 | 404 / 409 / 400 / 409 / 409 | 일정 연동 — 초대 코드 없음 / 4명 가득 참 / 내 코드 / 이미 다른 사람과 연동 / 이미 연동된 사람 |
### AUTH_002 (클라이언트 전용)
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
- 웹: `useApi.ts` 의 `toAuthCode()` (`authStore.clearAuth()` 호출)
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
- 앱: `dio_client.dart` — 401 이면 먼저 리프레시 토큰으로 갱신해 다시 보내고, **서버가 갱신을 거절했을 때만** AUTH_002 + 세션 삭제
(`SessionStore.clear(expired: true)` → 진입 상태가 로그인 화면으로). 갱신하려다 서버에 닿지 못했으면 세션을 두고 SYS_001.
## 도메인 코드 추가
+4 -2
View File
@@ -71,7 +71,8 @@ exportCsv(@Res() res: Response) { /* ... */ }
- 구현 SSOT: backend `common/dto/pagination.dto.ts`
(`CursorPaginationQueryDto` · `OffsetPaginationQueryDto` · `PageInfoDto` · `toPaginatedResult()`)
- 클라이언트 타입: frontend `src/shared/types/pagination.ts`, flutter `UserRepository.getUsers()` 패턴
- 구현 헬퍼: backend `common/dto/keyset-pagination.ts` 의 `keysetPaginate()` (커서 = 정렬 키를 담은 불투명 문자열)
- 클라이언트 타입: frontend `src/shared/types/pagination.ts`, flutter `core/api/api_paging.dart` 의 `fetchAllPages()`
- **커서 기반이 기본**이다. 전체 페이지 수가 UI 에 필요한 경우에만 오프셋을 쓴다.
- `limit` 기본값 20, 최대 100 — 무제한 조회를 허용하지 않는다.
@@ -81,10 +82,11 @@ exportCsv(@Res() res: Response) { /* ... */ }
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiPaginatedResponse(PetResponseDto, '아이 목록') // 목록 — { items, pageInfo }
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
```
- `ApiStandardResponse` / `ApiErrorResponse` 는 `common/decorators/api-standard-response.decorator.ts` 제공.
- `ApiStandardResponse` / `ApiPaginatedResponse` / `ApiErrorResponse` 는 `common/decorators/api-standard-response.decorator.ts` 제공.
- `ApiErrorResponse` 는 상태 코드·메시지를 에러 레지스트리에서 가져오므로 문서가 구현과 자동으로 일치한다.
> 에러 코드 ↔ 메시지 매핑은 [[error-codes]], API 설계 규약은 [[api-design]] 참조.
+26 -7
View File
@@ -12,7 +12,12 @@
## 인증 · 토큰
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 웹이면 HttpOnly 쿠키로만 전달
- **모바일 앱은 예외** — 앱에는 쿠키 저장소가 없어 리프레시 토큰을 응답 본문으로 받아 `flutter_secure_storage`(키: `refresh_token`)에 둔다.
대신 서버는 리프레시 토큰을 **해시로만 저장**하고, **한 번 쓰면 폐기(회전)** 하며, 폐기된 토큰이 다시 오면 그 회원의 토큰을 모두 폐기한다 (`modules/auth`)
- 소셜 로그인은 앱이 보낸 사용자 id 를 믿지 않는다 — 서버가 토큰을 **제공자에게 직접 확인**한다 (카카오는 앱 ID 까지 대조)
- 개발용 로그인(`/auth/dev-sessions`)과 개발용 서명 키는 `NODE_ENV` 가 **development·test 일 때만** 열린다 (allowlist, `shared/config/node-env.ts`).
staging·production·오타·미설정은 모두 닫히고 `JWT_SECRET` 이 없으면 기동하지 않는다 — env 값으로 열 수 없다
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_001)
@@ -26,7 +31,16 @@
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다. 저장한 뒤 **파일 앞부분(매직 바이트)도 확인**해 형식과 다르면 지운다.
- 파일은 **올린 회원의 것**으로 기록하고, 다른 기록에서의 참조는 본인 파일만 허용한다.
내려받기(`GET /files/:id`)는 본인 파일 + **함께 쓰는 기능이 등록한 열람 정책**(`FileAccessPolicy`)이 허락한 파일만이다 —
공유 다이어리(표지·기록 사진·대상 아이·멤버 프로필)와 일정 함께 관리(연동된 사람의 프로필·아이 사진). 그 밖은 남의 파일과 없는 파일 모두 RES_001.
- **코드만으로 남의 기록을 보거나 들어오는 기능**(명함 받기·공유 다이어리·일정 연동)은 코드가 곧 열쇠다.
- 초대 코드는 짐작하기 어렵게(`RUP-` + 6자, 혼동 글자 뺀 32자) 만든다
- 코드 조회·참여 엔드포인트는 모두 `@InviteCodeLookup()` 를 붙여 **회원 단위로 한 바구니**(분당 30회)를 같이 쓴다 — 엔드포인트마다 따로 세면 한도가 몇 배가 된다
- 코드를 바꿔 옛 코드를 막는다: 다이어리는 내보내거나 나가면, 일정 연동 코드는 **한 번 연동에 쓰이거나** 나가면 바뀐다
- 펫 명함의 상세히 공유(병원·질환·메모)는 마지막으로 공유한 뒤 7일만 상세히로 보이고, 그 뒤에는 간략히로 내려간다
- 개수 한도(다이어리 6권·명함 10장·받은 명함 100장)는 동시 요청에 뚫리지 않게 `lockFor()`(`shared/utils/db-lock.ts`, 회원별 advisory lock)를 잡고 센다
- **허용 목록(allowlist) 방식**으로만 받는다. 차단 목록은 언제나 빠뜨린다.
`.html`·`.svg`·`.js` 등 **브라우저가 실행할 수 있는 형식은 넣지 않는다.**
- **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
@@ -35,10 +49,8 @@
- 업로드 디렉터리를 **정적 파일로 그대로 서빙하지 않는다.** 내려줄 때는 컨트롤러를 거쳐
`Content-Type` 을 서버가 지정하고 `Content-Disposition: attachment` 를 붙인다.
> ⚠️ **알려진 한계**: 확장자·MIME 검사는 **파일 내용을 보지 않는다.**
> 이름이 `.png` 이고 MIME 이 `image/png` 이면 내용이 HTML 이어도 통과한다.
> 그래서 위의 "정적 서빙 금지"가 중요하다 — 내용 기반 검증이 필요하면
> 매직 바이트를 확인하는 라이브러리(`file-type` 등)를 파이프라인에 추가한다.
> ⚠️ **알려진 한계**: 매직 바이트는 파일의 **앞부분만** 본다. 앞은 PNG 이고 뒤에 다른 내용을 덧붙인 파일은 통과한다.
> 그래서 위의 "정적 서빙 금지"(attachment + 서버가 정한 Content-Type + nosniff)가 여전히 중요하다.
## 네트워크 · 인프라
@@ -85,7 +97,11 @@
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
> **현재 유예 중인 항목**: 없음 (`npm audit` 0건)
> **현재 유예 중인 항목** (backend, 2026-10-11 기준 `npm audit` moderate 20건):
> `js-yaml@3`·`sprintf-js` — `ts-jest → @jest/transform → babel-plugin-istanbul` 안의 테스트 커버리지 도구 체인.
> 1. 도달 경로: 운영 이미지(`npm ci --omit=dev`)에 들어가지 않고, 테스트가 읽는 YAML 도 저장소의 설정 파일뿐이다
> 2. 수정 비용: 같은 메이저에 고친 버전이 없고 `--force` 는 ts-jest 를 메이저로 내린다
> 3. 다시 볼 때: jest·ts-jest 메이저를 올릴 때, 또는 istanbul 이 js-yaml 4+ 로 옮겨 가면
### 전이 의존성 취약점은 overrides 로 끊는다
@@ -102,6 +118,9 @@
> 실제 사례: `@nestjs/platform-express@11` 은 `multer` 를 `2.2.0` 으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다.
> `@nestjs/platform-express@12` 는 `multer@2.4.0` 을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다.
> **v12 가 하는 일이 결국 multer 를 올리는 것**이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다.
>
> 같은 이유로 `@nestjs/swagger@11` 이 고정한 `js-yaml@5.3.0`(moderate)은 `"@nestjs/swagger": { "js-yaml": "^5.4.2" }` 로 올렸다
> (근거: `@nestjs/swagger@12` 가 `js-yaml@5.4.2` 를 선언한다). swagger 를 v12 로 올릴 때 함께 제거한다.
규칙:
+4 -2
View File
@@ -51,14 +51,16 @@ describe('formatSomething', () => {
| 에러 응답 포맷 | `backend/src/common/filters/http-exception.filter.spec.ts` | 코드/상태/traceId, **내부 정보 미노출**, 검증 상세 사유 미노출 |
| 프론트 에러 사전 | `frontend/src/composables/useApi.spec.ts` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 앱 에러 사전 | `flutter/test/error_lexicon_test.dart` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 응답 계약 (e2e) | `backend/test/app.e2e-spec.ts` | 실제 HTTP 로 래퍼·에러코드·traceId·페이지네이션·헬스체크 형식 검증 |
| 응답 계약 (e2e) | `backend/test/app.e2e-spec.ts` | 실제 HTTP 로 래퍼·에러코드·traceId·페이지네이션·헬스체크 형식 검증 (개발용 로그인으로 회원을 만들고 끝나면 탈퇴) |
| 앱 ↔ 서버 응답 모양 | `flutter/test/api_*_test.dart` + `test/support/fake_api.dart` | 앱의 서버 저장소가 보내고 읽는 JSON 모양 (가짜 서버) |
| 앱 ↔ 서버 라이브 점검 | `flutter/test_live/api_contract_live_test.dart` | 위 가정이 **실제 개발 서버**와 맞는지 — 서버가 필요해 CI 에서는 돌지 않는다 (`flutter test test_live`) |
> **e2e 는 DB 가 필요하다** (AppModule 이 TypeORM 을 초기화한다).
> 로컬에서는 개발 스택을 띄운 뒤 컨테이너 안에서 실행하는 것이 가장 간단하다.
> ```bash
> docker exec <프로젝트>-backend npx jest --config ./test/jest-e2e.json
> ```
> CI 는 postgres 서비스 컨테이너를 띄워 자동으로 실행한다.
> CI 는 postgres 서비스 컨테이너를 띄워 자동으로 실행한다. `NODE_ENV=test` 에서는 기동할 때 마이그레이션을 적용한다(빈 DB).
- 에러 코드를 추가/삭제하면 **세 곳의 기대 목록을 함께 갱신**한다 ([[checklist]] 3절).
- 매핑 로직(응답 래퍼·상태 역매핑)을 바꾸면 해당 분기 테스트를 추가한다.
+37 -30
View File
@@ -16,18 +16,25 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| Dio 클라이언트 | `lib/core/api/dio_client.dart` | 싱글톤, baseUrl=dotenv `API_URL`(fallback 로컬), 타임아웃 |
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(코드 매핑) — 인라인 |
| 데이터 모드 | `lib/core/config/app_config.dart` | `.env` 의 `DATA_MODE` — `api`(기본, 서버) / `local`(기기 저장 · 서버 없이 화면 확인 · 테스트) |
| Dio 클라이언트 | `lib/core/api/dio_client.dart` + `dio_provider.dart` | Provider 로 주입(`ref.watch(dioProvider)`), baseUrl=dotenv `API_URL`, 타임아웃 |
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(**401 → 토큰 갱신 후 한 번 재시도**·코드 매핑) — 인라인 |
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}` 정규화 예외. 인터셉터가 `DioException.error` 에 실어준다 |
| 실패 알림 | `apiErrorReporterProvider` → `rup_toast.dart` 의 `apiErrorToastReporter` | 사전 문구 토스트, 같은 코드는 잠깐 한 번만. 호출부가 처리하는 코드는 `kSilentCodesExtra` 로 끈다 |
| ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) |
| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 보유 상태에서 401 → `AUTH_002` 승격 + 토큰 삭제 |
| 토큰 저장 | `flutter_secure_storage`, key 상수 `kJwtTokenKey` | `dio_client` 에서 사용 |
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 |
| Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 |
| 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 |
| 세션 | `lib/core/api/session_store.dart` | 액세스·리프레시 토큰(secure storage `kJwtTokenKey`·`kRefreshTokenKey`)을 들고 있다. 회원이 바뀌면 `sessionEpochProvider` 가 오른다 |
| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 갱신을 서버가 거절하면 `AUTH_002` + 세션 삭제 → 진입 상태가 로그인 화면으로 보낸다 |
| 화면의 실패 처리 | `lib/core/api/api_guard.dart` | `succeeded(future)` / `isReportedApiFailure(e)` — 알림은 이미 떴으니 화면은 멈추기만 한다 |
| 목록 | `lib/core/api/api_paging.dart` | `fetchAllPages()` — `{items, pageInfo}` 를 마지막 페이지까지 이어 읽는다 |
| 사진 | `server_file.dart` · `file_uploader.dart` · `shared/widgets/photo_image.dart` | 사진 경로 자리에 `server-file:<id>` (내 것이 아닌 서버 경로는 `server-url:<경로>` — 받기 전 명함 미리보기). 기기 사진은 저장할 때 내용으로 형식을 확인해 올린다. 서버 사진은 Dio 로 받아 그린다 |
| QR 스캐너 | `shared/widgets/qr_scanner_page.dart` | `QrScannerPage<T>` — 문구·코드 꺼내기·불러오기만 넘긴다(명함 받기·다이어리 참여·일정 연동). 결과는 `ScanFound` / `ScanTypeCode`. 테스트는 `scannerCameraProvider` 로 카메라를 바꿔 끼운다 |
| 함께 쓰는 기록 | `features/diary` · `features/schedule` | 서버가 준 작성자·멤버의 회원 id 가 나면 `kMeAuthorId`/`isMe` 로 바꿔 담는다. 남의 아이는 내 목록에 없으므로 서버가 준 이름·사진 요약(`targets`)으로 그린다. 기기 저장 모드는 `canShare`/`available` 이 false 라 안내만 띄운다 |
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | 세션(토큰)을 첫 프레임 전에 읽어 override 한다 |
| 도메인 feature(패턴 참고) | `lib/features/pet/` | 저장소 **계약 + `Local*` + `Api*`**, 프로바이더가 `DATA_MODE` 로 고른다. 알림기 build 는 `sessionEpochProvider` 를 watch |
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))`
> - **상태관리**: `features/pet` 의 저장소 계약(Local·Api)→알림기 패턴을 복제. 서버가 이미 한 정리(아이 삭제에 따른 일정 정리 등)는 Api 저장소에서 아무것도 보내지 않는다
> - **소셜 로그인 SDK**: 앱 키를 받으면 `ApiAuthRepository.signIn` 만 SDK 토큰 → `POST /auth/sessions` 로 바꾼다 (지금은 기기 키로 개발용 로그인)
> - **앱 구조**: 라우터 도입 시 `MyApp` 을 `app.dart` 로 분리, `core/{constants,theme,storage}` 채움, 공통 위젯은 `shared/widgets/`
> - **토큰 저장 추상화(선택)**: 필요 시 `core/storage/` 래퍼로 분리
> - **권장 패키지(선택)**: `go_router`·`freezed`·`json_serializable`·`cached_network_image`·`fpdart` 도입 시 `pubspec.yaml` 추가
@@ -162,38 +169,33 @@ class UserWidget extends ConsumerWidget {
## API 통신 (Dio + 인터셉터)
실제 구현(`lib/core/api/dio_client.dart`)은 싱글톤 + `InterceptorsWrapper` 인라인 방식이다. 인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다.
실제 구현(`lib/core/api/dio_client.dart`)은 `InterceptorsWrapper` 인라인 방식이고, `dio_provider.dart` 가 세션·실패 알림을 넣어 만든다.
인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다.
```dart
// core/api/dio_client.dart (실제 구조 요약)
class DioClient {
DioClient._internal() {
// flutter_dotenv 로 .env 로드, 미설정 시 로컬 기본값
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
));
DioClient({required String baseUrl, required SessionStore session, ApiErrorReporter? onError}) {
_dio = Dio(BaseOptions(baseUrl: baseUrl, connectTimeout: ..., receiveTimeout: ...));
_authDio = Dio(...); // 갱신 전용 — 인터셉터 없음
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// jwt_token 읽어 Authorization 헤더 첨부
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
onRequest: (options, handler) {
// 세션의 액세스 토큰을 Authorization 헤더에 붙인다 (토큰은 SessionStore 가 secure storage 에서 읽어 둔다)
},
onResponse: (response, handler) {
// success==true → data 언래핑, false → ApiException 으로 reject
handler.next(response);
// success==true → data 언래핑, false → ApiException 으로 reject (+ 알림)
},
onError: (err, handler) async {
// 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조)
// 토큰 보유 상태의 401 은 AUTH_002(세션 만료)로 승격하고 토큰을 삭제한다.
handler.next(err.copyWith(error: await _toApiException(err)));
// 401 이면 리프레시 토큰으로 갱신해 한 번 다시 보낸다 (동시에 여러 요청이 와도 갱신은 한 번)
// 그래도 실패하면 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조)
// 서버가 갱신을 거절하면 AUTH_002(세션 만료) + 세션 삭제, 서버에 닿지 못했으면 SYS_001
},
));
}
}
// 리포지토리는 ref.watch(dioProvider) 로 주입받는다
final dioProvider = Provider<Dio>((ref) => ref.watch(dioClientProvider).dio);
```
> 인터셉터를 별도 클래스(`AuthInterceptor`/`ErrorInterceptor`)로 분리하고 싶다면 리팩터링 가능하나, 현재는 위 인라인 구조가 SSOT다.
@@ -206,7 +208,8 @@ class DioClient {
// flutter_secure_storage 사용
const storage = FlutterSecureStorage();
// 저장 (키 이름은 dio_client 의 상수로 통일: kJwtTokenKey == 'jwt_token')
// 저장 (키 이름은 session_store 의 상수로 통일: kJwtTokenKey == 'jwt_token', kRefreshTokenKey == 'refresh_token')
// 앱 코드는 직접 쓰지 않고 SessionStore(SecureTokenVault)가 맡는다
await storage.write(key: kJwtTokenKey, value: token);
// 읽기
@@ -242,9 +245,12 @@ await storage.deleteAll();
- [ ] **위젯**: depth 깊어지면 private 위젯 분리, `const` 적극, `build()` 내 비즈니스 로직 금지.
- [ ] **반응형**: 하드코딩 픽셀 금지 → `MediaQuery`/`Expanded`/`Flexible`.
- [ ] **API**: `dio_client.dart` 의 인터셉터 패턴 재사용. 리포지토리에서 try/catch 로 메시지를 만들지 않는다.
- [ ] **새 저장소**: 계약 + `Local*`(기기) + `Api*`(서버), 프로바이더가 `DATA_MODE` 로 고른다. 로그인 전에는 서버를 부르지 않는다. `test/api_*_test.dart` 처럼 가짜 서버(`test/support/fake_api.dart`)로 테스트한다.
- [ ] **화면 실패 처리**: 서버 호출은 `succeeded()` 또는 `catch (e) { if (!isReportedApiFailure(e)) rethrow; }` — 입력을 지운 채 넘어가지 않는다.
- [ ] **사진**: 화면은 `PetPhotoImage`/`PersonAvatar`(→ `PhotoImage`)로 그린다 — 서버 파일·기기 파일을 같은 자리에서 처리한다.
- [ ] **에러 분기**: `e.error as ApiException` 의 **`code` 로 분기**한다. 문구는 사전에서만 온다.
- [ ] **에러 사전**: `error_lexicon.g.dart` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`.
- [ ] **목록**: 응답은 `{items, pageInfo}` — `UserRepository.getUsers()` 패턴 재사용 ([[response-format]]).
- [ ] **목록**: 응답은 `{items, pageInfo}` — `fetchAllPages()` 재사용 ([[response-format]]).
- [ ] **토큰**: `flutter_secure_storage` + 키 상수 `kJwtTokenKey` 통일. `SharedPreferences` 저장 금지.
- [ ] **상태관리**: 새 기능은 Riverpod 프로바이더/노티파이어로 작성 (`ProviderScope` 는 기본 제공).
- [ ] **구조 보완 시**: `app.dart` 분리, `core/{constants,theme,storage}` 생성. 공통 위젯은 `lib/shared/widgets/` 에 둔다(이동 완료).
@@ -253,5 +259,6 @@ await storage.deleteAll();
CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다.
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL`·`DATA_MODE` 설정 (Android 에뮬레이터는 `10.0.2.2`).
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
- [ ] **서버 계약 점검**: 서버 쪽 응답 모양을 바꿨다면 개발 서버를 띄우고 `flutter test test_live` (CI 에서는 돌지 않는다).
+36 -15
View File
@@ -25,24 +25,35 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 전역 ValidationPipe | `main.ts` | `whitelist/forbidNonWhitelisted/transform` |
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터 |
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트 |
| Rate Limiting | `app.module.ts` 의 `ThrottlerGuard`(APP_GUARD) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정 |
| Rate Limiting | `common/guards/throttler.guards.ts` (APP_GUARD) | IP 단위 기본 한도(`THROTTLE_*`) → 인증 → 코드 조회 한도(`@InviteCodeLookup()`, 회원 단위 한 바구니). 초과 시 429 → `REQ_001` |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off |
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
| **인증(JWT)** | `modules/auth` + `common/guards/jwt-auth.guard.ts` (APP_GUARD) | **모든 라우트가 기본으로 로그인 필요** — 공개 라우트만 `@Public()`. 핸들러는 `@CurrentUser()` 로 회원을 받는다. 아래 "인증" 참조 |
| 회원 | `modules/user` | 내 정보 조회·수정·탈퇴(`/users/me`), 소셜 계정 연결 |
| 도메인 모듈(패턴 참고) | `modules/pet` · `schedule` · `diary` · `pet-card` | 새 모듈은 `modules/pet` 을 복제한다 (엔티티·DTO·서비스·컨트롤러·소유자 조회) |
| 커서 페이지네이션 헬퍼 | `common/dto/keyset-pagination.ts` | `keysetPaginate(qb, 정렬키, query)` + `mapPage()`. 목록 Swagger 는 `@ApiPaginatedResponse` |
| 공통 검증 데코레이터 | `common/validators` | `IsIsoDate`·`IsHexColor`·`IsHourMinute`·`IsTargetIdList`·`IsShortStringList` |
| 컬럼 이름 규칙 | `shared/config/snake-naming.strategy.ts` | 엔티티 속성 camelCase ↔ 컬럼 snake_case (database.config.ts 에 연결) |
| 도메인 간 정리 | `modules/pet/pet-lifecycle.ts` | 아이 삭제·보관을 따라 일정·다이어리·명함이 같은 트랜잭션에서 정리 (`onModuleInit` 에서 등록) |
| 파일 업로드 | `modules/upload` | 소유자 기록·매직 바이트 검사·`GET /files/:id`(본인 + 열람 정책, attachment)·`releaseIfUnused()` ([[security]]) |
| 파일 열람 정책 | `modules/upload/file-access-policy.ts` | 남과 함께 쓰는 기능은 `FileAccessPolicy` 를 만들어 `onModuleInit` 에서 `UploadService.registerAccessPolicy()` 로 등록한다 (`diary-file-access.ts`·`calendar-file-access.ts` 참고) |
| 초대 코드 | `shared/utils/invite-code.ts` | `inviteCodeFormat(길이)` — `RUP-` + 혼동 글자 뺀 대문자·숫자. 저장은 `retryOnUniqueViolation()` 으로 충돌 시 다시 만든다 |
| 한도·동시성 잠금 | `shared/utils/db-lock.ts` | `lockFor(manager, LOCK_SCOPE.x, 회원id)` — 트랜잭션 안 advisory lock. 개수 한도·연동처럼 "세고 넣는" 일은 이것부터 잡는다 |
| 회원 탈퇴 정리 | `modules/user/user-lifecycle.ts` | 탈퇴 트랜잭션 안에서 각 모듈이 정리할 파일을 알려 준다 (pet-lifecycle 과 같은 등록 방식) |
| 버려진 파일 정리 | `modules/upload/file-sweeper.ts` | 매시간, 24시간 넘게 아무도 참조하지 않는 파일을 지운다 — 참조 컬럼은 엔티티 메타데이터에서 읽으므로 새 사진 컬럼도 따로 등록할 필요 없다 |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
> - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체).
> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있다.
> - **소셜 로그인 운영**: 제공자 콘솔의 값을 `backend/.env.{env}` 에 넣는다 (`KAKAO_APP_ID`·`NAVER_CLIENT_ID`·`GOOGLE_CLIENT_IDS`·`APPLE_CLIENT_IDS`). 비어 있는 제공자는 로그인을 거절한다
> - **새 엔티티**: 소유자 컬럼(`owner_id`, 회원 삭제 시 CASCADE)을 두고 **조회는 항상 소유자 조건**으로 — 남의 것과 없는 것은 똑같이 `RES_001`.
> 스키마는 **마이그레이션으로 관리**하며 `synchronize` 는 기본 off 다 (아래 "DB 마이그레이션" 참조)
> - **파일을 참조하는 컬럼**: FK 는 `onDelete: 'NO ACTION'` 으로 둔다 — `UploadService.releaseIfUnused()` 가 이 제약으로 "아직 쓰는 파일"을 가린다
> - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes`
> - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
> - `common/pipes/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
---
@@ -181,6 +192,7 @@ SwaggerModule.setup('api-docs', app, document);
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiPaginatedResponse(PetResponseDto, '아이 목록') // 목록 — { items, pageInfo } 로 그린다
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음') // 상태·메시지를 레지스트리에서 가져온다
```
@@ -190,12 +202,20 @@ SwaggerModule.setup('api-docs', app, document);
## 인증 (JWT)
> 📐 **확장 지점.** 이 템플릿은 JWT 인증을 강제하지 않는다(규칙만 제공). **인증이 필요해지면** 아래 규칙대로 구현한다: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증.
`modules/auth` 가 구현되어 있다. 경로는 리소스 이름으로 둔다 ([[api-design]] — 동사 금지).
- Access Token: 만료 시간 짧게 (15분 ~ 1시간)
- Refresh Token: HttpOnly Secure 쿠키로 전달
- `@nestjs/jwt` + `@nestjs/passport` 사용
- Guard를 통한 라우트 보호
| 경로 | 하는 일 |
| --- | --- |
| `POST /auth/sessions` | 소셜 로그인(처음이면 가입) — 앱이 SDK 로 받은 토큰을 **서버가 제공자에게 직접 확인**한다 |
| `POST /auth/dev-sessions` | 개발용 로그인(기기 키) — 소셜 앱 키 전까지. **운영에서는 닫힌다**(RES_001) |
| `POST /auth/tokens` | 토큰 갱신 — 리프레시 토큰을 폐기하고 새 한 쌍 (회전) |
| `POST /auth/token-revocation` | 로그아웃 — 리프레시 토큰 폐기 (204) |
- Access Token: 1시간(`JWT_ACCESS_EXPIRES_IN`), HS256. 페이로드 `{ sub, typ: 'access' }`
- Refresh Token: 불투명 랜덤 값, **DB 에는 SHA-256 해시만**. 폐기된 토큰이 다시 오면 탈취로 보고 그 회원의 토큰을 모두 폐기한다
- 모바일은 리프레시 토큰을 응답 본문으로 받아 secure storage 에 둔다 (웹을 붙이면 HttpOnly 쿠키로 내려주는 경로를 따로 둔다 — [[security]])
- 전역 `JwtAuthGuard` — 토큰이 없거나 틀렸거나 탈퇴한 회원이면 원인을 가리지 않고 AUTH_001
- 로그인 엔드포인트는 전역보다 엄격한 rate limit(분당 10회)을 건다
- 토큰 저장·전송 보안 규칙은 [[security]] 참조.
---
@@ -217,7 +237,7 @@ SwaggerModule.setup('api-docs', app, document);
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD`). 초과 시 429 → `REQ_001`.
라우트 단위 조정은 `@Throttle({ default: { ttl, limit } })`, 제외는 `@SkipThrottle()` (헬스체크에 적용되어 있다).
**rate limiter 를 중복으로 추가하지 말 것** ([[security]])
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `toPaginatedResult()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]])
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `common/dto/keyset-pagination.ts` 의 `keysetPaginate()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]]). Swagger 는 `@ApiPaginatedResponse(Dto)`
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 금지. 레벨 기준·민감정보 마스킹은 [[logging]]
- **Graceful shutdown**: `app.enableShutdownHooks()` 가 적용되어 있다. 종료 시 정리가 필요한 리소스(큐 소비자, 워커, 외부 연결)는
`OnApplicationShutdown` 훅으로 정리한다. **`process.on(signal)` 로 직접 잡지 말 것** — Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다
@@ -270,6 +290,7 @@ dc exec backend npm run migration:run
- 생성된 SQL 을 **검토 없이 커밋하지 않는다.** 컬럼 rename 이 DROP+ADD 로 생성되면 데이터가 날아간다.
- 마이그레이션 파일은 **한번 커밋되면 수정하지 않는다.** 잘못됐으면 새 마이그레이션으로 고친다.
- 운영 배포 시 `migrationsRun: true` 로 자동 실행된다 — 별도 명령이 필요 없다.
테스트 환경(`NODE_ENV=test`)도 자동 실행한다 — CI 의 e2e 는 빈 DB 에서 시작하기 때문이다.
- 빠른 실험이 필요하면 개발 환경에서만 `DB_SYNCHRONIZE=true` 로 임시 활성화하되,
**커밋 전에는 끄고 마이그레이션을 생성**한다. 운영에서는 이 값과 무관하게 항상 비활성화된다.
- 접속 설정은 `shared/config/database.config.ts` 하나만 수정한다 (앱과 CLI 가 공유).
@@ -317,6 +338,6 @@ dc exec backend npm uninstall <패키지>
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
- [ ] **DB 전환 시**: in-memory Map → TypeORM 엔티티/리포지토리로 교체.
- [ ] **인증 추가 시**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/003 흐름 확인.
- [ ] **새 라우트**: 기본으로 로그인이 필요하다 — 공개해야 하는 라우트만 `@Public()`. 회원은 `@CurrentUser()` 로 받는다.
- [ ] **새 엔티티**: 소유자 조건으로만 조회(남의 것 = RES_001), 마이그레이션 생성·검토, 파일 FK 는 NO ACTION.
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
+3 -1
View File
@@ -36,7 +36,9 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
> 두 포트 모두 `vite.config.ts` 가 **루트 `.env.{mode}` 에서 직접 읽는다** — 프론트 쪽에 포트를 중복 작성하지 않는다.
> **확장 시 따르는 규칙:**
> - **로그인 연동**: 실제 `/auth/login` 엔드포인트 연결 → 성공 시 `useAuth` 가 `authStore.markAuthenticated()` 호출
> - **로그인 연동**: 백엔드 인증(`modules/auth`)은 지금 **모바일용**이다 — 소셜 토큰으로 `POST /auth/sessions`, 토큰은 응답 본문.
> 웹을 붙일 때는 리프레시 토큰을 HttpOnly 쿠키로 내려주는 경로를 백엔드에 추가한 뒤 `useAuth` 를 연결하고,
> 성공 시 `authStore.markAuthenticated()` 를 호출한다 ([[security]]). `useAuth`·`useUser` 는 아직 템플릿 예제 경로를 가리킨다 (내 정보는 `GET /users/me`)
> - **로그인 페이지**: 추가 시 라우터 가드 리다이렉트를 `{ name: 'login' }` 으로 변경
> - **공통 컴포넌트**: `BaseButton` 패턴을 따라 `src/components/` 에 추가
+6
View File
@@ -51,6 +51,12 @@ pnpm-debug.log*
.DS_Store
Thumbs.db
# ==========================================
# 디자인 시안 (Claude Design 내보내기) — 로컬 참고용, 개인 사진 포함
# 앱에 필요한 에셋은 flutter/assets/ 로 옮겨 커밋한다
# ==========================================
/.design/
# ==========================================
# 캐시
# ==========================================
+21 -1
View File
@@ -9,7 +9,27 @@
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
# ==========================================
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
# JWT 서명 키 — 환경마다 다른 값을 쓰고 절대 커밋하지 않는다. 운영은 32자 이상 필수.
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
# (개발에서 비워 두면 개발용 고정 키로 서명한다 — modules/auth/auth.config.ts)
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
# ==========================================
# 소셜 로그인 (앱이 SDK 로 받은 토큰을 서버가 제공자에게 확인한다)
#
# 비어 있는 제공자는 로그인을 거절한다(AUTH_001). 값은 각 개발자 콘솔에서 확인한다.
# ==========================================
# 카카오 앱 ID (숫자) — 토큰이 우리 앱에서 발급됐는지 확인한다
KAKAO_APP_ID=
# 네이버 클라이언트 ID — 설정해야 네이버 로그인이 켜진다
NAVER_CLIENT_ID=
# 구글 OAuth 클라이언트 ID (iOS·Android 등 여러 개면 콤마로 구분) — id_token 의 aud
GOOGLE_CLIENT_IDS=
# Apple 번들 ID·서비스 ID (콤마 구분) — id_token 의 aud
APPLE_CLIENT_IDS=
# 개발용 로그인(POST /api/auth/dev-sessions) — 소셜 앱 키 없이 기기 키로 가입한다.
# 운영에서는 이 값과 무관하게 항상 꺼진다. 개발에서도 끄려면 false.
AUTH_DEV_LOGIN=true
+17 -1
View File
@@ -9,7 +9,23 @@
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
# ==========================================
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
# JWT 서명 키 — 환경마다 다른 값을 쓰고 절대 커밋하지 않는다. 운영은 32자 이상 필수.
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
# (개발에서 비워 두면 개발용 고정 키로 서명한다 — modules/auth/auth.config.ts)
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
# ==========================================
# 소셜 로그인 (앱이 SDK 로 받은 토큰을 서버가 제공자에게 확인한다)
#
# 비어 있는 제공자는 로그인을 거절한다(AUTH_001). 값은 각 개발자 콘솔에서 확인한다.
# ==========================================
# 카카오 앱 ID (숫자) — 토큰이 우리 앱에서 발급됐는지 확인한다
KAKAO_APP_ID=
# 네이버 클라이언트 ID — 설정해야 네이버 로그인이 켜진다
NAVER_CLIENT_ID=
# 구글 OAuth 클라이언트 ID (iOS·Android 등 여러 개면 콤마로 구분) — id_token 의 aud
GOOGLE_CLIENT_IDS=
# Apple 번들 ID·서비스 ID (콤마 구분) — id_token 의 aud
APPLE_CLIENT_IDS=
+1119 -958
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -32,6 +32,8 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
@@ -43,6 +45,8 @@
"class-validator": "^0.14.4",
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@@ -61,6 +65,7 @@
"@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"chokidar": "^5.0.0",
"eslint": "^9.18.0",
@@ -79,7 +84,10 @@
"typescript-eslint": "^8.20.0"
},
"overrides": {
"multer": "^2.4.0"
"multer": "^2.4.0",
"@nestjs/swagger": {
"js-yaml": "^5.4.2"
}
},
"jest": {
"moduleFileExtensions": [
+39 -4
View File
@@ -1,13 +1,28 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
// 도메인 일정 모듈(modules/schedule)과 이름이 겹쳐 크론 모듈을 별칭으로 부른다
import { ScheduleModule as CronScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import {
INVITE_THROTTLE,
INVITE_THROTTLER,
} from '@/common/decorators/invite-code-lookup.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import {
InviteThrottlerGuard,
IpThrottlerGuard,
} from '@/common/guards/throttler.guards';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { AuthModule } from '@/modules/auth/auth.module';
import { DiaryModule } from '@/modules/diary/diary.module';
import { HealthModule } from '@/modules/health/health.module';
import { PetModule } from '@/modules/pet/pet.module';
import { PetCardModule } from '@/modules/pet-card/pet-card.module';
import { ScheduleModule } from '@/modules/schedule/schedule.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
import { parsePositiveInt } from '@/shared/utils/env';
@@ -21,7 +36,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
@Module({
imports: [
ScheduleModule.forRoot(),
CronScheduleModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
@@ -36,8 +51,10 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
// 전역 Rate Limiter — API 남용/브루트포스 방지 (프로젝트의 유일한 rate limiting 수단)
// 한도 초과 시 429 → 전역 필터가 REQ_001 로 매핑한다.
// 라우트 단위 조정은 @Throttle({ default: { ttl, limit } }), 제외는 @SkipThrottle().
// 'invite' 는 코드로 찾는 엔드포인트(@InviteCodeLookup)만 회원별로 한 통에서 센다 (common/guards/throttler.guards.ts)
ThrottlerModule.forRoot([
{
name: 'default',
// compose 가 미정의 변수를 빈 문자열로 주입하므로 parsePositiveInt 로 방어한다
ttl: parsePositiveInt(
process.env.THROTTLE_TTL_MS,
@@ -48,6 +65,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
THROTTLE_DEFAULT_LIMIT,
),
},
{ name: INVITE_THROTTLER, ...INVITE_THROTTLE },
]),
// DB 접속 설정은 database.config.ts 하나만 수정한다 (CLI 와 공유)
TypeOrmModule.forRootAsync({
@@ -60,12 +78,29 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
HealthModule,
UploadModule,
UserModule,
AuthModule,
PetModule,
ScheduleModule,
DiaryModule,
PetCardModule,
],
providers: [
ShutdownLogger,
// 가드는 등록한 순서대로 돈다 — IP 한도 → 인증 → 코드 조회 한도(회원별)
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
useClass: IpThrottlerGuard,
},
// 전역 인증 — 모든 라우트가 기본으로 로그인을 요구한다. 예외는 @Public() 으로 연다.
// (rate limit 이 먼저 돌아 토큰 없는 대량 요청도 한도에 걸린다)
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// 코드 조회 한도는 회원을 알아야 하므로 인증 다음에 둔다
{
provide: APP_GUARD,
useClass: InviteThrottlerGuard,
},
],
})
@@ -1,6 +1,7 @@
import { Type, applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import { ErrorCode, getErrorDefinition } from '@/common/errors/error-codes';
/**
@@ -38,6 +39,38 @@ export function ApiStandardResponse<TModel extends Type<unknown>>(
);
}
/**
* 목록(페이지네이션) 응답을 문서화한다 — 실제 본문은 { success, data: { items, pageInfo } } 다.
* `ApiStandardResponse(Dto, { isArray: true })` 는 data 를 배열로 그려 실응답과 어긋난다.
*
* 사용 예:
* @ApiPaginatedResponse(PetResponseDto, '아이 목록')
*/
export function ApiPaginatedResponse<TModel extends Type<unknown>>(
model: TModel,
description = '목록 조회 성공',
) {
return applyDecorators(
ApiExtraModels(model, PageInfoDto),
ApiResponse({
status: 200,
description,
schema: {
properties: {
success: { type: 'boolean', example: true },
data: {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: getSchemaPath(model) } },
pageInfo: { $ref: getSchemaPath(PageInfoDto) },
},
},
},
},
}),
);
}
/**
* 표준 실패 응답을 에러 코드와 함께 문서화한다.
* 상태 코드·메시지는 레지스트리에서 가져오므로 문서와 구현이 자동으로 일치한다.
@@ -0,0 +1,24 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
import type { Request } from 'express';
import { BusinessException } from '@/common/errors/business.exception';
/** 액세스 토큰에서 꺼낸 로그인 사용자 — JwtStrategy.validate 가 만든다 */
export interface AuthUser {
readonly userId: string;
}
/**
* 로그인한 사용자를 핸들러 인자로 받는다.
* findMine(@CurrentUser() user: AuthUser) { ... }
* @Public() 라우트에서는 쓰지 않는다 — 사용자가 없으면 AUTH_001 이다.
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): AuthUser => {
const request = context
.switchToHttp()
.getRequest<Request & { user?: AuthUser }>();
if (!request.user) throw new BusinessException('AUTH_001');
return request.user;
},
);
@@ -0,0 +1,17 @@
import { SetMetadata } from '@nestjs/common';
/**
* 코드로 남의 것을 찾는 엔드포인트의 한도 — 명함 코드·다이어리 초대 코드·일정 연동 코드 모두 **한 통**에서 센다.
* 엔드포인트마다 따로 세면 여러 곳을 돌아가며 코드를 짐작할 수 있다.
* 회원별(로그인 전이면 IP별) 분당 30회 — 사진 몇 장을 포함해 명함 몇 장을 받거나 코드를 몇 번 잘못 입력해도 충분하다.
*/
export const INVITE_THROTTLER = 'invite';
export const INVITE_THROTTLE = { ttl: 60_000, limit: 30 } as const;
export const INVITE_CODE_LOOKUP_KEY = 'inviteCodeLookup';
/**
* 코드로 찾는 엔드포인트에 붙인다 — InviteThrottlerGuard 가 회원별로 함께 센다 (common/guards/throttler.guards.ts).
* 컨트롤러에 붙이면 그 안의 모든 라우트에 적용된다.
*/
export const InviteCodeLookup = () => SetMetadata(INVITE_CODE_LOOKUP_KEY, true);
@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/**
* 로그인 없이 부를 수 있는 엔드포인트에 붙인다.
* 전역 JwtAuthGuard 가 기본으로 모든 라우트를 보호하므로, 예외만 이 데코레이터로 연다
* (로그인·토큰 갱신·헬스체크 등). 빠뜨려서 열리는 일은 없고, 빠뜨리면 닫힌다.
*/
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,64 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { CursorValue, decodeCursor, keysetPage } from '@/shared/utils/cursor';
import { PaginatedResult, toPaginatedResult } from './pagination.dto';
/** 커서 정렬 키 — 앞의 키부터 오름차순으로 정렬하고, 마지막 키(보통 id)로 순서를 확정한다 */
export interface KeysetColumn<T> {
/** 쿼리 빌더 별칭을 붙인 컬럼 (예: `pet.sort_order`) */
readonly column: string;
/** 커서 값을 비교할 때 쓰는 DB 타입 (예: `integer`·`timestamptz`·`uuid`) */
readonly cast: string;
/** 행에서 커서에 담을 값 */
readonly read: (row: T) => CursorValue;
}
/**
* 커서 기반 목록 조회 — [query] 의 cursor 다음부터 limit 개를 읽는다.
* total 은 커서 조건 없이 센 전체 개수다. 형식이 틀린 커서는 VAL_001.
*/
export async function keysetPaginate<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
columns: readonly KeysetColumn<T>[],
query: { readonly limit: number; readonly cursor?: string },
): Promise<PaginatedResult<T>> {
const total = await qb.clone().getCount();
if (query.cursor !== undefined) {
const values = decodeCursor(query.cursor, columns.length);
if (!values) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid-cursor' },
});
}
const left = columns.map((c) => c.column).join(', ');
const right = columns.map((c, i) => `:cursor${i}::${c.cast}`).join(', ');
qb.andWhere(
`(${left}) > (${right})`,
Object.fromEntries(values.map((v, i) => [`cursor${i}`, v])),
);
}
columns.forEach((c, i) =>
i === 0 ? qb.orderBy(c.column, 'ASC') : qb.addOrderBy(c.column, 'ASC'),
);
const rows = await qb.limit(query.limit + 1).getMany();
const page = keysetPage(rows, query.limit, (row) =>
columns.map((c) => c.read(row)),
);
return toPaginatedResult(page.items, {
hasNext: page.hasNext,
nextCursor: page.nextCursor,
total,
});
}
/** 목록의 각 항목을 응답 DTO 로 바꾼다 (pageInfo 는 그대로) */
export function mapPage<T, R>(
page: PaginatedResult<T>,
map: (item: T) => R,
): PaginatedResult<R> {
return { items: page.items.map(map), pageInfo: page.pageInfo };
}
@@ -2,6 +2,7 @@ import { HttpStatus } from '@nestjs/common';
import {
ERROR_CODES,
ErrorCodeDefinition,
FALLBACK_ERROR_CODE,
getErrorDefinition,
isErrorCode,
@@ -46,7 +47,9 @@ describe('에러 코드 레지스트리', () => {
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
const clientOnlyCodes = Object.entries(ERROR_CODES)
.filter(([, definition]) => definition.clientOnly)
.filter(([, definition]: [string, ErrorCodeDefinition]) =>
Boolean(definition.clientOnly),
)
.map(([code]) => code);
expect(clientOnlyCodes).toContain('AUTH_002');
+76
View File
@@ -73,6 +73,82 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.PAYLOAD_TOO_LARGE,
message: '파일 크기가 허용 범위를 초과했습니다.',
},
// 다이어리 — 책장에는 최대 6권 (추억 보관함의 아이만 담은 다이어리는 세지 않는다)
DIARY_001: {
status: HttpStatus.CONFLICT,
message: '다이어리는 최대 6권까지 만들 수 있어요.',
},
// 다이어리 — 한 사람이 한 다이어리에 하루 한 번 기록한다
DIARY_002: {
status: HttpStatus.CONFLICT,
message: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.',
},
// 공유 다이어리 — 초대 코드에 맞는(공유 중인) 다이어리가 없다
DIARY_003: {
status: HttpStatus.NOT_FOUND,
message: '초대 코드를 찾을 수 없어요. 코드를 다시 확인해 주세요.',
},
// 공유 다이어리 — 함께하는 사람은 주인 포함 최대 4명
DIARY_004: {
status: HttpStatus.CONFLICT,
message: '참여 인원이 가득 찼어요. (최대 4명)',
},
// 공유 다이어리 — 이미 함께하고 있다 (주인 포함)
DIARY_005: {
status: HttpStatus.CONFLICT,
message: '이미 참여하고 있는 다이어리예요.',
},
// 일정 연동 — 초대 코드에 맞는 회원이 없다
SCHED_001: {
status: HttpStatus.NOT_FOUND,
message: '초대 코드를 찾을 수 없어요. 코드를 다시 확인해 주세요.',
},
// 일정 연동 — 한 묶음은 최대 4명
SCHED_002: {
status: HttpStatus.CONFLICT,
message: '이미 4명이 연동되어 있어요.',
},
// 일정 연동 — 내 코드로는 연동할 수 없다
SCHED_003: {
status: HttpStatus.BAD_REQUEST,
message: '내 초대 코드로는 연동할 수 없어요.',
},
// 일정 연동 — 한 사람은 한 묶음에만 든다 (해제 후 다시)
SCHED_004: {
status: HttpStatus.CONFLICT,
message:
'이미 다른 사람과 연동되어 있어요. 연동을 해제한 뒤 다시 시도해 주세요.',
},
// 일정 연동 — 코드 주인과 이미 같은 묶음이다
SCHED_005: {
status: HttpStatus.CONFLICT,
message: '이미 연동된 사람이에요.',
},
// 펫 명함 — 한 사람이 최대 10장
CARD_001: {
status: HttpStatus.CONFLICT,
message: '펫 명함은 최대 10장까지 만들 수 있어요.',
},
// 명함 받기 — 코드에 맞는 명함이 없다
CARD_002: {
status: HttpStatus.NOT_FOUND,
message: '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.',
},
// 명함 받기 — 내 명함은 받을 수 없다
CARD_003: {
status: HttpStatus.BAD_REQUEST,
message: '내 명함은 받을 수 없어요.',
},
// 명함 받기 — 같은 명함이 이미 보관함에 있다
CARD_004: {
status: HttpStatus.CONFLICT,
message: '이미 보관함에 있는 명함이에요.',
},
// 명함 받기 — 보관함은 최대 100장
CARD_005: {
status: HttpStatus.CONFLICT,
message: '보관함이 가득 찼어요. (최대 100장)',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -0,0 +1,35 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '@/common/decorators/public.decorator';
import { BusinessException } from '@/common/errors/business.exception';
/**
* 전역 인증 가드 (app.module.ts 의 APP_GUARD).
* 모든 라우트는 기본으로 액세스 토큰이 필요하고, @Public() 이 붙은 라우트만 연다.
* 토큰이 없거나 틀렸거나 만료됐으면 원인을 가리지 않고 AUTH_001 로 응답한다 (.claude/rules/security.md).
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
override canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
override handleRequest<TUser>(err: unknown, user: TUser | false): TUser {
if (err || !user) {
// passport 가 던진 예외(영문 메시지)를 그대로 내보내지 않고 에러 코드로 바꾼다
throw new BusinessException('AUTH_001');
}
return user;
}
}
@@ -0,0 +1,73 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import {
INVITE_CODE_LOOKUP_KEY,
INVITE_THROTTLER,
} from '@/common/decorators/invite-code-lookup.decorator';
/**
* Rate limit 가드 둘 — 같은 ThrottlerModule 설정(app.module.ts)을 나눠 쓴다.
* throttler 는 등록된 이름 있는 한도를 모든 라우트에 적용하므로 가드마다 자기 한도만 남긴다.
*
* 1. IpThrottlerGuard — 인증보다 먼저, IP 별로 모든 요청을 센다 (토큰 없는 대량 요청도 막는다)
* 2. InviteThrottlerGuard — 인증 다음, 코드로 찾는 엔드포인트(@InviteCodeLookup)만 회원별로 한 통에서 센다
*/
/** 기본 한도 — IP 별, 라우트별. 'invite' 한도는 여기서 세지 않는다 */
@Injectable()
export class IpThrottlerGuard extends ThrottlerGuard {
override async onModuleInit(): Promise<void> {
await super.onModuleInit();
this.throttlers = this.throttlers.filter(
(t) => t.name !== INVITE_THROTTLER,
);
}
}
/** 요청한 쪽 — 로그인했으면 회원, 아니면 IP */
export function inviteTrackerOf(req: {
user?: { userId?: string };
ip?: string;
}): string {
const userId = req.user?.userId;
return userId ? `user:${userId}` : `ip:${req.ip ?? 'unknown'}`;
}
/** 엔드포인트와 무관한 한 통 — 같은 회원이면 어느 코드 엔드포인트든 같은 칸에서 센다 */
export function inviteBucketKey(name: string, tracker: string): string {
return `${name}:${tracker}`;
}
/** 코드 조회 한도 — @InviteCodeLookup 이 붙은 라우트만, 회원별로 한 통에서 센다 */
@Injectable()
export class InviteThrottlerGuard extends ThrottlerGuard {
override async onModuleInit(): Promise<void> {
await super.onModuleInit();
this.throttlers = this.throttlers.filter(
(t) => t.name === INVITE_THROTTLER,
);
}
protected override shouldSkip(context: ExecutionContext): Promise<boolean> {
const marked = this.reflector.getAllAndOverride<boolean>(
INVITE_CODE_LOOKUP_KEY,
[context.getHandler(), context.getClass()],
);
return Promise.resolve(!marked);
}
protected override getTracker(req: Record<string, unknown>): Promise<string> {
return Promise.resolve(
inviteTrackerOf(req as { user?: { userId?: string }; ip?: string }),
);
}
protected override generateKey(
_context: ExecutionContext,
tracker: string,
name: string,
): string {
return inviteBucketKey(name, tracker);
}
}
+106
View File
@@ -0,0 +1,106 @@
import { applyDecorators } from '@nestjs/common';
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsString,
Matches,
MaxLength,
ValidateBy,
ValidationOptions,
buildMessage,
} from 'class-validator';
import { isValidIsoDate } from '@/shared/utils/iso-date';
/** `#RRGGBB` 색 — 앱(color_utils hexOf)이 쓰는 형식 */
export const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/;
/** 시각 없는 날짜 `YYYY-MM-DD` — 달력에 있는 날짜만 */
export function IsIsoDate(options?: ValidationOptions): PropertyDecorator {
return ValidateBy(
{
name: 'isIsoDate',
validator: {
validate: (value: unknown) =>
typeof value === 'string' && isValidIsoDate(value),
defaultMessage: buildMessage(
() => '날짜는 YYYY-MM-DD 형식으로 입력해 주세요.',
options,
),
},
},
options,
);
}
/** `#RRGGBB` 색 */
export function IsHexColor(message = '색 값이 올바르지 않습니다.') {
return Matches(HEX_COLOR_PATTERN, { message });
}
/** 짧은 문자열 목록 (알레르기·질환처럼 칩으로 고르는 값) */
export function IsShortStringList(maxItems: number, maxLength: number) {
return applyDecorators(
IsArray({ message: '목록 형식이 올바르지 않습니다.' }),
ArrayMaxSize(maxItems, {
message: `최대 ${maxItems}개까지 입력할 수 있습니다.`,
}),
IsString({ each: true, message: '목록의 값은 문자로 입력해 주세요.' }),
MaxLength(maxLength, {
each: true,
message: `목록의 값은 ${maxLength}자 이하로 입력해 주세요.`,
}),
);
}
/** 일정·다이어리 대상의 "나(보호자 본인)" — 앱의 kSelfTargetId 와 같다 */
export const SELF_TARGET_ID = 'self';
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* 대상 id 를 저장 형식으로 맞춘다 — UUID 는 소문자로. DB 의 uuid 는 소문자로 돌려주므로
* 대문자로 저장되면 target_ids 의 array_remove · ::text = ANY 비교가 어긋난다 (아이를 지워도 대상에서 안 빠진다).
*/
export function normalizeTargetIds(value: unknown): unknown {
if (!Array.isArray(value)) return value;
return value.map((v: unknown) =>
typeof v === 'string' && UUID_PATTERN.test(v) ? v.toLowerCase() : v,
);
}
/**
* 대상 목록 — 각 값은 "self" 이거나 아이 id(UUID, 소문자로 맞춘다). 기본은 1개 이상(일정), 다이어리는 0개도 된다.
* 형식만 본다 — 내 아이인지는 서비스가 PetService.assertOwnedTargets 로 확인한다.
*/
export function IsTargetIdList(maxItems: number, minItems = 1) {
return applyDecorators(
Transform(({ value }: { value: unknown }) => normalizeTargetIds(value)),
IsArray({ message: '대상을 골라 주세요.' }),
ArrayMinSize(minItems, { message: '대상을 골라 주세요.' }),
ArrayMaxSize(maxItems, { message: `대상은 최대 ${maxItems}개까지예요.` }),
ValidateBy({
name: 'isTargetId',
validator: {
validate: (value: unknown) =>
Array.isArray(value) &&
value.every(
(v) =>
typeof v === 'string' &&
(v === SELF_TARGET_ID || UUID_PATTERN.test(v)),
),
defaultMessage: () => '대상 정보가 올바르지 않습니다.',
},
}),
);
}
/** 하루 중 시각 `HH:mm` (00:00 ~ 23:59) */
export const HOUR_MINUTE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
export function IsHourMinute(message = '시각은 HH:mm 형식으로 입력해 주세요.') {
return Matches(HOUR_MINUTE_PATTERN, { message });
}
@@ -0,0 +1,32 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { IsTargetIdList, normalizeTargetIds } from './index';
class TargetsDto {
@IsTargetIdList(5)
targetIds!: string[];
}
const UPPER = 'A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D';
describe('대상 id', () => {
it('UUID 는 소문자로 맞추고 self 는 그대로 둔다', () => {
expect(normalizeTargetIds([UPPER, 'self'])).toEqual([
UPPER.toLowerCase(),
'self',
]);
});
it('배열이 아니거나 UUID 가 아닌 값은 손대지 않는다 (검증이 거른다)', () => {
expect(normalizeTargetIds('self')).toBe('self');
expect(normalizeTargetIds(['Not-A-Uuid', 3])).toEqual(['Not-A-Uuid', 3]);
});
it('요청 DTO 로 들어오면 검증 전에 소문자로 바뀐다', () => {
const dto = plainToInstance(TargetsDto, { targetIds: [UPPER, 'self'] });
expect(validateSync(dto)).toHaveLength(0);
expect(dto.targetIds).toEqual([UPPER.toLowerCase(), 'self']);
});
});
@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAuthUsersFiles1791705189542 implements MigrationInterface {
name = 'AddAuthUsersFiles1791705189542';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "stored_name" character varying(64) NOT NULL, "original_name" character varying(255) NOT NULL, "mime_type" character varying(100) NOT NULL, "size" integer NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_dea1609c1d56367529ace1e1530" UNIQUE ("stored_name"), CONSTRAINT "PK_6c16b9093a142e0e7613b04a3d9" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_4bc1db1f4f34ec9415acd88afd" ON "files" ("owner_id") `,
);
await queryRunner.query(
`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "nickname" character varying(20), "photo_file_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "social_accounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" uuid NOT NULL, "provider" character varying(10) NOT NULL, "subject" character varying(255) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_820de75e7b1d6872a798f40850a" UNIQUE ("provider", "subject"), CONSTRAINT "PK_e9e58d2d8e9fafa20af914d9750" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_05a0f282d3bed93ca048a7e54d" ON "social_accounts" ("user_id") `,
);
await queryRunner.query(
`CREATE TABLE "refresh_tokens" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" uuid NOT NULL, "token_hash" character(64) NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_a7838d2ba25be1342091b6695f1" UNIQUE ("token_hash"), CONSTRAINT "PK_7d8bee0204106019488c4c50ffa" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_3ddc983c5f7bcf132fd8732c3f" ON "refresh_tokens" ("user_id") `,
);
await queryRunner.query(
`ALTER TABLE "files" ADD CONSTRAINT "FK_4bc1db1f4f34ec9415acd88afdb" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "users" ADD CONSTRAINT "FK_e4313545e4345b359e66bdbebd8" FOREIGN KEY ("photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "social_accounts" ADD CONSTRAINT "FK_05a0f282d3bed93ca048a7e54dd" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "refresh_tokens" ADD CONSTRAINT "FK_3ddc983c5f7bcf132fd8732c3f4" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "refresh_tokens" DROP CONSTRAINT "FK_3ddc983c5f7bcf132fd8732c3f4"`,
);
await queryRunner.query(
`ALTER TABLE "social_accounts" DROP CONSTRAINT "FK_05a0f282d3bed93ca048a7e54dd"`,
);
await queryRunner.query(
`ALTER TABLE "users" DROP CONSTRAINT "FK_e4313545e4345b359e66bdbebd8"`,
);
await queryRunner.query(
`ALTER TABLE "files" DROP CONSTRAINT "FK_4bc1db1f4f34ec9415acd88afdb"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_3ddc983c5f7bcf132fd8732c3f"`,
);
await queryRunner.query(`DROP TABLE "refresh_tokens"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_05a0f282d3bed93ca048a7e54d"`,
);
await queryRunner.query(`DROP TABLE "social_accounts"`);
await queryRunner.query(`DROP TABLE "users"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_4bc1db1f4f34ec9415acd88afd"`,
);
await queryRunner.query(`DROP TABLE "files"`);
}
}
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPets1791706303884 implements MigrationInterface {
name = 'AddPets1791706303884';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "pets" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "sort_order" integer NOT NULL, "tint" character varying(7) NOT NULL, "name" character varying(30) NOT NULL, "species" character varying(20) NOT NULL, "breed" character varying(60) NOT NULL DEFAULT '', "gender" character varying(6) NOT NULL, "neutered" boolean NOT NULL, "birth_date" date NOT NULL, "weight" double precision NOT NULL, "weight_unit" character varying(2) NOT NULL, "photo_file_id" uuid, "microchip" character varying(30), "blood_type" character varying(20), "vet" character varying(60), "memo" character varying(1000), "allergies" text array NOT NULL DEFAULT '{}', "conditions" text array NOT NULL DEFAULT '{}', "watch_conditions" text array NOT NULL DEFAULT '{}', "archive_from" date, "archive_to" date, "archived_at" TIMESTAMP WITH TIME ZONE, "archive_memo" character varying(1000), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_d01e9e7b4ada753c826720bee8b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_d6c565fded8031d4cdd54fe104" ON "pets" ("owner_id") `,
);
await queryRunner.query(
`ALTER TABLE "pets" ADD CONSTRAINT "FK_d6c565fded8031d4cdd54fe1043" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "pets" ADD CONSTRAINT "FK_816320a5aa9d3cafbdddcdde33c" FOREIGN KEY ("photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "pets" DROP CONSTRAINT "FK_816320a5aa9d3cafbdddcdde33c"`,
);
await queryRunner.query(
`ALTER TABLE "pets" DROP CONSTRAINT "FK_d6c565fded8031d4cdd54fe1043"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_d6c565fded8031d4cdd54fe104"`,
);
await queryRunner.query(`DROP TABLE "pets"`);
}
}
@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSchedule1791706872774 implements MigrationInterface {
name = 'AddSchedule1791706872774';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "schedule_events" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "date" date NOT NULL, "is_done" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "title" character varying(50) NOT NULL, "kind" character varying(12) NOT NULL, "category" character varying(12) NOT NULL, "target_ids" text array NOT NULL, "start_time" character varying(5), "end_time" character varying(5), "place" character varying(60), "label_color" character varying(7), CONSTRAINT "PK_c14624cf0aa0f238ace86e789aa" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_3351bce510c509278ac3d0a5ad" ON "schedule_events" ("owner_id", "date") `,
);
await queryRunner.query(
`CREATE TABLE "repeat_rules" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "from_date" date NOT NULL, "mode" character varying(10) NOT NULL, "unit" character varying(5) NOT NULL, "n" smallint NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "title" character varying(50) NOT NULL, "kind" character varying(12) NOT NULL, "category" character varying(12) NOT NULL, "target_ids" text array NOT NULL, "start_time" character varying(5), "end_time" character varying(5), "place" character varying(60), "label_color" character varying(7), CONSTRAINT "PK_a77f6c9f8f34779343443201bbc" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_c3fbe53b49bb6e4c5fb15deb85" ON "repeat_rules" ("owner_id") `,
);
await queryRunner.query(
`CREATE TABLE "repeat_rule_completions" ("rule_id" uuid NOT NULL, "date" date NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_087b6885d4535901d3387d3f8a7" PRIMARY KEY ("rule_id", "date"))`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_d5be69de4c90f931ccede796f1a" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "repeat_rules" ADD CONSTRAINT "FK_c3fbe53b49bb6e4c5fb15deb850" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "repeat_rule_completions" ADD CONSTRAINT "FK_e400c99c6640f922ca900709adc" FOREIGN KEY ("rule_id") REFERENCES "repeat_rules"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "repeat_rule_completions" DROP CONSTRAINT "FK_e400c99c6640f922ca900709adc"`,
);
await queryRunner.query(
`ALTER TABLE "repeat_rules" DROP CONSTRAINT "FK_c3fbe53b49bb6e4c5fb15deb850"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_d5be69de4c90f931ccede796f1a"`,
);
await queryRunner.query(`DROP TABLE "repeat_rule_completions"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_c3fbe53b49bb6e4c5fb15deb85"`,
);
await queryRunner.query(`DROP TABLE "repeat_rules"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_3351bce510c509278ac3d0a5ad"`,
);
await queryRunner.query(`DROP TABLE "schedule_events"`);
}
}
@@ -0,0 +1,83 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDiaries1791707431165 implements MigrationInterface {
name = 'AddDiaries1791707431165';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "diaries" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "name" character varying(18) NOT NULL, "target_ids" text array NOT NULL DEFAULT '{}', "ribbon" character varying(7) NOT NULL, "tint" character varying(7) NOT NULL, "cover_file_id" uuid, "muted_notices" text array NOT NULL DEFAULT '{}', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_ffd738e7d40dcfa59283dcaae87" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_66a03e13cfd64933272bd90721" ON "diaries" ("owner_id") `,
);
await queryRunner.query(
`CREATE TABLE "diary_posts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "diary_id" uuid NOT NULL, "author_id" uuid NOT NULL, "date" date NOT NULL, "text" character varying(2000) NOT NULL, "mood" character varying(10), "photo_file_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_582c1c407ea8c8288009260222d" UNIQUE ("diary_id", "author_id", "date"), CONSTRAINT "PK_e5a9335997f1abec691b448fdd3" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_a9bbbf83bc3e7765878dec1f10" ON "diary_posts" ("diary_id") `,
);
await queryRunner.query(
`CREATE TABLE "diary_comments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "post_id" uuid NOT NULL, "author_id" uuid NOT NULL, "text" character varying(50) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_02bcbae3dcc56ec77d072c232d4" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7a76737dfe5d0818b867696df6" ON "diary_comments" ("post_id") `,
);
await queryRunner.query(
`ALTER TABLE "diaries" ADD CONSTRAINT "FK_66a03e13cfd64933272bd90721a" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diaries" ADD CONSTRAINT "FK_49e7cfa49fed5dd22a4e8c25dab" FOREIGN KEY ("cover_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" ADD CONSTRAINT "FK_a9bbbf83bc3e7765878dec1f10e" FOREIGN KEY ("diary_id") REFERENCES "diaries"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" ADD CONSTRAINT "FK_c25aa8312dc21996eb00531bb68" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" ADD CONSTRAINT "FK_07e1d0110b6bf6db265e9963882" FOREIGN KEY ("photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_comments" ADD CONSTRAINT "FK_7a76737dfe5d0818b867696df6b" FOREIGN KEY ("post_id") REFERENCES "diary_posts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_comments" ADD CONSTRAINT "FK_ac7b686ceee2547592e05072dca" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "diary_comments" DROP CONSTRAINT "FK_ac7b686ceee2547592e05072dca"`,
);
await queryRunner.query(
`ALTER TABLE "diary_comments" DROP CONSTRAINT "FK_7a76737dfe5d0818b867696df6b"`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" DROP CONSTRAINT "FK_07e1d0110b6bf6db265e9963882"`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" DROP CONSTRAINT "FK_c25aa8312dc21996eb00531bb68"`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" DROP CONSTRAINT "FK_a9bbbf83bc3e7765878dec1f10e"`,
);
await queryRunner.query(
`ALTER TABLE "diaries" DROP CONSTRAINT "FK_49e7cfa49fed5dd22a4e8c25dab"`,
);
await queryRunner.query(
`ALTER TABLE "diaries" DROP CONSTRAINT "FK_66a03e13cfd64933272bd90721a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7a76737dfe5d0818b867696df6"`,
);
await queryRunner.query(`DROP TABLE "diary_comments"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_a9bbbf83bc3e7765878dec1f10"`,
);
await queryRunner.query(`DROP TABLE "diary_posts"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_66a03e13cfd64933272bd90721"`,
);
await queryRunner.query(`DROP TABLE "diaries"`);
}
}
@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPetCards1791707889244 implements MigrationInterface {
name = 'AddPetCards1791707889244';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "pet_cards" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "pet_id" uuid NOT NULL, "code" character varying(8) NOT NULL, "tint" character varying(7) NOT NULL, "tint2" character varying(7) NOT NULL, "name_bg" character varying(7), "name_bg_asset" character varying(120), "name_bg_file_id" uuid, "text_light" boolean NOT NULL DEFAULT false, "scrim" boolean NOT NULL DEFAULT false, "scrim_tone" character varying(5) NOT NULL DEFAULT 'light', "is_representative" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_b0cdb9b6d8843b8ccdd8189475e" UNIQUE ("code"), CONSTRAINT "PK_cb97f59edc0aab4f315acd5c1fa" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_71ffe780c984a78a864a07465f" ON "pet_cards" ("owner_id") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_pet_cards_representative" ON "pet_cards" ("pet_id") WHERE "is_representative"`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_71ffe780c984a78a864a07465f8" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_5d2fc9ac2b34f34dfaca776fc7f" FOREIGN KEY ("pet_id") REFERENCES "pets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_672dc0c78f4a9ffde6239f1db86" FOREIGN KEY ("name_bg_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "pet_cards" DROP CONSTRAINT "FK_672dc0c78f4a9ffde6239f1db86"`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" DROP CONSTRAINT "FK_5d2fc9ac2b34f34dfaca776fc7f"`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" DROP CONSTRAINT "FK_71ffe780c984a78a864a07465f8"`,
);
await queryRunner.query(
`DROP INDEX "public"."UQ_pet_cards_representative"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_71ffe780c984a78a864a07465f"`,
);
await queryRunner.query(`DROP TABLE "pet_cards"`);
}
}
@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddReceivedCards1791714286898 implements MigrationInterface {
name = 'AddReceivedCards1791714286898';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "received_cards" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "source_card_id" uuid, "code" character varying(8) NOT NULL, "mode" character varying(6) NOT NULL, "sender_nickname" character varying(20), "sender_photo_file_id" uuid, "pet" jsonb NOT NULL, "pet_photo_file_id" uuid, "style" jsonb NOT NULL, "background_file_id" uuid, "note" character varying(200), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_43872dae929b5dd22d6b81e744d" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_3b8508528d21833ebec7c3e7b1" ON "received_cards" ("owner_id") `,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD "share_mode" character varying(6) NOT NULL DEFAULT 'simple'`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_3b8508528d21833ebec7c3e7b1b" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_65e8b70115d2de13ce0380b8ade" FOREIGN KEY ("source_card_id") REFERENCES "pet_cards"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_ea06e251a9ed53e63e66ef5bb65" FOREIGN KEY ("sender_photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_21733fe1b0dfce3e27726b322e6" FOREIGN KEY ("pet_photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_549a5fe176831cb7cebb29f9e22" FOREIGN KEY ("background_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_549a5fe176831cb7cebb29f9e22"`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_21733fe1b0dfce3e27726b322e6"`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_ea06e251a9ed53e63e66ef5bb65"`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_65e8b70115d2de13ce0380b8ade"`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_3b8508528d21833ebec7c3e7b1b"`,
);
await queryRunner.query(`ALTER TABLE "pet_cards" DROP COLUMN "share_mode"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_3b8508528d21833ebec7c3e7b1"`,
);
await queryRunner.query(`DROP TABLE "received_cards"`);
}
}
@@ -0,0 +1,70 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 공유 다이어리 — 함께하는 사람(diary_members)을 두고, 알림 설정을 사람별로 옮긴다.
* 자동 생성본은 diaries.muted_notices 를 먼저 지워 설정이 사라지므로 순서를 손으로 바꿨다:
* 표를 만들고 → 지금 있는 다이어리마다 주인 한 줄을 넣으며 알림 설정을 옮긴 뒤 → 예전 컬럼을 지운다.
*/
export class AddDiaryMembers1791717026547 implements MigrationInterface {
name = 'AddDiaryMembers1791717026547';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "diary_members" ("diary_id" uuid NOT NULL, "user_id" uuid NOT NULL, "muted_notices" text array NOT NULL DEFAULT '{}', "joined_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_9ee05cf02f0820ef2b8855620ff" PRIMARY KEY ("diary_id", "user_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_9efd539d83a9f2e26f4cb34e7f" ON "diary_members" ("user_id") `,
);
await queryRunner.query(
`ALTER TABLE "diary_members" ADD CONSTRAINT "FK_0780a472fccfacd4889777672d9" FOREIGN KEY ("diary_id") REFERENCES "diaries"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_members" ADD CONSTRAINT "FK_9efd539d83a9f2e26f4cb34e7f4" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
// 지금 있는 다이어리 — 주인을 함께하는 사람으로 넣고 알림 설정을 옮긴다 (참여한 때 = 만든 때)
await queryRunner.query(
`INSERT INTO "diary_members" ("diary_id", "user_id", "muted_notices", "joined_at") SELECT "id", "owner_id", "muted_notices", "created_at" FROM "diaries"`,
);
await queryRunner.query(
`ALTER TABLE "diaries" ADD "shared" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "diaries" ADD "invite_code" character varying(10)`,
);
await queryRunner.query(
`ALTER TABLE "diaries" ADD CONSTRAINT "UQ_c73eaac887ff63f901ab189de80" UNIQUE ("invite_code")`,
);
await queryRunner.query(
`ALTER TABLE "diaries" DROP COLUMN "muted_notices"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 알림 설정을 다이어리로 되돌린다 — 주인의 설정만 남는다 (참여한 사람의 설정·참여 기록은 사라진다)
await queryRunner.query(
`ALTER TABLE "diaries" ADD "muted_notices" text array NOT NULL DEFAULT '{}'`,
);
await queryRunner.query(
`UPDATE "diaries" d SET "muted_notices" = m."muted_notices" FROM "diary_members" m WHERE m."diary_id" = d."id" AND m."user_id" = d."owner_id"`,
);
await queryRunner.query(
`ALTER TABLE "diaries" DROP CONSTRAINT "UQ_c73eaac887ff63f901ab189de80"`,
);
await queryRunner.query(`ALTER TABLE "diaries" DROP COLUMN "invite_code"`);
await queryRunner.query(`ALTER TABLE "diaries" DROP COLUMN "shared"`);
await queryRunner.query(
`ALTER TABLE "diary_members" DROP CONSTRAINT "FK_9efd539d83a9f2e26f4cb34e7f4"`,
);
await queryRunner.query(
`ALTER TABLE "diary_members" DROP CONSTRAINT "FK_0780a472fccfacd4889777672d9"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_9efd539d83a9f2e26f4cb34e7f"`,
);
await queryRunner.query(`DROP TABLE "diary_members"`);
}
}
@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 일정 연동 — 묶음(calendar_groups)과 묶음에 든 사람(한 사람은 한 묶음, UNIQUE user_id),
* 사람마다 하나인 초대 코드(users.calendar_invite_code, 처음 연동 화면을 열 때 만든다).
* 새 표·컬럼만 더한다 — 기존 데이터는 건드리지 않는다.
*/
export class AddCalendarGroups1791719955147 implements MigrationInterface {
name = 'AddCalendarGroups1791719955147';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "calendar_groups" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_0aea5b6eac2b790d68b2be2f001" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "calendar_group_members" ("group_id" uuid NOT NULL, "user_id" uuid NOT NULL, "joined_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_8b1e326d46a87c5f207ceecfa6a" UNIQUE ("user_id"), CONSTRAINT "PK_4d4fec5c1e37c6fd8d777a0e6e0" PRIMARY KEY ("group_id", "user_id"))`,
);
await queryRunner.query(
`ALTER TABLE "users" ADD "calendar_invite_code" character varying(10)`,
);
await queryRunner.query(
`ALTER TABLE "users" ADD CONSTRAINT "UQ_c6c9728d74f5d955072566a43af" UNIQUE ("calendar_invite_code")`,
);
await queryRunner.query(
`ALTER TABLE "calendar_group_members" ADD CONSTRAINT "FK_64dd46c67ff4d9972763c16d0f3" FOREIGN KEY ("group_id") REFERENCES "calendar_groups"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "calendar_group_members" ADD CONSTRAINT "FK_8b1e326d46a87c5f207ceecfa6a" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "calendar_group_members" DROP CONSTRAINT "FK_8b1e326d46a87c5f207ceecfa6a"`,
);
await queryRunner.query(
`ALTER TABLE "calendar_group_members" DROP CONSTRAINT "FK_64dd46c67ff4d9972763c16d0f3"`,
);
await queryRunner.query(
`ALTER TABLE "users" DROP CONSTRAINT "UQ_c6c9728d74f5d955072566a43af"`,
);
await queryRunner.query(
`ALTER TABLE "users" DROP COLUMN "calendar_invite_code"`,
);
await queryRunner.query(`DROP TABLE "calendar_group_members"`);
await queryRunner.query(`DROP TABLE "calendar_groups"`);
}
}
@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 명함 공유 안전장치.
* - pet_cards.share_mode_set_at: 공유 모양을 정한 때 — 상세히는 그때부터 7일만 상세히로 내보낸다 (card-snapshot.ts)
* - 받은 명함은 같은 명함을 한 번만 (owner_id, source_card_id) — 원래 명함이 지워진 사본(NULL)은 제외
*
* 인덱스를 만들기 전에, 이미 같은 명함을 두 번 이상 받아 둔 사본은 가장 먼저 받은 것만 원래 명함과 잇고
* 나머지는 원래 명함과의 연결만 끊는다(source_card_id = NULL) — 사본·사진은 지우지 않는다.
* 되돌려도 끊은 연결은 돌아오지 않는다 (원래 명함이 지워진 사본과 같은 상태로 남는다).
*/
export class AddCardShareLimits1791727449415 implements MigrationInterface {
name = 'AddCardShareLimits1791727449415';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD "share_mode_set_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`UPDATE "received_cards" r SET "source_card_id" = NULL
WHERE r."source_card_id" IS NOT NULL
AND EXISTS (
SELECT 1 FROM "received_cards" o
WHERE o."owner_id" = r."owner_id"
AND o."source_card_id" = r."source_card_id"
AND (o."created_at", o."id") < (r."created_at", r."id")
)`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_received_cards_owner_source" ON "received_cards" ("owner_id", "source_card_id") WHERE "source_card_id" IS NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."UQ_received_cards_owner_source"`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" DROP COLUMN "share_mode_set_at"`,
);
}
}
@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 일정 연동 초대 코드를 모든 회원에게 — 예전에는 연동 화면을 처음 열 때(GET) 만들었다.
* 이제는 가입할 때 만들고 GET 은 아무것도 쓰지 않으므로, 코드가 없는 기존 회원에게 채운 뒤 NOT NULL 로 둔다.
*
* 형식은 앱 코드와 같다 — `RUP-` + 헷갈리는 글자(0·O·1·I)를 뺀 32자 중 6자 (modules/user/calendar-invite-code.ts).
* 무작위 값은 gen_random_uuid() 의 바이트(앞 6바이트는 버전 표시가 없는 순수 난수)에서 얻는다 —
* random() 은 예측할 수 있어 초대 코드에 쓰지 않는다. 256 은 32 의 배수라 글자마다 고르게 나온다.
*/
export class BackfillCalendarInviteCodes1791727449416 implements MigrationInterface {
name = 'BackfillCalendarInviteCodes1791727449416';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
DECLARE
alphabet CONSTANT text := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
target record;
candidate text;
BEGIN
FOR target IN SELECT "id" FROM "users" WHERE "calendar_invite_code" IS NULL LOOP
LOOP
SELECT 'RUP-' || string_agg(substr(alphabet, 1 + get_byte(r.bytes, i) % 32, 1), '' ORDER BY i)
INTO candidate
FROM (SELECT uuid_send(gen_random_uuid()) AS bytes) AS r,
generate_series(0, 5) AS i;
EXIT WHEN NOT EXISTS (
SELECT 1 FROM "users" WHERE "calendar_invite_code" = candidate
);
END LOOP;
UPDATE "users" SET "calendar_invite_code" = candidate WHERE "id" = target."id";
END LOOP;
END
$$`);
await queryRunner.query(
`ALTER TABLE "users" ALTER COLUMN "calendar_invite_code" SET NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 채운 코드는 그대로 둔다 — 예전 코드도 비어 있으면 처음 열 때 만들었으므로 값이 있어도 문제없다
await queryRunner.query(
`ALTER TABLE "users" ALTER COLUMN "calendar_invite_code" DROP NOT NULL`,
);
}
}
@@ -0,0 +1,82 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* FK 컬럼 인덱스 — 파일을 가리키는 *_file_id 와 diary_posts·diary_comments 의 author_id, received_cards.source_card_id.
* 파일을 지울 때(releaseIfUnused·쓰이지 않는 파일 정리) Postgres 가 NO ACTION FK 를 확인하느라 이 컬럼들을 훑고,
* 회원·명함을 지울 때 CASCADE/SET NULL 도 같은 컬럼으로 찾는다 — 인덱스가 없으면 표 전체를 읽는다. 인덱스만 더한다.
*/
export class AddFileAndAuthorIndexes1791727662580 implements MigrationInterface {
name = 'AddFileAndAuthorIndexes1791727662580';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE INDEX "IDX_e4313545e4345b359e66bdbebd" ON "users" ("photo_file_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_816320a5aa9d3cafbdddcdde33" ON "pets" ("photo_file_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_672dc0c78f4a9ffde6239f1db8" ON "pet_cards" ("name_bg_file_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_65e8b70115d2de13ce0380b8ad" ON "received_cards" ("source_card_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_ea06e251a9ed53e63e66ef5bb6" ON "received_cards" ("sender_photo_file_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_21733fe1b0dfce3e27726b322e" ON "received_cards" ("pet_photo_file_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_549a5fe176831cb7cebb29f9e2" ON "received_cards" ("background_file_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_49e7cfa49fed5dd22a4e8c25da" ON "diaries" ("cover_file_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_c25aa8312dc21996eb00531bb6" ON "diary_posts" ("author_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_07e1d0110b6bf6db265e996388" ON "diary_posts" ("photo_file_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_ac7b686ceee2547592e05072dc" ON "diary_comments" ("author_id") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_ac7b686ceee2547592e05072dc"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_07e1d0110b6bf6db265e996388"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_c25aa8312dc21996eb00531bb6"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_49e7cfa49fed5dd22a4e8c25da"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_549a5fe176831cb7cebb29f9e2"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_21733fe1b0dfce3e27726b322e"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_ea06e251a9ed53e63e66ef5bb6"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_65e8b70115d2de13ce0380b8ad"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_672dc0c78f4a9ffde6239f1db8"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_816320a5aa9d3cafbdddcdde33"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_e4313545e4345b359e66bdbebd"`,
);
}
}
@@ -0,0 +1,81 @@
import {
accessTokenTtlMs,
isDevLoginEnabled,
refreshTokenTtlMs,
resolveJwtSecret,
socialAuthConfig,
} from './auth.config';
describe('인증 설정', () => {
it('토큰 유효 시간은 env 값을, 없거나 틀리면 기본값(1시간·14일)을 쓴다', () => {
expect(accessTokenTtlMs({ JWT_ACCESS_EXPIRES_IN: '15m' })).toBe(900_000);
expect(accessTokenTtlMs({})).toBe(3_600_000);
expect(accessTokenTtlMs({ JWT_ACCESS_EXPIRES_IN: '' })).toBe(3_600_000);
expect(refreshTokenTtlMs({ JWT_REFRESH_EXPIRES_IN: '30d' })).toBe(
2_592_000_000,
);
expect(refreshTokenTtlMs({ JWT_REFRESH_EXPIRES_IN: '2주' })).toBe(
1_209_600_000,
);
});
it('JWT_SECRET 이 비어 있으면 개발·테스트에서만 개발용 키로 대신한다', () => {
expect(
resolveJwtSecret({ NODE_ENV: 'production', JWT_SECRET: 'real-secret' }),
).toBe('real-secret');
expect(
resolveJwtSecret({ NODE_ENV: 'development', JWT_SECRET: ' ' }),
).toMatch(/dev-only/);
expect(resolveJwtSecret({ NODE_ENV: 'test' })).toMatch(/dev-only/);
});
it('개발·테스트 밖(staging·production·미설정)에서 JWT_SECRET 이 없으면 기동을 멈춘다', () => {
for (const env of [
{ NODE_ENV: 'production' },
{ NODE_ENV: 'staging' },
{ NODE_ENV: 'Development' },
{},
]) {
expect(() => resolveJwtSecret(env)).toThrow(/JWT_SECRET/);
}
});
it('개발용 로그인은 개발·테스트에서만 열린다 (allowlist)', () => {
expect(isDevLoginEnabled({ NODE_ENV: 'development' })).toBe(true);
expect(isDevLoginEnabled({ NODE_ENV: 'test' })).toBe(true);
expect(
isDevLoginEnabled({ NODE_ENV: 'development', AUTH_DEV_LOGIN: 'false' }),
).toBe(false);
for (const nodeEnv of ['production', 'staging', 'prod', '', undefined]) {
expect(
isDevLoginEnabled({ NODE_ENV: nodeEnv, AUTH_DEV_LOGIN: 'true' }),
).toBe(false);
}
});
it('소셜 설정은 비어 있으면 끈 것으로 본다', () => {
expect(socialAuthConfig({})).toEqual({
kakaoAppId: null,
naverClientId: null,
googleClientIds: [],
appleClientIds: [],
});
});
it('카카오 앱 ID 는 숫자, 구글·Apple 은 콤마 목록으로 읽는다', () => {
expect(
socialAuthConfig({
KAKAO_APP_ID: '123456',
NAVER_CLIENT_ID: 'naver-client',
GOOGLE_CLIENT_IDS: 'ios.apps.googleusercontent.com, android.apps',
APPLE_CLIENT_IDS: 'net.wandergo.rup,',
}),
).toEqual({
kakaoAppId: 123456,
naverClientId: 'naver-client',
googleClientIds: ['ios.apps.googleusercontent.com', 'android.apps'],
appleClientIds: ['net.wandergo.rup'],
});
expect(socialAuthConfig({ KAKAO_APP_ID: 'abc' }).kakaoAppId).toBeNull();
});
});
+92
View File
@@ -0,0 +1,92 @@
import { isDevNodeEnv } from '@/shared/config/node-env';
import { parseDurationMs } from '@/shared/utils/duration';
/**
* 인증 설정 — backend/.env.{APP_ENV} 에서 읽는다 (루트에서 내려주지 않는 백엔드 고유 값).
* 값은 모듈을 불러올 때가 아니라 쓸 때 읽는다 (ConfigModule 이 env 파일을 읽은 뒤여야 한다).
* 형식 검증은 shared/config/env.validation.ts 가 기동 시점에 한다.
*/
type Env = Readonly<Record<string, string | undefined>>;
/** 액세스 토큰 기본 유효 시간 — 짧게 둔다 (.claude/rules/security.md) */
const DEFAULT_ACCESS_TTL_MS = 3_600_000; // 1시간
/** 리프레시 토큰 기본 유효 시간 */
const DEFAULT_REFRESH_TTL_MS = 1_209_600_000; // 14일
/**
* 개발·테스트 전용 서명 키 — NODE_ENV 가 development·test 이고 JWT_SECRET 을 비워 둔 로컬·CI 에서만 쓴다.
* 그 밖의 환경(staging·production·미설정 …)은 env.validation 이 JWT_SECRET 을 필수로 요구해 기동을 막고,
* 혹시 검증을 거치지 않고 불려도 아래 resolveJwtSecret 이 이 키를 내주지 않는다.
*/
const DEV_ONLY_JWT_SECRET = 'rup-dev-only-jwt-secret-not-for-production';
function readEnv(env: Env, key: string): string | undefined {
const value = env[key]?.trim();
return value === '' ? undefined : value;
}
export function resolveJwtSecret(env: Env = process.env): string {
const secret = readEnv(env, 'JWT_SECRET');
if (secret !== undefined) return secret;
if (isDevNodeEnv(env.NODE_ENV)) return DEV_ONLY_JWT_SECRET;
// env.validation 이 먼저 막는다 — 여기까지 왔다면 설정 경로가 잘못된 것이므로 기동을 멈춘다
throw new Error(
'JWT_SECRET 가 설정되지 않았습니다. (development·test 밖에서는 필수)',
);
}
export function accessTokenTtlMs(env: Env = process.env): number {
return parseDurationMs(env.JWT_ACCESS_EXPIRES_IN) ?? DEFAULT_ACCESS_TTL_MS;
}
export function refreshTokenTtlMs(env: Env = process.env): number {
return parseDurationMs(env.JWT_REFRESH_EXPIRES_IN) ?? DEFAULT_REFRESH_TTL_MS;
}
/**
* 개발용 로그인(소셜 앱 키 없이 기기 키로 가입) 허용 여부.
* NODE_ENV 가 development·test 일 때만 열린다 — 그 밖(staging·production·미설정 …)에서는 어떤 값을 주어도 닫힌다.
* 개발·테스트에서도 AUTH_DEV_LOGIN=false 로 끌 수 있다.
*/
export function isDevLoginEnabled(env: Env = process.env): boolean {
if (!isDevNodeEnv(env.NODE_ENV)) return false;
return readEnv(env, 'AUTH_DEV_LOGIN') !== 'false';
}
/** 콤마로 나눈 목록 (빈 칸 제거) */
function readList(env: Env, key: string): string[] {
return (readEnv(env, key) ?? '')
.split(',')
.map((v) => v.trim())
.filter((v) => v !== '');
}
/** 소셜 제공자별 앱 정보 — 비어 있으면 그 제공자 로그인은 거절한다 */
export interface SocialAuthConfig {
/** 카카오 앱 ID (숫자) — 토큰이 우리 앱에서 발급됐는지 확인한다 */
readonly kakaoAppId: number | null;
/** 네이버 로그인 사용 여부 (클라이언트 ID 가 있으면 켠다) */
readonly naverClientId: string | null;
/** 구글 OAuth 클라이언트 ID 들 (iOS·Android·웹) — id_token 의 aud */
readonly googleClientIds: readonly string[];
/** Apple 번들 ID·서비스 ID 들 — id_token 의 aud */
readonly appleClientIds: readonly string[];
}
export function socialAuthConfig(env: Env = process.env): SocialAuthConfig {
const kakao = Number(readEnv(env, 'KAKAO_APP_ID'));
return {
kakaoAppId: Number.isInteger(kakao) && kakao > 0 ? kakao : null,
naverClientId: readEnv(env, 'NAVER_CLIENT_ID') ?? null,
googleClientIds: readList(env, 'GOOGLE_CLIENT_IDS'),
appleClientIds: readList(env, 'APPLE_CLIENT_IDS'),
};
}
/** 액세스 토큰 페이로드의 종류 표시 — 다른 용도의 JWT 를 액세스 토큰으로 쓰지 못하게 한다 */
export const ACCESS_TOKEN_TYPE = 'access';
/** 액세스 토큰 서명 알고리즘 — 다른 알고리즘(none 등)으로 서명한 토큰은 받지 않는다 */
export const ACCESS_TOKEN_ALGORITHM = 'HS256';
@@ -0,0 +1,83 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiNoContentResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { Public } from '@/common/decorators/public.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { AuthService } from './auth.service';
import { AuthResultDto, AuthTokensDto } from './dto/auth-result.dto';
import { DevLoginDto, RefreshTokenDto, SocialLoginDto } from './dto/login.dto';
/**
* 로그인처럼 브루트포스 표적이 되는 엔드포인트는 전역 한도보다 엄격하게 막는다 (.claude/rules/security.md).
* 분당 10회 — 정상 사용(로그인 한 번, 갱신 몇 번)에는 걸리지 않는다.
*/
const AUTH_THROTTLE = { default: { ttl: 60_000, limit: 10 } };
// 인증 — 경로는 리소스 이름으로 둔다 (세션 만들기 = 로그인, 토큰 만들기 = 갱신, 토큰 폐기 = 로그아웃)
@ApiTags('Auth')
@Public()
@Throttle(AUTH_THROTTLE)
@ApiErrorResponse('REQ_001', '요청 한도 초과')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('sessions')
@ApiOperation({
summary: '소셜 로그인 (처음이면 가입)',
description:
'앱이 소셜 SDK 로 받은 토큰을 서버가 제공자에게 직접 확인한다. 실패 원인은 구분하지 않는다.',
})
@ApiStandardResponse(AuthResultDto, {
status: 201,
description: '로그인 성공',
})
@ApiErrorResponse('AUTH_001', '토큰 확인 실패')
@ApiErrorResponse('SYS_001', '소셜 제공자 장애')
login(@Body() dto: SocialLoginDto): Promise<AuthResultDto> {
return this.authService.socialLogin(dto);
}
@Post('dev-sessions')
@ApiOperation({
summary: '개발용 로그인 (운영에서는 열리지 않음)',
description:
'소셜 앱 키가 준비되기 전, 기기 키를 소셜 계정 대신 써서 가입·로그인한다.',
})
@ApiStandardResponse(AuthResultDto, {
status: 201,
description: '로그인 성공',
})
@ApiErrorResponse('RES_001', '운영 환경 — 엔드포인트 없음')
devLogin(@Body() dto: DevLoginDto): Promise<AuthResultDto> {
return this.authService.devLogin(dto);
}
@Post('tokens')
@ApiOperation({
summary: '토큰 갱신',
description:
'리프레시 토큰은 한 번만 쓸 수 있다. 새 한 쌍으로 바꿔 보관한다.',
})
@ApiStandardResponse(AuthTokensDto, { status: 201, description: '새 토큰' })
@ApiErrorResponse('AUTH_001', '만료·폐기·없는 토큰')
refresh(@Body() dto: RefreshTokenDto): Promise<AuthTokensDto> {
return this.authService.refresh(dto.refreshToken);
}
@Post('token-revocation')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '로그아웃 (리프레시 토큰 폐기)' })
@ApiNoContentResponse({
description: '폐기 완료 (이미 폐기된 토큰이어도 성공)',
})
async logout(@Body() dto: RefreshTokenDto): Promise<void> {
await this.authService.logout(dto.refreshToken);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '@/modules/user/user.module';
import { ACCESS_TOKEN_ALGORITHM, resolveJwtSecret } from './auth.config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { RefreshToken } from './entities/refresh-token.entity';
import { JwtStrategy } from './jwt.strategy';
import { RefreshTokenCleanup } from './refresh-token.cleanup';
import { IdTokenVerifier } from './social/id-token.verifier';
import { SocialVerifierService } from './social/social-verifier.service';
@Module({
imports: [
TypeOrmModule.forFeature([RefreshToken]),
PassportModule,
// 서명 키는 쓸 때 읽는다 — ConfigModule 이 env 파일을 읽은 뒤여야 한다
JwtModule.registerAsync({
useFactory: () => ({
secret: resolveJwtSecret(),
signOptions: { algorithm: ACCESS_TOKEN_ALGORITHM },
}),
}),
UserModule,
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
SocialVerifierService,
IdTokenVerifier,
RefreshTokenCleanup,
],
})
export class AuthModule {}
@@ -0,0 +1,218 @@
import { JwtService } from '@nestjs/jwt';
import { Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { UserService } from '@/modules/user/user.service';
import { sha256Hex } from '@/shared/utils/hash';
import { AuthService } from './auth.service';
import { RefreshToken } from './entities/refresh-token.entity';
import { SocialVerifierService } from './social/social-verifier.service';
const USER_ID = '0b6f3c4e-2a1d-4f5e-8c9b-7a6d5e4f3c2b';
const SIGNED_IN = {
user: {
id: USER_ID,
nickname: '집사',
photoFileId: null,
provider: 'kakao' as const,
createdAt: '2026-10-11T00:00:00.000Z',
},
isNewUser: true,
};
function storedToken(overrides: Partial<RefreshToken> = {}): RefreshToken {
return {
id: 'rt-1',
userId: USER_ID,
tokenHash: sha256Hex('old-token'),
expiresAt: new Date(Date.now() + 60_000),
revokedAt: null,
createdAt: new Date(),
...overrides,
};
}
async function codeOf(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof BusinessException) return error.code;
throw error;
}
throw new Error('예외가 나지 않았다');
}
describe('AuthService', () => {
const repo = {
findOneBy: jest.fn(),
update: jest.fn(),
save: jest.fn(),
create: jest.fn((v: Partial<RefreshToken>) => v),
};
const users = { findOrCreateBySocial: jest.fn() };
const verifier = { verify: jest.fn() };
const jwt = new JwtService({ secret: 'test-secret' });
const service = new AuthService(
jwt,
users as unknown as UserService,
verifier as unknown as SocialVerifierService,
repo as unknown as Repository<RefreshToken>,
);
const originalEnv = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...originalEnv, NODE_ENV: 'test' };
users.findOrCreateBySocial.mockResolvedValue(SIGNED_IN);
repo.save.mockImplementation((v: unknown) => Promise.resolve(v));
});
afterAll(() => {
process.env = originalEnv;
});
describe('로그인', () => {
it('개발용 로그인은 기기 키를 소셜 id 로 써서 가입시키고 토큰을 준다', async () => {
const result = await service.devLogin({
provider: 'kakao',
deviceKey: 'device-key-0000000001',
nickname: ' 집사 ',
});
expect(users.findOrCreateBySocial).toHaveBeenCalledWith({
provider: 'kakao',
subject: 'dev:device-key-0000000001',
nickname: '집사',
});
expect(result.user.id).toBe(USER_ID);
expect(result.isNewUser).toBe(true);
expect(
jwt.verify<{ sub: string; typ: string }>(result.accessToken),
).toMatchObject({ sub: USER_ID, typ: 'access' });
});
it('리프레시 토큰은 원문이 아니라 해시로 저장한다', async () => {
const result = await service.devLogin({
provider: 'kakao',
deviceKey: 'device-key-0000000001',
});
const [[saved]] = repo.save.mock.calls as [[Partial<RefreshToken>]];
expect(saved.tokenHash).toBe(sha256Hex(result.refreshToken));
expect(saved.tokenHash).not.toContain(result.refreshToken);
});
it('운영에서는 개발용 로그인이 없는 엔드포인트처럼 응답한다', async () => {
process.env.NODE_ENV = 'production';
await expect(
codeOf(
service.devLogin({ provider: 'kakao', deviceKey: 'device-key-01' }),
),
).resolves.toBe('RES_001');
expect(users.findOrCreateBySocial).not.toHaveBeenCalled();
});
it('소셜 로그인은 제공자가 이름을 주지 않을 때만 앱이 보낸 이름을 쓴다', async () => {
verifier.verify.mockResolvedValueOnce({
provider: 'apple',
subject: 'a-1',
nickname: null,
});
await service.socialLogin({
provider: 'apple',
token: 't',
nickname: '두부맘',
});
expect(users.findOrCreateBySocial).toHaveBeenLastCalledWith(
expect.objectContaining({ nickname: '두부맘' }),
);
verifier.verify.mockResolvedValueOnce({
provider: 'kakao',
subject: 'k-1',
nickname: '카카오이름',
});
await service.socialLogin({
provider: 'kakao',
token: 't',
nickname: '앱이름',
});
expect(users.findOrCreateBySocial).toHaveBeenLastCalledWith(
expect.objectContaining({ nickname: '카카오이름' }),
);
});
});
describe('토큰 갱신', () => {
it('유효한 토큰이면 폐기하고 새 한 쌍을 준다', async () => {
repo.findOneBy.mockResolvedValue(storedToken());
repo.update.mockResolvedValue({ affected: 1 });
const tokens = await service.refresh('old-token');
expect(repo.findOneBy).toHaveBeenCalledWith({
tokenHash: sha256Hex('old-token'),
});
expect(repo.update).toHaveBeenCalledWith(
expect.objectContaining({ id: 'rt-1' }),
expect.objectContaining({ revokedAt: expect.any(Date) as Date }),
);
expect(tokens.refreshToken).not.toBe('old-token');
expect(tokens.accessTokenExpiresIn).toBe(3600);
});
it('없는 토큰이면 AUTH_001', async () => {
repo.findOneBy.mockResolvedValue(null);
await expect(codeOf(service.refresh('nope'))).resolves.toBe('AUTH_001');
});
it('만료된 토큰이면 AUTH_001 이고 새 토큰을 만들지 않는다', async () => {
repo.findOneBy.mockResolvedValue(
storedToken({ expiresAt: new Date(Date.now() - 1) }),
);
await expect(codeOf(service.refresh('old-token'))).resolves.toBe(
'AUTH_001',
);
expect(repo.save).not.toHaveBeenCalled();
});
it('폐기된 토큰이 다시 오면 그 회원의 토큰을 모두 폐기한다 (탈취 대응)', async () => {
repo.findOneBy.mockResolvedValue(
storedToken({ revokedAt: new Date(Date.now() - 1000) }),
);
repo.update.mockResolvedValue({ affected: 2 });
await expect(codeOf(service.refresh('old-token'))).resolves.toBe(
'AUTH_001',
);
expect(repo.update).toHaveBeenCalledWith(
expect.objectContaining({ userId: USER_ID }),
expect.objectContaining({ revokedAt: expect.any(Date) as Date }),
);
expect(repo.save).not.toHaveBeenCalled();
});
it('같은 토큰으로 동시에 갱신하면 늦은 쪽은 AUTH_001', async () => {
repo.findOneBy.mockResolvedValue(storedToken());
repo.update.mockResolvedValue({ affected: 0 });
await expect(codeOf(service.refresh('old-token'))).resolves.toBe(
'AUTH_001',
);
expect(repo.save).not.toHaveBeenCalled();
});
});
it('로그아웃은 토큰 해시로 폐기한다', async () => {
repo.update.mockResolvedValue({ affected: 0 });
await expect(service.logout('old-token')).resolves.toBeUndefined();
expect(repo.update).toHaveBeenCalledWith(
expect.objectContaining({ tokenHash: sha256Hex('old-token') }),
expect.objectContaining({ revokedAt: expect.any(Date) as Date }),
);
});
});
+164
View File
@@ -0,0 +1,164 @@
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { VerifiedSocialAccount } from '@/modules/user/entities/social-account.entity';
import { SignedInUser, UserService } from '@/modules/user/user.service';
import { randomToken, sha256Hex } from '@/shared/utils/hash';
import {
ACCESS_TOKEN_TYPE,
accessTokenTtlMs,
isDevLoginEnabled,
refreshTokenTtlMs,
} from './auth.config';
import { AuthResultDto, AuthTokensDto } from './dto/auth-result.dto';
import { DevLoginDto, SocialLoginDto } from './dto/login.dto';
import { RefreshToken } from './entities/refresh-token.entity';
import { SocialVerifierService } from './social/social-verifier.service';
export interface AccessTokenPayload {
readonly sub: string;
readonly typ: typeof ACCESS_TOKEN_TYPE;
}
/** 리프레시 토큰 원문 길이 (바이트) */
const REFRESH_TOKEN_BYTES = 32;
/** 개발용 로그인의 소셜 id 접두어 — 실제 소셜 id 와 겹치지 않게 한다 */
const DEV_SUBJECT_PREFIX = 'dev:';
const MS_PER_SECOND = 1_000;
// 로그인·토큰 갱신·로그아웃
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly jwt: JwtService,
private readonly users: UserService,
private readonly verifier: SocialVerifierService,
@InjectRepository(RefreshToken)
private readonly refreshTokens: Repository<RefreshToken>,
) {}
/** 소셜 로그인 — 제공자에게 토큰을 확인하고, 처음이면 가입시킨다 */
async socialLogin(dto: SocialLoginDto): Promise<AuthResultDto> {
const verified = await this.verifier.verify(dto.provider, dto.token);
return this.signIn({
...verified,
nickname: verified.nickname ?? dto.nickname?.trim() ?? null,
});
}
/**
* 개발용 로그인 — 기기 키를 소셜 계정 대신 쓴다. 운영에서는 없는 엔드포인트처럼 응답한다.
* 소셜 앱 키가 준비되면 앱은 socialLogin 으로 바꾼다.
*/
async devLogin(dto: DevLoginDto): Promise<AuthResultDto> {
if (!isDevLoginEnabled()) {
throw new BusinessException('RES_001', {
context: { reason: 'dev-login-disabled' },
});
}
return this.signIn({
provider: dto.provider,
subject: `${DEV_SUBJECT_PREFIX}${dto.deviceKey}`,
nickname: dto.nickname?.trim() || null,
});
}
/**
* 토큰 갱신 — 리프레시 토큰을 폐기하고 새 한 쌍을 준다(회전).
* 이미 폐기된 토큰이 다시 오면 탈취로 보고 그 회원의 리프레시 토큰을 모두 폐기한다.
*/
async refresh(refreshToken: string): Promise<AuthTokensDto> {
const stored = await this.refreshTokens.findOneBy({
tokenHash: sha256Hex(refreshToken),
});
if (!stored) {
throw new BusinessException('AUTH_001', {
context: { reason: 'unknown-refresh-token' },
});
}
if (stored.revokedAt !== null) {
await this.revokeAll(stored.userId);
this.logger.warn(
`폐기된 리프레시 토큰 재사용 — 회원 ${stored.userId} 의 토큰을 모두 폐기`,
);
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-reused', userId: stored.userId },
});
}
if (stored.expiresAt.getTime() <= Date.now()) {
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-expired', userId: stored.userId },
});
}
// 같은 토큰으로 동시에 갱신하면 한쪽만 성공해야 한다 — 아직 유효한 경우에만 폐기한다
const revoked = await this.refreshTokens.update(
{ id: stored.id, revokedAt: IsNull() },
{ revokedAt: new Date() },
);
if (!revoked.affected) {
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-raced', userId: stored.userId },
});
}
return this.issueTokens(stored.userId);
}
/** 로그아웃 — 이 기기의 리프레시 토큰을 폐기한다. 이미 없거나 폐기된 토큰이어도 성공으로 본다 */
async logout(refreshToken: string): Promise<void> {
await this.refreshTokens.update(
{ tokenHash: sha256Hex(refreshToken), revokedAt: IsNull() },
{ revokedAt: new Date() },
);
}
private async signIn(account: VerifiedSocialAccount): Promise<AuthResultDto> {
const signedIn: SignedInUser =
await this.users.findOrCreateBySocial(account);
const tokens = await this.issueTokens(signedIn.user.id);
return { ...tokens, ...signedIn };
}
private async issueTokens(userId: string): Promise<AuthTokensDto> {
const accessTtlMs = accessTokenTtlMs();
const payload: AccessTokenPayload = { sub: userId, typ: ACCESS_TOKEN_TYPE };
const accessToken = await this.jwt.signAsync(payload, {
expiresIn: Math.floor(accessTtlMs / MS_PER_SECOND),
});
const refreshToken = randomToken(REFRESH_TOKEN_BYTES);
const expiresAt = new Date(Date.now() + refreshTokenTtlMs());
await this.refreshTokens.save(
this.refreshTokens.create({
userId,
tokenHash: sha256Hex(refreshToken),
expiresAt,
revokedAt: null,
}),
);
return {
accessToken,
accessTokenExpiresIn: Math.floor(accessTtlMs / MS_PER_SECOND),
refreshToken,
refreshTokenExpiresAt: expiresAt.toISOString(),
};
}
private async revokeAll(userId: string): Promise<void> {
await this.refreshTokens.update(
{ userId, revokedAt: IsNull() },
{ revokedAt: new Date() },
);
}
}
@@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import { UserResponseDto } from '@/modules/user/dto/user-response.dto';
/** 발급한 토큰 한 쌍 */
export class AuthTokensDto {
@ApiProperty({ description: '액세스 토큰 — Authorization: Bearer 로 보낸다' })
accessToken!: string;
@ApiProperty({ description: '액세스 토큰 유효 시간 (초)', example: 3600 })
accessTokenExpiresIn!: number;
@ApiProperty({
description:
'리프레시 토큰 — 한 번 쓰면 폐기되고 새 토큰이 온다. 앱은 secure storage 에 보관한다',
})
refreshToken!: string;
@ApiProperty({ description: '리프레시 토큰 만료 시각 (ISO 8601 UTC)' })
refreshTokenExpiresAt!: string;
}
/** 로그인 결과 */
export class AuthResultDto extends AuthTokensDto {
@ApiProperty({ description: '로그인한 회원', type: UserResponseDto })
user!: UserResponseDto;
@ApiProperty({ description: '이번에 처음 가입했는지', example: false })
isNewUser!: boolean;
}
+76
View File
@@ -0,0 +1,76 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsIn,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { SOCIAL_PROVIDERS } from '@/modules/user/entities/social-account.entity';
import type { SocialProvider } from '@/modules/user/entities/social-account.entity';
import { NICKNAME_MAX_LENGTH } from '@/modules/user/entities/user.entity';
/** 소셜 토큰 최대 길이 — 제공자 토큰은 수백~2천 자 정도다. 과도한 입력만 막는다 */
const SOCIAL_TOKEN_MAX_LENGTH = 4096;
/** 개발용 로그인의 기기 키 — 앱이 처음 실행할 때 만든 무작위 값 */
const DEVICE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
class LoginBaseDto {
@ApiProperty({
description: '소셜 로그인 제공자',
enum: SOCIAL_PROVIDERS,
example: 'kakao',
})
@IsIn(SOCIAL_PROVIDERS, { message: '지원하지 않는 로그인 방식입니다.' })
provider!: SocialProvider;
@ApiPropertyOptional({
description: `처음 가입할 때 쓸 이름 (제공자가 이름을 주지 않는 경우 — Apple 등). 최대 ${NICKNAME_MAX_LENGTH}자`,
example: '집사',
})
@IsOptional()
@IsString({ message: '이름은 문자로 입력해 주세요.' })
@MaxLength(NICKNAME_MAX_LENGTH, {
message: `이름은 ${NICKNAME_MAX_LENGTH}자 이하로 입력해 주세요.`,
})
nickname?: string;
}
/** 소셜 로그인 — 앱이 소셜 SDK 로 받은 토큰을 넘긴다 */
export class SocialLoginDto extends LoginBaseDto {
@ApiProperty({
description:
'카카오·네이버는 액세스 토큰, 구글·Apple 은 id_token. 서버가 제공자에게 직접 확인한다',
})
@IsString({ message: '로그인 정보가 올바르지 않습니다.' })
@IsNotEmpty({ message: '로그인 정보가 올바르지 않습니다.' })
@MaxLength(SOCIAL_TOKEN_MAX_LENGTH, {
message: '로그인 정보가 올바르지 않습니다.',
})
token!: string;
}
/**
* 개발용 로그인 — 소셜 앱 키가 준비되기 전, 기기 키를 소셜 계정 대신 쓴다.
* 운영에서는 엔드포인트가 열리지 않는다.
*/
export class DevLoginDto extends LoginBaseDto {
@ApiProperty({
description: '기기 키 (영문·숫자·-·_ 16~128자). 같은 키면 같은 회원이다',
example: 'f3b1c2d4e5a6978812345678',
})
@Matches(DEVICE_KEY_PATTERN, { message: '기기 정보가 올바르지 않습니다.' })
deviceKey!: string;
}
/** 토큰 갱신·로그아웃 — 리프레시 토큰을 넘긴다 */
export class RefreshTokenDto {
@ApiProperty({ description: '로그인 때 받은 리프레시 토큰' })
@IsString({ message: '로그인 정보가 올바르지 않습니다.' })
@IsNotEmpty({ message: '로그인 정보가 올바르지 않습니다.' })
@MaxLength(256, { message: '로그인 정보가 올바르지 않습니다.' })
refreshToken!: string;
}
@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '@/modules/user/entities/user.entity';
/**
* 리프레시 토큰 — 원문은 저장하지 않고 SHA-256 해시만 둔다 (DB 가 새어도 토큰으로 쓸 수 없다).
* 한 번 쓰면 폐기하고 새 토큰을 발급한다(회전). 폐기된 토큰이 다시 오면 탈취로 보고 그 회원의 토큰을 모두 폐기한다.
*/
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: User;
@Index()
@Column({ type: 'uuid' })
userId!: string;
/** 토큰 원문의 SHA-256 (hex) */
@Column({ type: 'char', length: 64, unique: true })
tokenHash!: string;
@Column({ type: 'timestamptz' })
expiresAt!: Date;
/** 회전·로그아웃으로 폐기한 때 — 유효하면 null */
@Column({ type: 'timestamptz', nullable: true })
revokedAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
+38
View File
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { UserService } from '@/modules/user/user.service';
import {
ACCESS_TOKEN_ALGORITHM,
ACCESS_TOKEN_TYPE,
resolveJwtSecret,
} from './auth.config';
/** Authorization: Bearer 액세스 토큰을 확인해 요청에 로그인 사용자를 붙인다 */
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly users: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: resolveJwtSecret(),
algorithms: [ACCESS_TOKEN_ALGORITHM],
ignoreExpiration: false,
});
}
async validate(payload: { sub?: unknown; typ?: unknown }): Promise<AuthUser> {
if (typeof payload.sub !== 'string' || payload.typ !== ACCESS_TOKEN_TYPE) {
throw new BusinessException('AUTH_001');
}
// 탈퇴한 회원의 토큰은 만료 전이라도 거절한다
if (!(await this.users.exists(payload.sub))) {
throw new BusinessException('AUTH_001', {
context: { reason: 'user-gone', userId: payload.sub },
});
}
return { userId: payload.sub };
}
}
@@ -0,0 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThan, Repository } from 'typeorm';
import { RefreshToken } from './entities/refresh-token.entity';
/**
* 만료된 리프레시 토큰을 매일 지운다.
* 폐기된 토큰은 만료 전까지 남겨 둔다 — 재사용(탈취)을 알아보려면 기록이 있어야 한다.
*/
@Injectable()
export class RefreshTokenCleanup {
private readonly logger = new Logger(RefreshTokenCleanup.name);
constructor(
@InjectRepository(RefreshToken)
private readonly refreshTokens: Repository<RefreshToken>,
) {}
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async removeExpired(): Promise<void> {
const result = await this.refreshTokens.delete({
expiresAt: LessThan(new Date()),
});
if (result.affected) {
this.logger.log(`만료된 리프레시 토큰 ${result.affected}개 정리`);
}
}
}
@@ -0,0 +1,130 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { JsonWebKey, createPublicKey } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import { fetchProviderJson, pick } from './social-http';
/** 공개키 목록을 다시 받아 오는 주기 — 제공자는 키를 주기적으로 바꾼다 */
const JWKS_CACHE_TTL_MS = 3_600_000; // 1시간
/** 제공자 id_token 의 서명 알고리즘 (구글·Apple 모두 RS256) */
const ID_TOKEN_ALGORITHM = 'RS256';
export interface IdTokenRules {
/** 제공자의 공개키 목록 주소 (JWKS) */
readonly jwksUrl: string;
/** 발급자(iss) 로 허용하는 값 */
readonly issuers: readonly string[];
/** 수신자(aud) 로 허용하는 값 — 우리 앱의 클라이언트 ID */
readonly audiences: readonly string[];
}
interface CachedKeys {
readonly keys: ReadonlyMap<string, JsonWebKey>;
readonly fetchedAt: number;
}
/** JWT 헤더에서 서명 키 id(kid)와 알고리즘을 꺼낸다 — 형식이 틀리면 null */
export function readJwtHeader(
token: string,
): { kid: string; alg: string } | null {
const [encoded] = token.split('.');
if (!encoded) return null;
try {
const header: unknown = JSON.parse(
Buffer.from(encoded, 'base64url').toString('utf8'),
);
const kid = pick(header, 'kid');
const alg = pick(header, 'alg');
return typeof kid === 'string' && typeof alg === 'string'
? { kid, alg }
: null;
} catch {
return null;
}
}
/**
* 구글·Apple 의 id_token 을 확인한다 — 서명(JWKS)·발급자·수신자·만료.
* 확인에 실패하면 원인을 가리지 않고 AUTH_001 이다.
*/
@Injectable()
export class IdTokenVerifier {
private readonly cache = new Map<string, CachedKeys>();
constructor(private readonly jwt: JwtService) {}
async verify(
token: string,
rules: IdTokenRules,
): Promise<Record<string, unknown>> {
const header = readJwtHeader(token);
if (!header || header.alg !== ID_TOKEN_ALGORITHM) {
throw new BusinessException('AUTH_001', {
context: { reason: 'invalid-id-token-header' },
});
}
const jwk = await this.findKey(rules.jwksUrl, header.kid);
const publicKey = createPublicKey({ key: jwk, format: 'jwk' })
.export({ type: 'spki', format: 'pem' })
.toString();
try {
return await this.jwt.verifyAsync<Record<string, unknown>>(token, {
publicKey,
algorithms: [ID_TOKEN_ALGORITHM],
// 호출한 쪽이 비어 있지 않은 목록만 넘긴다 (설정이 비면 그 전에 거절한다)
issuer: [...rules.issuers] as [string, ...string[]],
audience: [...rules.audiences] as [string, ...string[]],
});
} catch (error) {
throw new BusinessException('AUTH_001', {
context: {
reason: 'id-token-rejected',
detail: error instanceof Error ? error.message : String(error),
},
});
}
}
/** kid 에 맞는 공개키 — 없으면 목록을 한 번 새로 받아 본다 (키가 막 바뀐 경우) */
private async findKey(jwksUrl: string, kid: string): Promise<JsonWebKey> {
const cached = this.cache.get(jwksUrl);
const fresh =
cached !== undefined && Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS;
const key = fresh ? cached.keys.get(kid) : undefined;
if (key) return key;
const reloaded = await this.loadKeys(jwksUrl);
const found = reloaded.get(kid);
if (!found) {
throw new BusinessException('AUTH_001', {
context: { reason: 'unknown-signing-key', kid },
});
}
return found;
}
private async loadKeys(
jwksUrl: string,
): Promise<ReadonlyMap<string, JsonWebKey>> {
const body = await fetchProviderJson(jwksUrl);
const list = pick(body, 'keys');
const keys = new Map<string, JsonWebKey>();
if (Array.isArray(list)) {
for (const item of list as unknown[]) {
const kid = pick(item, 'kid');
if (typeof kid === 'string' && pick(item, 'kty') === 'RSA') {
keys.set(kid, item as JsonWebKey);
}
}
}
this.cache.set(jwksUrl, { keys, fetchedAt: Date.now() });
return keys;
}
}
@@ -0,0 +1,74 @@
import { BusinessException } from '@/common/errors/business.exception';
/** 소셜 제공자 API 응답을 기다리는 최대 시간 */
const SOCIAL_HTTP_TIMEOUT_MS = 5_000;
/** 4xx — 토큰이 틀렸거나 만료됐다 (사용자 책임). 5xx·네트워크 오류는 제공자 장애다 */
const CLIENT_ERROR_MIN = 400;
const SERVER_ERROR_MIN = 500;
/**
* 소셜 제공자 API 를 부르고 JSON 본문을 돌려준다.
* - 토큰 거절(4xx) → AUTH_001 (원인을 구분해 알리지 않는다 — .claude/rules/security.md)
* - 제공자 장애(5xx·시간 초과·네트워크) → SYS_001 (사용자에게 "다시 로그인" 이 아니라 "잠시 후" 로 안내)
*/
export async function fetchProviderJson(
url: string,
init: { headers?: Record<string, string> } = {},
): Promise<unknown> {
let response: Response;
try {
response = await fetch(url, {
headers: init.headers,
signal: AbortSignal.timeout(SOCIAL_HTTP_TIMEOUT_MS),
});
} catch (error) {
throw new BusinessException('SYS_001', {
context: {
url,
reason: error instanceof Error ? error.message : String(error),
},
});
}
if (response.status >= SERVER_ERROR_MIN) {
throw new BusinessException('SYS_001', {
context: { url, status: response.status },
});
}
if (response.status >= CLIENT_ERROR_MIN) {
throw new BusinessException('AUTH_001', {
context: { url, status: response.status },
});
}
try {
return (await response.json()) as unknown;
} catch {
throw new BusinessException('SYS_001', {
context: { url, reason: 'invalid-json' },
});
}
}
/** 응답 객체에서 경로를 따라 값을 꺼낸다 — 형식이 다르면 undefined */
export function pick(source: unknown, ...path: string[]): unknown {
let current: unknown = source;
for (const key of path) {
if (typeof current !== 'object' || current === null) return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
/** 문자열·숫자 id 를 문자열로 — 카카오는 숫자, 네이버는 문자열로 준다 */
export function idString(value: unknown): string | null {
if (typeof value === 'string' && value !== '') return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return null;
}
/** 비어 있지 않은 문자열만 */
export function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
}
@@ -0,0 +1,194 @@
import { BusinessException } from '@/common/errors/business.exception';
import { IdTokenVerifier } from './id-token.verifier';
import { readJwtHeader } from './id-token.verifier';
import { SocialVerifierService } from './social-verifier.service';
/** fetch 를 정해진 응답으로 바꿔 끼운다 — 외부 네트워크에 의존하지 않는다 */
function mockFetch(
responses: Record<string, { status?: number; body: unknown }>,
): jest.SpyInstance {
return jest.spyOn(global, 'fetch').mockImplementation((input) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url;
const found = responses[url];
if (!found) return Promise.reject(new Error(`unexpected ${url}`));
return Promise.resolve(
new Response(JSON.stringify(found.body), { status: found.status ?? 200 }),
);
});
}
async function codeOf(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof BusinessException) return error.code;
throw error;
}
throw new Error('예외가 나지 않았다');
}
describe('SocialVerifierService', () => {
const idTokens = { verify: jest.fn() };
const service = new SocialVerifierService(
idTokens as unknown as IdTokenVerifier,
);
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
idTokens.verify.mockReset();
});
afterEach(() => {
process.env = originalEnv;
jest.restoreAllMocks();
});
describe('카카오', () => {
beforeEach(() => {
process.env.KAKAO_APP_ID = '1001';
});
it('우리 앱의 토큰이면 카카오 id 와 닉네임을 돌려준다', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
body: { id: 42, app_id: 1001 },
},
'https://kapi.kakao.com/v2/user/me': {
body: { id: 42, kakao_account: { profile: { nickname: '콩이맘' } } },
},
});
await expect(service.verify('kakao', 'token')).resolves.toEqual({
provider: 'kakao',
subject: '42',
nickname: '콩이맘',
});
});
it('다른 앱에서 발급된 토큰이면 AUTH_001', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
body: { id: 42, app_id: 9999 },
},
});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'AUTH_001',
);
});
it('카카오가 토큰을 거절하면(401) AUTH_001', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
status: 401,
body: { code: -401 },
},
});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'AUTH_001',
);
});
it('카카오 장애(5xx)는 로그인 실패가 아니라 SYS_001', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
status: 503,
body: {},
},
});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'SYS_001',
);
});
it('앱 ID 가 설정되지 않았으면 제공자를 부르지 않고 AUTH_001', async () => {
delete process.env.KAKAO_APP_ID;
const fetchSpy = mockFetch({});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'AUTH_001',
);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe('네이버', () => {
it('성공 응답이면 네이버 id 와 닉네임을 돌려준다', async () => {
process.env.NAVER_CLIENT_ID = 'client';
mockFetch({
'https://openapi.naver.com/v1/nid/me': {
body: {
resultcode: '00',
response: { id: 'naver-abc', nickname: '두부' },
},
},
});
await expect(service.verify('naver', 'token')).resolves.toEqual({
provider: 'naver',
subject: 'naver-abc',
nickname: '두부',
});
});
});
describe('구글·Apple', () => {
it('구글 id_token 의 sub 와 name 을 쓴다', async () => {
process.env.GOOGLE_CLIENT_IDS = 'client-a,client-b';
idTokens.verify.mockResolvedValue({ sub: 'g-1', name: '집사' });
await expect(service.verify('google', 'id-token')).resolves.toEqual({
provider: 'google',
subject: 'g-1',
nickname: '집사',
});
expect(idTokens.verify).toHaveBeenCalledWith(
'id-token',
expect.objectContaining({ audiences: ['client-a', 'client-b'] }),
);
});
it('Apple 은 이름을 주지 않는다', async () => {
process.env.APPLE_CLIENT_IDS = 'net.wandergo.rup';
idTokens.verify.mockResolvedValue({ sub: 'apple-1' });
await expect(service.verify('apple', 'id-token')).resolves.toEqual({
provider: 'apple',
subject: 'apple-1',
nickname: null,
});
});
it('클라이언트 ID 가 없으면 id_token 을 확인하지 않고 AUTH_001', async () => {
await expect(codeOf(service.verify('google', 'id-token'))).resolves.toBe(
'AUTH_001',
);
expect(idTokens.verify).not.toHaveBeenCalled();
});
});
});
describe('readJwtHeader', () => {
const encode = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString('base64url');
it('kid 와 alg 를 꺼낸다', () => {
expect(readJwtHeader(`${encode({ kid: 'k1', alg: 'RS256' })}.x.y`)).toEqual(
{ kid: 'k1', alg: 'RS256' },
);
});
it('형식이 틀리면 null', () => {
expect(readJwtHeader('')).toBeNull();
expect(readJwtHeader('not-json.x.y')).toBeNull();
expect(readJwtHeader(`${encode({ alg: 'RS256' })}.x.y`)).toBeNull();
});
});
@@ -0,0 +1,167 @@
import { Injectable } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
SocialProvider,
VerifiedSocialAccount,
} from '@/modules/user/entities/social-account.entity';
import { SocialAuthConfig, socialAuthConfig } from '../auth.config';
import { IdTokenVerifier } from './id-token.verifier';
import {
fetchProviderJson,
idString,
nonEmptyString,
pick,
} from './social-http';
/** 제공자 API 주소 */
const KAKAO_TOKEN_INFO_URL = 'https://kapi.kakao.com/v1/user/access_token_info';
const KAKAO_ME_URL = 'https://kapi.kakao.com/v2/user/me';
const NAVER_ME_URL = 'https://openapi.naver.com/v1/nid/me';
const NAVER_SUCCESS_CODE = '00';
const GOOGLE_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs';
const GOOGLE_ISSUERS = ['accounts.google.com', 'https://accounts.google.com'];
const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys';
const APPLE_ISSUERS = ['https://appleid.apple.com'];
/**
* 앱이 소셜 SDK 로 받은 토큰을 제공자에게 직접 확인한다. 앱이 보낸 사용자 id 는 믿지 않는다.
* - 카카오·네이버: 액세스 토큰으로 사용자 정보 API 를 부른다
* - 구글·Apple: id_token 의 서명·발급자·수신자를 확인한다
* 그 제공자의 앱 정보가 설정되지 않았으면 로그인을 거절한다 (AUTH_001).
*/
@Injectable()
export class SocialVerifierService {
constructor(private readonly idTokens: IdTokenVerifier) {}
verify(
provider: SocialProvider,
token: string,
): Promise<VerifiedSocialAccount> {
const config = socialAuthConfig();
switch (provider) {
case 'kakao':
return this.verifyKakao(token, config);
case 'naver':
return this.verifyNaver(token, config);
case 'google':
return this.verifyGoogle(token, config);
case 'apple':
return this.verifyApple(token, config);
}
}
private async verifyKakao(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.kakaoAppId === null)
throw notConfigured('kakao', 'KAKAO_APP_ID');
const headers = { Authorization: `Bearer ${token}` };
// 다른 앱에서 발급된 토큰으로 로그인하는 것을 막는다 — 토큰의 앱 ID 가 우리 앱이어야 한다
const info = await fetchProviderJson(KAKAO_TOKEN_INFO_URL, { headers });
if (pick(info, 'app_id') !== config.kakaoAppId) {
throw rejected('kakao', 'app-id-mismatch');
}
const me = await fetchProviderJson(KAKAO_ME_URL, { headers });
const subject = idString(pick(me, 'id'));
if (!subject) throw rejected('kakao', 'missing-id');
return {
provider: 'kakao',
subject,
nickname:
nonEmptyString(pick(me, 'kakao_account', 'profile', 'nickname')) ??
nonEmptyString(pick(me, 'properties', 'nickname')),
};
}
/**
* ⚠️ 네이버는 액세스 토큰이 어느 앱에서 발급됐는지 확인하는 API 가 없다.
* 클라이언트 ID 를 설정해야 켜지게 해 두었고, 더 엄격히 하려면 인가 코드를 서버가 교환하는 방식으로 바꾼다.
*/
private async verifyNaver(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.naverClientId === null) {
throw notConfigured('naver', 'NAVER_CLIENT_ID');
}
const me = await fetchProviderJson(NAVER_ME_URL, {
headers: { Authorization: `Bearer ${token}` },
});
if (pick(me, 'resultcode') !== NAVER_SUCCESS_CODE) {
throw rejected('naver', 'result-code');
}
const subject = idString(pick(me, 'response', 'id'));
if (!subject) throw rejected('naver', 'missing-id');
return {
provider: 'naver',
subject,
nickname:
nonEmptyString(pick(me, 'response', 'nickname')) ??
nonEmptyString(pick(me, 'response', 'name')),
};
}
private async verifyGoogle(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.googleClientIds.length === 0) {
throw notConfigured('google', 'GOOGLE_CLIENT_IDS');
}
const claims = await this.idTokens.verify(token, {
jwksUrl: GOOGLE_JWKS_URL,
issuers: GOOGLE_ISSUERS,
audiences: config.googleClientIds,
});
const subject = idString(claims.sub);
if (!subject) throw rejected('google', 'missing-sub');
return {
provider: 'google',
subject,
nickname: nonEmptyString(claims.name),
};
}
/** Apple 은 id_token 에 이름을 싣지 않는다 — 앱이 첫 로그인 때 받은 이름을 따로 보낸다 */
private async verifyApple(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.appleClientIds.length === 0) {
throw notConfigured('apple', 'APPLE_CLIENT_IDS');
}
const claims = await this.idTokens.verify(token, {
jwksUrl: APPLE_JWKS_URL,
issuers: APPLE_ISSUERS,
audiences: config.appleClientIds,
});
const subject = idString(claims.sub);
if (!subject) throw rejected('apple', 'missing-sub');
return { provider: 'apple', subject, nickname: null };
}
}
function rejected(provider: SocialProvider, reason: string): BusinessException {
return new BusinessException('AUTH_001', { context: { provider, reason } });
}
/** 설정 누락은 운영자가 고칠 일이다 — 로그에 어떤 값이 빠졌는지 남긴다 */
function notConfigured(
provider: SocialProvider,
envKey: string,
): BusinessException {
return new BusinessException('AUTH_001', {
context: { provider, reason: `not-configured:${envKey}` },
});
}
@@ -0,0 +1,205 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { DiaryService } from './diary.service';
import {
CreateDiaryDto,
CreatePostDto,
DiaryPostResponseDto,
DiaryResponseDto,
DiarySharingDto,
MutedNoticesDto,
UpdateDiaryDto,
} from './dto/diary.dto';
// 다이어리 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Diary')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('diaries')
export class DiariesController {
constructor(private readonly diary: DiaryService) {}
@Get()
@ApiOperation({
summary: '내가 함께하는 다이어리 목록 (만든 순, 커서 페이지네이션)',
description: '내가 만든 다이어리와 초대 코드로 참여한 다이어리가 함께 온다',
})
@ApiPaginatedResponse(DiaryResponseDto, '다이어리 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryResponseDto>> {
return this.diary.listDiaries(user.userId, query);
}
@Post()
@ApiOperation({
summary: '새 다이어리',
description:
'shared=true 면 초대 코드를 함께 만든다. 책장은 참여한 다이어리도 함께 센다',
})
@ApiStandardResponse(DiaryResponseDto, {
status: 201,
description: '만든 다이어리',
})
@ApiErrorResponse('DIARY_001', '책장이 가득 참 (최대 6권)')
@ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 아이가 아닌 대상')
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreateDiaryDto,
): Promise<DiaryResponseDto> {
return this.diary.createDiary(user.userId, dto);
}
@Patch(':id')
@ApiOperation({
summary: '다이어리 수정 (이름·대상·리본·표지, 보낸 값만) — 주인만',
})
@ApiStandardResponse(DiaryResponseDto, { description: '수정한 다이어리' })
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '주인이 아님')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDiaryDto,
): Promise<DiaryResponseDto> {
return this.diary.updateDiary(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '다이어리 삭제 (기록·댓글·함께하는 사람 함께) — 주인만',
})
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '주인이 아님 (나가기를 쓴다)')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.diary.deleteDiary(user.userId, id);
}
@Put(':id/sharing')
@ApiOperation({
summary: '공유 켜기·끄기 — 주인만',
description:
'켜면 초대 코드가 생긴다(이미 있으면 그대로). 끄면 주인 말고는 모두 빠지고 코드도 지워진다 — 남긴 기록·댓글은 다이어리에 남는다',
})
@ApiStandardResponse(DiaryResponseDto, { description: '바뀐 다이어리' })
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '주인이 아님')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
setSharing(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DiarySharingDto,
): Promise<DiaryResponseDto> {
return this.diary.setSharing(user.userId, id, dto);
}
@Put(':id/muted-notices')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '내 알림 설정 (함께하는 사람마다 따로)' })
@ApiNoContentResponse({ description: '저장 완료' })
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
async setMutedNotices(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: MutedNoticesDto,
): Promise<void> {
await this.diary.setMutedNotices(user.userId, id, dto);
}
@Delete(':id/membership')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '다이어리에서 나가기 (남긴 기록은 남는다)',
description:
'초대 코드가 새로 바뀐다 — 주인이 다시 초대해야 돌아올 수 있다. 주인은 나갈 수 없다 — 다이어리를 지운다 (BIZ_001)',
})
@ApiNoContentResponse({ description: '나가기 완료' })
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('BIZ_001', '주인은 나갈 수 없음')
async leave(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.diary.leaveDiary(user.userId, id);
}
@Delete(':id/members/:userId')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '함께하는 사람 내보내기 — 주인만',
description:
'초대 코드가 새로 바뀐다 — 내보낸 사람은 예전 코드로 다시 들어오지 못한다. 남긴 기록은 남는다',
})
@ApiNoContentResponse({ description: '내보내기 완료' })
@ApiErrorResponse('RES_001', '다이어리 또는 그 사람을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '주인이 아님')
@ApiErrorResponse('BIZ_001', '주인은 내보낼 수 없음')
async removeMember(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Param('userId', ParseUUIDPipe) memberId: string,
): Promise<void> {
await this.diary.removeMember(user.userId, id, memberId);
}
@Post(':id/posts')
@ApiOperation({ summary: '기록 남기기 (함께하는 사람 누구나, 하루 한 번)' })
@ApiStandardResponse(DiaryPostResponseDto, {
status: 201,
description: '남긴 기록',
})
@ApiErrorResponse('DIARY_002', '오늘 이미 기록함')
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
createPost(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreatePostDto,
): Promise<DiaryPostResponseDto> {
return this.diary.createPost(user.userId, id, dto);
}
}
@@ -0,0 +1,82 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { DiaryService } from './diary.service';
import { CommentDto, DiaryCommentResponseDto } from './dto/diary.dto';
// 다이어리 댓글 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Diary')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('diary-comments')
export class DiaryCommentsController {
constructor(private readonly diary: DiaryService) {}
@Get()
@ApiOperation({
summary: '내가 함께하는 다이어리들의 댓글 목록 (쓴 순, 커서 페이지네이션)',
})
@ApiPaginatedResponse(DiaryCommentResponseDto, '댓글 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryCommentResponseDto>> {
return this.diary.listComments(user.userId, query);
}
@Patch(':id')
@ApiOperation({ summary: '댓글 고치기 (내가 쓴 댓글만)' })
@ApiStandardResponse(DiaryCommentResponseDto, { description: '고친 댓글' })
@ApiErrorResponse('RES_001', '댓글을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 댓글이 아님')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
return this.diary.updateComment(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '댓글 삭제 (내가 쓴 댓글만)' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '댓글을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 댓글이 아님')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.diary.deleteComment(user.userId, id);
}
}
@@ -0,0 +1,77 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import type { FileAccessPolicy } from '@/modules/upload/file-access-policy';
import { UploadService } from '@/modules/upload/upload.service';
/**
* 함께하는 다이어리에서 보이는 남의 사진 — 내가 함께하는 다이어리 중 하나라도 이 파일을 보여 주면 허락한다.
* - 다이어리 표지
* - 그 다이어리 기록의 사진 (나간 사람이 남긴 기록 포함)
* - 그 다이어리 대상 아이의 사진 (주인의 아이)
* - 그 다이어리에 함께하거나 기록·댓글을 남긴 회원의 지금 프로필 사진
*/
const MEMBER_CAN_READ_FILE = `
SELECT EXISTS (
SELECT 1
FROM diary_members me
JOIN diaries d ON d.id = me.diary_id
JOIN files f ON f.id = $2
WHERE me.user_id = $1
AND (
d.cover_file_id = f.id
OR EXISTS (
SELECT 1 FROM diary_posts p
WHERE p.diary_id = d.id AND p.photo_file_id = f.id
)
OR EXISTS (
SELECT 1 FROM pets pet
WHERE pet.owner_id = d.owner_id
AND pet.photo_file_id = f.id
AND pet.id::text = ANY (d.target_ids)
)
OR EXISTS (
SELECT 1 FROM users u
WHERE u.id = f.owner_id
AND u.photo_file_id = f.id
AND (
EXISTS (
SELECT 1 FROM diary_members m
WHERE m.diary_id = d.id AND m.user_id = u.id
)
OR EXISTS (
SELECT 1 FROM diary_posts p
WHERE p.diary_id = d.id AND p.author_id = u.id
)
OR EXISTS (
SELECT 1 FROM diary_comments c
JOIN diary_posts cp ON cp.id = c.post_id
WHERE cp.diary_id = d.id AND c.author_id = u.id
)
)
)
)
) AS allowed`;
// 공유 다이어리의 사진 내려받기 규칙 — UploadService 에 등록한다 (file-access-policy.ts)
@Injectable()
export class DiaryFileAccess implements FileAccessPolicy, OnModuleInit {
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly uploads: UploadService,
) {}
onModuleInit(): void {
this.uploads.registerAccessPolicy(this);
}
async canRead(userId: string, fileId: string): Promise<boolean> {
const rows = await this.dataSource.query<{ allowed: boolean }[]>(
MEMBER_CAN_READ_FILE,
[userId, fileId],
);
return rows[0]?.allowed === true;
}
}
@@ -0,0 +1,78 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { InviteCodeLookup } from '@/common/decorators/invite-code-lookup.decorator';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { DiaryService } from './diary.service';
import {
DiaryInvitationDto,
DiaryResponseDto,
JoinDiaryDto,
} from './dto/diary.dto';
// 공유 다이어리 초대 — 코드를 받은 회원이 참여하기 전에 미리 본다
@ApiTags('Diary')
@ApiBearerAuth()
@InviteCodeLookup()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('diary-invitations')
export class DiaryInvitationsController {
constructor(private readonly diary: DiaryService) {}
@Get(':code')
@ApiOperation({
summary: '초대 코드로 다이어리 미리보기 (참여 전)',
description: '코드는 대소문자를 가리지 않는다',
})
@ApiParam({ name: 'code', example: 'RUP-7KQ2MX' })
@ApiStandardResponse(DiaryInvitationDto, { description: '초대된 다이어리' })
@ApiErrorResponse('DIARY_003', '코드에 맞는(공유 중인) 다이어리가 없음')
preview(
@CurrentUser() user: AuthUser,
@Param('code') code: string,
): Promise<DiaryInvitationDto> {
return this.diary.previewInvitation(user.userId, code);
}
}
// 공유 다이어리 참여
@ApiTags('Diary')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('diary-memberships')
export class DiaryMembershipsController {
constructor(private readonly diary: DiaryService) {}
@Post()
@InviteCodeLookup()
@ApiOperation({
summary: '초대 코드로 다이어리 참여',
description:
'확인 순서: DIARY_003(코드 없음·공유 중 아님) → DIARY_005(이미 함께함) → DIARY_004(인원 가득) → DIARY_001(내 책장 가득)',
})
@ApiStandardResponse(DiaryResponseDto, {
status: 201,
description: '참여한 다이어리',
})
@ApiErrorResponse('DIARY_003', '코드에 맞는(공유 중인) 다이어리가 없음')
@ApiErrorResponse('DIARY_005', '이미 함께하고 있음 (주인 포함)')
@ApiErrorResponse('DIARY_004', '참여 인원이 가득 참 (최대 4명)')
@ApiErrorResponse('DIARY_001', '내 책장이 가득 참 (최대 6권)')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
join(
@CurrentUser() user: AuthUser,
@Body() dto: JoinDiaryDto,
): Promise<DiaryResponseDto> {
return this.diary.joinDiary(user.userId, dto);
}
}
@@ -0,0 +1,104 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { DiaryService } from './diary.service';
import {
CommentDto,
DiaryCommentResponseDto,
DiaryPostResponseDto,
UpdatePostDto,
} from './dto/diary.dto';
// 다이어리 기록 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Diary')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('diary-posts')
export class DiaryPostsController {
constructor(private readonly diary: DiaryService) {}
@Get()
@ApiOperation({
summary: '내가 함께하는 다이어리들의 기록 목록 (쓴 순, 커서 페이지네이션)',
})
@ApiPaginatedResponse(DiaryPostResponseDto, '기록 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryPostResponseDto>> {
return this.diary.listPosts(user.userId, query);
}
@Patch(':id')
@ApiOperation({ summary: '기록 내용 고치기 (내가 쓴 기록만)' })
@ApiStandardResponse(DiaryPostResponseDto, { description: '고친 기록' })
@ApiErrorResponse('RES_001', '기록을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 기록이 아님')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePostDto,
): Promise<DiaryPostResponseDto> {
return this.diary.updatePost(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '기록 삭제 (댓글 함께, 내가 쓴 기록만)' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '기록을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 기록이 아님')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.diary.deletePost(user.userId, id);
}
@Post(':id/comments')
@ApiOperation({ summary: '댓글 쓰기 (함께하는 사람 누구나)' })
@ApiStandardResponse(DiaryCommentResponseDto, {
status: 201,
description: '쓴 댓글',
})
@ApiErrorResponse('RES_001', '기록을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
createComment(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
return this.diary.createComment(user.userId, id, dto);
}
}
@@ -0,0 +1,25 @@
import { countShelfDiaries } from './diary-shelf';
describe('countShelfDiaries', () => {
const archived = new Set(['old']);
it('대상이 모두 보관된 아이인 다이어리는 세지 않는다', () => {
expect(countShelfDiaries([['old'], ['old', 'old']], archived)).toBe(0);
});
it('함께 지내는 아이나 나(self)가 섞이면 책장에 둔다', () => {
expect(
countShelfDiaries(
[
['old', 'kong'],
['old', 'self'],
],
archived,
),
).toBe(2);
});
it('대상이 없는 다이어리도 책장에 둔다', () => {
expect(countShelfDiaries([[]], archived)).toBe(1);
});
});
+15
View File
@@ -0,0 +1,15 @@
// 다이어리 책장 — 부수효과 없는 순수 함수 (앱의 DiaryBook.shelf 와 같은 규칙)
/**
* 책장에 꽂힌 다이어리 수 — 대상이 모두 추억 보관함의 아이인 다이어리는 보관함에서 보므로 세지 않는다.
* 대상이 없는 다이어리는 책장에 둔다.
*/
export function countShelfDiaries(
diaryTargets: ReadonlyArray<readonly string[]>,
archivedPetIds: ReadonlySet<string>,
): number {
return diaryTargets.filter(
(targets) =>
!(targets.length > 0 && targets.every((id) => archivedPetIds.has(id))),
).length;
}
@@ -0,0 +1,29 @@
import { toMemberViews } from './diary-view';
const OWNER = 'owner-1';
describe('함께하는 사람', () => {
it('주인이 맨 앞, 나머지는 들어온 순서', () => {
const views = toMemberViews(
[
{ userId: 'b', joinedAt: new Date('2026-10-03T00:00:00Z') },
{ userId: OWNER, joinedAt: new Date('2026-10-05T00:00:00Z') },
{ userId: 'a', joinedAt: new Date('2026-10-01T00:00:00Z') },
],
OWNER,
);
expect(views.map((m) => m.userId)).toEqual([OWNER, 'a', 'b']);
expect(views.map((m) => m.isOwner)).toEqual([true, false, false]);
expect(views[1].joinedAt).toBe('2026-10-01T00:00:00.000Z');
});
it('회원 정보가 없으면 닉네임·사진은 null', () => {
const [view] = toMemberViews(
[{ userId: OWNER, joinedAt: new Date(0), user: null }],
OWNER,
);
expect(view.nickname).toBeNull();
expect(view.photoFileId).toBeNull();
});
});
+32
View File
@@ -0,0 +1,32 @@
// 다이어리 응답 조립 — 부수효과 없는 순수 함수 (함께하는 사람 표시)
// 대상 표시는 일정과 함께 쓰는 @/modules/pet/pet-targets 의 resolveTargets 를 쓴다.
import type { ProfileSource } from '@/modules/pet/pet-targets';
import type { DiaryMemberDto } from './dto/diary.dto';
/** 함께하는 사람 한 줄 (diary_members + users) */
export interface MemberSource {
readonly userId: string;
readonly joinedAt: Date;
readonly user?: ProfileSource | null;
}
/** 함께하는 사람 — 주인이 맨 앞, 나머지는 들어온 순서 */
export function toMemberViews(
members: readonly MemberSource[],
ownerId: string,
): DiaryMemberDto[] {
return [...members]
.sort((a, b) => {
if (a.userId === ownerId) return -1;
if (b.userId === ownerId) return 1;
return a.joinedAt.getTime() - b.joinedAt.getTime();
})
.map((m) => ({
userId: m.userId,
nickname: m.user?.nickname ?? null,
photoFileId: m.user?.photoFileId ?? null,
isOwner: m.userId === ownerId,
joinedAt: m.joinedAt.toISOString(),
}));
}
+46
View File
@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PetModule } from '@/modules/pet/pet.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { User } from '@/modules/user/entities/user.entity';
import { UserModule } from '@/modules/user/user.module';
import { DiariesController } from './diaries.controller';
import { DiaryCommentsController } from './diary-comments.controller';
import { DiaryFileAccess } from './diary-file-access';
import {
DiaryInvitationsController,
DiaryMembershipsController,
} from './diary-invitations.controller';
import { DiaryPostsController } from './diary-posts.controller';
import { DiaryService } from './diary.service';
import {
Diary,
DiaryComment,
DiaryMember,
DiaryPost,
} from './entities/diary.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
Diary,
DiaryMember,
DiaryPost,
DiaryComment,
User,
]),
PetModule,
UploadModule,
UserModule,
],
controllers: [
DiariesController,
DiaryPostsController,
DiaryCommentsController,
DiaryInvitationsController,
DiaryMembershipsController,
],
providers: [DiaryService, DiaryFileAccess],
})
export class DiaryModule {}
+722
View File
@@ -0,0 +1,722 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import {
DataSource,
EntityManager,
In,
IsNull,
Not,
ObjectLiteral,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { Pet } from '@/modules/pet/entities/pet.entity';
import {
PetLifecycleEvent,
PetLifecycleListener,
} from '@/modules/pet/pet-lifecycle';
import { PetService } from '@/modules/pet/pet.service';
import { UploadService } from '@/modules/upload/upload.service';
import { User } from '@/modules/user/entities/user.entity';
import {
UserLifecycleEvent,
UserLifecycleListener,
} from '@/modules/user/user-lifecycle';
import { UserService } from '@/modules/user/user.service';
import {
isUniqueViolation,
retryOnUniqueViolation,
} from '@/shared/utils/db-error';
import { LOCK_SCOPE, lockFor } from '@/shared/utils/db-lock';
import {
CommentDto,
CreateDiaryDto,
CreatePostDto,
DiaryCommentResponseDto,
DiaryInvitationDto,
DiaryPostResponseDto,
DiaryResponseDto,
DiarySharingDto,
JoinDiaryDto,
MutedNoticesDto,
UpdateDiaryDto,
UpdatePostDto,
} from './dto/diary.dto';
import {
DIARY_LIMITS,
Diary,
DiaryComment,
DiaryMember,
DiaryPost,
} from './entities/diary.entity';
import { countShelfDiaries } from './diary-shelf';
import { DIARY_INVITE_CODE } from './invite-code';
/** 초대 코드가 겹쳤을 때 다시 뽑는 횟수 — 32^6 가지라 실제로 겹치는 일은 드물다 */
const CODE_RETRY = 5;
const createdAtKeys = <T extends { createdAt: Date; id: string }>(
alias: string,
) => [
{
column: `${alias}.created_at`,
cast: 'timestamptz',
read: (row: T) => row.createdAt.toISOString(),
},
{ column: `${alias}.id`, cast: 'uuid', read: (row: T) => row.id },
];
/** [diaryColumn] 의 다이어리에 [userId] 가 함께하는 행만 남긴다 */
function onlyMemberOf<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
diaryColumn: string,
userId: string,
): SelectQueryBuilder<T> {
return qb.innerJoin(
DiaryMember,
'member',
`member.diary_id = ${diaryColumn} AND member.user_id = :memberUserId`,
{ memberUserId: userId },
);
}
/** 초대 코드가 겹치면(유니크 위반) 새 코드로 처음부터 다시 한다 */
const withFreshInviteCode = <T>(run: () => Promise<T>): Promise<T> =>
retryOnUniqueViolation(run, CODE_RETRY);
/**
* 다이어리 — 다이어리·기록·댓글, 그리고 함께 쓰기(초대 코드·참여·나가기).
* 다이어리에 함께하는 사람(diary_members, 주인 포함)만 읽고 쓴다 — 그 밖의 사람에게는 없는 것과 똑같이 RES_001.
*/
@Injectable()
export class DiaryService
implements OnModuleInit, PetLifecycleListener, UserLifecycleListener
{
private readonly logger = new Logger(DiaryService.name);
constructor(
@InjectRepository(Diary)
private readonly diaries: Repository<Diary>,
@InjectRepository(DiaryMember)
private readonly members: Repository<DiaryMember>,
@InjectRepository(DiaryPost)
private readonly posts: Repository<DiaryPost>,
@InjectRepository(DiaryComment)
private readonly comments: Repository<DiaryComment>,
@InjectRepository(User)
private readonly users: Repository<User>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly pets: PetService,
private readonly uploads: UploadService,
private readonly userService: UserService,
) {}
onModuleInit(): void {
this.pets.registerListener(this);
this.userService.registerListener(this);
}
// ── 다이어리 ───────────────────────────────────────────
/** 내가 함께하는 다이어리 (내가 만든 것 + 참여한 것) */
async listDiaries(
userId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryResponseDto>> {
const qb = onlyMemberOf(
this.diaries.createQueryBuilder('diary'),
'diary.id',
userId,
);
const page = await keysetPaginate(qb, createdAtKeys<Diary>('diary'), query);
return {
items: await this.toResponses(userId, page.items),
pageInfo: page.pageInfo,
};
}
/** 새 다이어리 — 책장이 가득 찼으면 DIARY_001. 만든 사람이 주인으로 함께한다 */
async createDiary(
userId: string,
dto: CreateDiaryDto,
): Promise<DiaryResponseDto> {
await this.pets.assertOwnedTargets(userId, dto.targetIds);
await this.uploads.assertOwnedImage(dto.coverFileId, userId);
const shared = dto.shared ?? false;
const diary = await withFreshInviteCode(() =>
this.dataSource.transaction(async (manager) => {
await this.assertShelfRoom(manager, userId);
const saved = await manager.save(
manager.create(Diary, {
ownerId: userId,
name: dto.name,
targetIds: [...new Set(dto.targetIds)],
ribbon: dto.ribbon,
tint: dto.tint,
coverFileId: dto.coverFileId ?? null,
shared,
inviteCode: shared ? DIARY_INVITE_CODE.random() : null,
}),
);
await manager.insert(DiaryMember, {
diaryId: saved.id,
userId,
mutedNotices: [],
});
return saved;
}),
);
return this.toResponse(userId, diary);
}
/** 다이어리 수정 — 주인만 (함께하는 사람은 AUTH_003) */
async updateDiary(
userId: string,
diaryId: string,
dto: UpdateDiaryDto,
): Promise<DiaryResponseDto> {
const diary = await this.findOwnedDiary(userId, diaryId);
const previousCover = diary.coverFileId;
if (dto.targetIds !== undefined) {
await this.pets.assertOwnedTargets(userId, dto.targetIds);
diary.targetIds = [...new Set(dto.targetIds)];
}
if (dto.coverFileId !== undefined) {
await this.uploads.assertOwnedImage(dto.coverFileId, userId);
diary.coverFileId = dto.coverFileId;
}
if (dto.name !== undefined) diary.name = dto.name;
if (dto.ribbon !== undefined) diary.ribbon = dto.ribbon;
const saved = await this.diaries.save(diary);
if (previousCover !== saved.coverFileId) {
await this.uploads.releaseIfUnused([previousCover]);
}
return this.toResponse(userId, saved);
}
/**
* 다이어리를 지우면 그 안의 기록·댓글과 함께하는 사람도 모두 지운다 (FK) — 쓰지 않게 된 사진도 정리한다.
* 주인만 (함께하는 사람은 AUTH_003 — 나가기를 쓴다)
*/
async deleteDiary(userId: string, diaryId: string): Promise<void> {
const diary = await this.findOwnedDiary(userId, diaryId);
const photos = await this.posts.find({
where: { diaryId, photoFileId: Not(IsNull()) },
select: { photoFileId: true },
});
await this.diaries.delete({ id: diaryId, ownerId: userId });
await this.uploads.releaseIfUnused([
diary.coverFileId,
...photos.map((p) => p.photoFileId),
]);
}
// ── 함께 쓰기 ─────────────────────────────────────────
/**
* 공유 켜기·끄기 — 주인만.
* 켜면 초대 코드를 만든다(이미 있으면 그대로). 끄면 주인 말고는 모두 빠지고 코드도 지운다 —
* 그 사람들이 남긴 기록·댓글은 다이어리에 남는다.
*/
async setSharing(
userId: string,
diaryId: string,
dto: DiarySharingDto,
): Promise<DiaryResponseDto> {
const diary = await this.findOwnedDiary(userId, diaryId);
if (dto.shared) {
if (!diary.shared || diary.inviteCode === null) {
await withFreshInviteCode(async () => {
const inviteCode = diary.inviteCode ?? DIARY_INVITE_CODE.random();
await this.diaries.update(
{ id: diaryId },
{ shared: true, inviteCode },
);
diary.inviteCode = inviteCode;
});
diary.shared = true;
}
} else {
await this.dataSource.transaction(async (manager) => {
// 다이어리를 먼저 바꿔 행을 잠근다 — 동시에 들어오던 참여는 꺼진 것을 보고 DIARY_003 이 된다
await manager.update(
Diary,
{ id: diaryId },
{ shared: false, inviteCode: null },
);
await manager.delete(DiaryMember, { diaryId, userId: Not(userId) });
});
diary.shared = false;
diary.inviteCode = null;
}
return this.toResponse(userId, diary);
}
/** 내 알림 설정 — 함께하는 사람 누구나 자기 것만 */
async setMutedNotices(
userId: string,
diaryId: string,
dto: MutedNoticesDto,
): Promise<void> {
const result = await this.members.update(
{ diaryId, userId },
{ mutedNotices: [...new Set(dto.mutedNotices)] },
);
if (!result.affected) {
throw new BusinessException('RES_001', { context: { diaryId } });
}
}
/**
* 다이어리에서 나가기 — 주인은 나갈 수 없다(BIZ_001, 다이어리를 지운다). 남긴 기록은 남는다.
* 초대 코드가 바뀌므로 주인이 다시 초대해야 돌아올 수 있다
*/
async leaveDiary(userId: string, diaryId: string): Promise<void> {
const diary = await this.findMemberDiary(userId, diaryId);
if (diary.ownerId === userId) {
throw new BusinessException('BIZ_001', {
context: { diaryId, reason: 'owner-cannot-leave' },
});
}
await this.dropMember(diaryId, userId);
this.logger.log(`공유 다이어리 나가기: ${diaryId} (회원 ${userId})`);
}
/**
* 함께하는 사람 내보내기 — 주인만. 주인 자신은 내보낼 수 없다(BIZ_001).
* 초대 코드가 바뀌므로 예전 코드로는 다시 들어오지 못한다
*/
async removeMember(
userId: string,
diaryId: string,
memberId: string,
): Promise<void> {
const diary = await this.findOwnedDiary(userId, diaryId);
if (memberId === diary.ownerId) {
throw new BusinessException('BIZ_001', {
context: { diaryId, reason: 'cannot-remove-owner' },
});
}
await this.dropMember(diaryId, memberId);
this.logger.log(`공유 다이어리 내보내기: ${diaryId} (회원 ${memberId})`);
}
/**
* 함께하는 사람 한 명을 빼고 초대 코드를 새로 바꾼다 (한 트랜잭션) — 빠진 사람이 갖고 있던 코드로는
* 다시 들어오지 못한다. 그 사람이 함께하고 있지 않았으면 RES_001 (코드도 그대로)
*/
private async dropMember(diaryId: string, userId: string): Promise<void> {
await withFreshInviteCode(() =>
this.dataSource.transaction(async (manager) => {
// 다이어리를 먼저 바꿔 행을 잠근다 — 예전 코드로 동시에 들어오던 참여는 DIARY_003 이 된다
await manager.update(
Diary,
{ id: diaryId, shared: true },
{ inviteCode: DIARY_INVITE_CODE.random() },
);
const result = await manager.delete(DiaryMember, { diaryId, userId });
if (!result.affected) {
throw new BusinessException('RES_001', {
context: { diaryId, memberId: userId },
});
}
}),
);
}
/** 초대 코드로 미리 보기 — 참여하기 전. 없거나 공유 중이 아니면 DIARY_003 */
async previewInvitation(
userId: string,
rawCode: string,
): Promise<DiaryInvitationDto> {
const code = DIARY_INVITE_CODE.normalize(rawCode);
const diary =
code === null
? null
: await this.diaries.findOne({
where: { inviteCode: code, shared: true },
relations: { owner: true },
});
if (!diary) {
throw new BusinessException('DIARY_003', { context: { code: rawCode } });
}
const members = await this.members.find({
where: { diaryId: diary.id },
select: { diaryId: true, userId: true },
});
return {
diaryId: diary.id,
name: diary.name,
ownerNickname: diary.owner?.nickname ?? null,
memberCount: members.length,
maxMembers: DIARY_LIMITS.maxMembers,
isMember: members.some((m) => m.userId === userId),
};
}
/**
* 초대 코드로 참여 — 이 순서로 확인한다:
* 코드가 없거나 공유 중이 아님(DIARY_003) → 이미 함께함(DIARY_005) → 인원 가득(DIARY_004) → 내 책장 가득(DIARY_001)
*/
async joinDiary(
userId: string,
dto: JoinDiaryDto,
): Promise<DiaryResponseDto> {
const code = DIARY_INVITE_CODE.normalize(dto.code);
if (code === null) {
throw new BusinessException('DIARY_003', { context: { code: dto.code } });
}
const diary = await this.dataSource.transaction(async (manager) => {
// 다이어리를 잠그고 센다 — 동시에 참여해 인원을 넘지 않게
const found = await manager.findOne(Diary, {
where: { inviteCode: code, shared: true },
lock: { mode: 'pessimistic_write' },
});
if (!found) {
throw new BusinessException('DIARY_003', { context: { code } });
}
const members = await manager.find(DiaryMember, {
where: { diaryId: found.id },
select: { diaryId: true, userId: true },
});
if (members.some((m) => m.userId === userId)) {
throw new BusinessException('DIARY_005', {
context: { diaryId: found.id },
});
}
if (members.length >= DIARY_LIMITS.maxMembers) {
throw new BusinessException('DIARY_004', {
context: { diaryId: found.id, members: members.length },
});
}
await this.assertShelfRoom(manager, userId);
await manager.insert(DiaryMember, {
diaryId: found.id,
userId,
mutedNotices: [],
});
return found;
});
this.logger.log(`공유 다이어리 참여: ${diary.id} (회원 ${userId})`);
return this.toResponse(userId, diary);
}
// ── 기록 ───────────────────────────────────────────────
/** 내가 함께하는 다이어리들의 모든 기록 (쓴 순서) */
async listPosts(
userId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryPostResponseDto>> {
const qb = onlyMemberOf(
this.posts.createQueryBuilder('post'),
'post.diary_id',
userId,
).leftJoinAndSelect('post.author', 'author');
const page = await keysetPaginate(
qb,
createdAtKeys<DiaryPost>('post'),
query,
);
return mapPage(page, (p) => DiaryPostResponseDto.from(p));
}
/** 기록 남기기 — 함께하는 사람 누구나. 한 사람이 한 다이어리에 하루 한 번 (DIARY_002) */
async createPost(
userId: string,
diaryId: string,
dto: CreatePostDto,
): Promise<DiaryPostResponseDto> {
await this.findMemberDiary(userId, diaryId);
await this.uploads.assertOwnedImage(dto.photoFileId, userId);
try {
const post = await this.posts.save(
this.posts.create({
diaryId,
authorId: userId,
date: dto.date,
text: dto.text,
mood: dto.mood ?? null,
photoFileId: dto.photoFileId ?? null,
}),
);
post.author = (await this.users.findOneBy({ id: userId })) ?? undefined;
return DiaryPostResponseDto.from(post);
} catch (error) {
if (isUniqueViolation(error)) {
throw new BusinessException('DIARY_002', {
context: { diaryId, date: dto.date },
});
}
throw error;
}
}
async updatePost(
userId: string,
postId: string,
dto: UpdatePostDto,
): Promise<DiaryPostResponseDto> {
const post = await this.findOwnPost(userId, postId);
post.text = dto.text;
return DiaryPostResponseDto.from(await this.posts.save(post));
}
/** 기록을 지우면 댓글도 함께 지워진다 (FK) */
async deletePost(userId: string, postId: string): Promise<void> {
const post = await this.findOwnPost(userId, postId);
await this.posts.delete({ id: post.id });
await this.uploads.releaseIfUnused([post.photoFileId]);
}
// ── 댓글 ───────────────────────────────────────────────
/** 내가 함께하는 다이어리들의 모든 댓글 (쓴 순서) */
async listComments(
userId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryCommentResponseDto>> {
const qb = onlyMemberOf(
this.comments
.createQueryBuilder('comment')
.innerJoin('comment.post', 'post'),
'post.diary_id',
userId,
).leftJoinAndSelect('comment.author', 'author');
const page = await keysetPaginate(
qb,
createdAtKeys<DiaryComment>('comment'),
query,
);
return mapPage(page, (c) => DiaryCommentResponseDto.from(c));
}
/** 댓글 쓰기 — 함께하는 사람 누구나 */
async createComment(
userId: string,
postId: string,
dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
await this.findReadablePost(userId, postId);
const comment = await this.comments.save(
this.comments.create({ postId, authorId: userId, text: dto.text }),
);
comment.author = (await this.users.findOneBy({ id: userId })) ?? undefined;
return DiaryCommentResponseDto.from(comment);
}
async updateComment(
userId: string,
commentId: string,
dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
const comment = await this.findOwnComment(userId, commentId);
comment.text = dto.text;
return DiaryCommentResponseDto.from(await this.comments.save(comment));
}
async deleteComment(userId: string, commentId: string): Promise<void> {
const comment = await this.findOwnComment(userId, commentId);
await this.comments.delete({ id: comment.id });
}
// ── 회원 탈퇴 ─────────────────────────────────────────
/**
* 주인이 탈퇴하면 다이어리가 기록째 지워진다 — 함께하는 사람이 올린 기록 사진(그 사람 소유)은
* 더 쓰이지 않게 되므로 커밋 뒤 정리하도록 돌려준다. 주인 자신의 사진은 files CASCADE 로 함께 지워진다.
*/
async onUserDeleting({
userId,
manager,
}: UserLifecycleEvent): Promise<Array<string | null>> {
const photos = await manager.find(DiaryPost, {
where: {
diary: { ownerId: userId },
authorId: Not(userId),
photoFileId: Not(IsNull()),
},
select: { id: true, photoFileId: true },
});
return photos.map((p) => p.photoFileId);
}
// ── 아이 삭제 ─────────────────────────────────────────
/** 아이를 지우면 다이어리 대상에서만 뗀다 — 다이어리와 기록은 남긴다 (앱의 dropTarget) */
async onPetDeleting({
ownerId,
petId,
manager,
}: PetLifecycleEvent): Promise<void> {
await manager.query(
`UPDATE diaries SET target_ids = array_remove(target_ids, $2)
WHERE owner_id = $1 AND $2 = ANY(target_ids)`,
[ownerId, petId],
);
}
// ── 책장 ───────────────────────────────────────────────
/**
* 책장에 한 권 더 꽂을 수 있는지 — 내가 함께하는 다이어리를 센다(추억 보관함의 내 아이만 담은 다이어리는 빼고).
* 동시에 만들거나 참여해 한도를 넘지 않도록 회원 단위로 잠그고 센다 — 행 잠금과 달리 아직 한 권도 없을 때도 잠긴다.
*/
private async assertShelfRoom(
manager: EntityManager,
userId: string,
): Promise<void> {
await lockFor(manager, LOCK_SCOPE.diaryShelf, userId);
const memberships = await manager.find(DiaryMember, {
where: { userId },
select: { diaryId: true, userId: true },
});
const diaryIds = memberships.map((m) => m.diaryId);
const diaries =
diaryIds.length === 0
? []
: await manager.find(Diary, {
where: { id: In(diaryIds) },
select: { id: true, targetIds: true },
});
const archived = await manager.find(Pet, {
where: { ownerId: userId, archivedAt: Not(IsNull()) },
select: { id: true },
});
const shelf = countShelfDiaries(
diaries.map((d) => d.targetIds),
new Set(archived.map((p) => p.id)),
);
if (shelf >= DIARY_LIMITS.maxDiaries) {
throw new BusinessException('DIARY_001', { context: { shelf } });
}
}
// ── 응답 조립 ─────────────────────────────────────────
private async toResponse(
viewerId: string,
diary: Diary,
): Promise<DiaryResponseDto> {
const [response] = await this.toResponses(viewerId, [diary]);
return response;
}
/** 다이어리 응답 — 함께하는 사람과 대상 아이를 한 번에 읽어 붙인다 */
private async toResponses(
viewerId: string,
diaries: readonly Diary[],
): Promise<DiaryResponseDto[]> {
if (diaries.length === 0) return [];
const members = await this.members.find({
where: { diaryId: In(diaries.map((d) => d.id)) },
relations: { user: true },
});
const petById = await this.pets.findTargetPets(
diaries.flatMap((d) => d.targetIds),
);
return diaries.map((diary) =>
DiaryResponseDto.from(diary, {
viewerId,
members: members.filter((m) => m.diaryId === diary.id),
pets: petById,
}),
);
}
// ── 조회 도우미 ───────────────────────────────────────
/** 내가 함께하는 다이어리 — 아니면 없는 것과 똑같이 RES_001 */
private async findMemberDiary(
userId: string,
diaryId: string,
): Promise<Diary> {
const member = await this.members.findOne({
where: { diaryId, userId },
relations: { diary: true },
});
if (!member?.diary) {
throw new BusinessException('RES_001', { context: { diaryId } });
}
return member.diary;
}
/** 내가 주인인 다이어리 — 함께하는 사람이면 AUTH_003, 아니면 RES_001 */
private async findOwnedDiary(
userId: string,
diaryId: string,
): Promise<Diary> {
const diary = await this.findMemberDiary(userId, diaryId);
if (diary.ownerId !== userId) {
throw new BusinessException('AUTH_003', { context: { diaryId } });
}
return diary;
}
/** 볼 수 있는 기록 — 내가 함께하는 다이어리의 기록 (쓴 사람 정보 포함) */
private async findReadablePost(
userId: string,
postId: string,
): Promise<DiaryPost> {
const post = await onlyMemberOf(
this.posts.createQueryBuilder('post'),
'post.diary_id',
userId,
)
.leftJoinAndSelect('post.author', 'author')
.where('post.id = :postId', { postId })
.getOne();
if (!post) throw new BusinessException('RES_001', { context: { postId } });
return post;
}
/** 고칠 수 있는 기록 — 내가 함께하는 다이어리에 내가 쓴 기록 (나간 뒤에는 RES_001) */
private async findOwnPost(
userId: string,
postId: string,
): Promise<DiaryPost> {
const post = await this.findReadablePost(userId, postId);
if (post.authorId !== userId) {
throw new BusinessException('AUTH_003', { context: { postId } });
}
return post;
}
/** 고칠 수 있는 댓글 — 내가 함께하는 다이어리에서 내가 쓴 댓글 (나간 뒤에는 RES_001) */
private async findOwnComment(
userId: string,
commentId: string,
): Promise<DiaryComment> {
const comment = await onlyMemberOf(
this.comments
.createQueryBuilder('comment')
.innerJoin('comment.post', 'post'),
'post.diary_id',
userId,
)
.leftJoinAndSelect('comment.author', 'author')
.where('comment.id = :commentId', { commentId })
.getOne();
if (!comment) {
throw new BusinessException('RES_001', { context: { commentId } });
}
if (comment.authorId !== userId) {
throw new BusinessException('AUTH_003', { context: { commentId } });
}
return comment;
}
}
+440
View File
@@ -0,0 +1,440 @@
import {
ApiProperty,
ApiPropertyOptional,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { IsHexColor, IsIsoDate, IsTargetIdList } from '@/common/validators';
import type { User } from '@/modules/user/entities/user.entity';
import { resolveTargets } from '@/modules/pet/pet-targets';
import type { TargetPetSource } from '@/modules/pet/pet-targets';
import { toMemberViews } from '../diary-view';
import { DIARY_LIMITS, DIARY_NOTICES, MOODS } from '../entities/diary.entity';
import type {
Diary,
DiaryComment,
DiaryMember,
DiaryNotice,
DiaryPost,
Mood,
} from '../entities/diary.entity';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** 초대 코드 입력 길이 상한 — 형식 검사는 서비스가 한다 (틀린 코드는 DIARY_003) */
const INVITE_CODE_INPUT_MAX_LENGTH = 20;
/** 새 다이어리 */
export class CreateDiaryDto {
@ApiProperty({
description: `이름 (1~${DIARY_LIMITS.name}자)`,
example: '콩이의 하루',
})
@Transform(trim)
@IsString({ message: '다이어리 이름을 입력해 주세요.' })
@IsNotEmpty({ message: '다이어리 이름을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.name, {
message: `다이어리 이름은 ${DIARY_LIMITS.name}자 이하로 입력해 주세요.`,
})
name!: string;
@ApiProperty({
description: '누구의 다이어리인지 — 아이 id 또는 "self". 비어 있어도 된다',
type: [String],
})
@IsTargetIdList(DIARY_LIMITS.targets, 0)
targetIds!: string[];
@ApiProperty({ description: '책갈피 리본 색 (#RRGGBB)', example: '#FF7500' })
@IsHexColor()
ribbon!: string;
@ApiProperty({
description: '표지 사진이 없을 때 바탕색 (#RRGGBB) — 앱이 고른다',
example: '#FF8A5B',
})
@IsHexColor()
tint!: string;
@ApiPropertyOptional({
description: '표지 사진 파일 ID (POST /api/files 로 올린 이미지)',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
coverFileId?: string | null;
@ApiPropertyOptional({
description:
'함께 쓰는 다이어리로 만들지 (기본 false) — 켜면 초대 코드가 생긴다',
default: false,
})
@IsOptional()
@IsBoolean({ message: '공유 설정이 올바르지 않습니다.' })
shared?: boolean;
}
/**
* 다이어리 수정 — 보낸 값만 바꾼다. 바탕색(tint)은 만들 때 정한 값을 유지한다.
* 공유는 PUT /diaries/:id/sharing, 알림은 PUT /diaries/:id/muted-notices 로 바꾼다.
*/
export class UpdateDiaryDto extends PartialType(
OmitType(CreateDiaryDto, ['tint', 'shared'] as const),
) {}
/** 공유 켜기·끄기 */
export class DiarySharingDto {
@ApiProperty({
description:
'켜면 초대 코드가 생긴다. 끄면 주인 말고는 모두 빠지고 초대 코드도 지워진다 (남긴 기록은 다이어리에 남는다)',
})
@IsBoolean({ message: '공유 설정이 올바르지 않습니다.' })
shared!: boolean;
}
/** 내 알림 설정 — 함께하는 사람마다 따로 둔다 */
export class MutedNoticesDto {
@ApiProperty({
description: '끈 알림 목록 (기본은 모두 켜짐)',
enum: DIARY_NOTICES,
isArray: true,
})
@IsArray({ message: '알림 설정이 올바르지 않습니다.' })
@ArrayMaxSize(DIARY_NOTICES.length, {
message: '알림 설정이 올바르지 않습니다.',
})
@IsIn(DIARY_NOTICES, {
each: true,
message: '알림 설정이 올바르지 않습니다.',
})
mutedNotices!: DiaryNotice[];
}
/** 초대 코드로 참여하기 */
export class JoinDiaryDto {
@ApiProperty({
description: '초대 코드 (RUP-XXXXXX, 대소문자 구분 없음)',
example: 'RUP-7KQ2MX',
})
@IsString({ message: '초대 코드를 입력해 주세요.' })
@IsNotEmpty({ message: '초대 코드를 입력해 주세요.' })
@MaxLength(INVITE_CODE_INPUT_MAX_LENGTH, {
message: '초대 코드가 올바르지 않습니다.',
})
code!: string;
}
/** 기록 남기기 — 하루 한 번 */
export class CreatePostDto {
@ApiProperty({
description:
'기록한 날 (앱 기준 오늘, YYYY-MM-DD) — 같은 날 두 번이면 DIARY_002',
example: '2026-10-11',
})
@IsIsoDate()
date!: string;
@ApiProperty({ description: `내용 (최대 ${DIARY_LIMITS.postText}자)` })
@Transform(trim)
@IsString({ message: '내용을 입력해 주세요.' })
@IsNotEmpty({ message: '내용을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.postText, {
message: `내용은 ${DIARY_LIMITS.postText}자 이하로 입력해 주세요.`,
})
text!: string;
@ApiPropertyOptional({ enum: MOODS, nullable: true })
@IsOptional()
@IsIn(MOODS, { message: '기분이 올바르지 않습니다.' })
mood?: Mood | null;
@ApiPropertyOptional({
description: '사진 파일 ID',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
photoFileId?: string | null;
}
/** 기록 내용 고치기 */
export class UpdatePostDto {
@ApiProperty({ description: `내용 (최대 ${DIARY_LIMITS.postText}자)` })
@Transform(trim)
@IsString({ message: '내용을 입력해 주세요.' })
@IsNotEmpty({ message: '내용을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.postText, {
message: `내용은 ${DIARY_LIMITS.postText}자 이하로 입력해 주세요.`,
})
text!: string;
}
/** 댓글 쓰기·고치기 */
export class CommentDto {
@ApiProperty({ description: `댓글 (최대 ${DIARY_LIMITS.comment}자)` })
@Transform(trim)
@IsString({ message: '댓글을 입력해 주세요.' })
@IsNotEmpty({ message: '댓글을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.comment, {
message: `댓글은 ${DIARY_LIMITS.comment}자 이하로 입력해 주세요.`,
})
text!: string;
}
/** 함께하는 사람 */
export class DiaryMemberDto {
@ApiProperty({ description: '회원 ID' }) userId!: string;
@ApiProperty({
description: '닉네임 — 모르면 null',
nullable: true,
type: String,
})
nickname!: string | null;
@ApiProperty({
description: '프로필 사진 파일 ID — GET /api/files/{id}',
nullable: true,
type: String,
})
photoFileId!: string | null;
@ApiProperty({ description: '다이어리 주인인지' }) isOwner!: boolean;
@ApiProperty({ description: '참여한 때 (ISO 8601 UTC)' }) joinedAt!: string;
}
/** 다이어리 대상 — 아이 또는 주인 자신("self") */
export class DiaryTargetDto {
@ApiProperty({ description: '아이 ID 또는 "self"' }) id!: string;
@ApiProperty({
description: '아이 이름 · self 면 주인 닉네임',
nullable: true,
type: String,
})
name!: string | null;
@ApiProperty({
description: '아이 사진 · self 면 주인 프로필 사진 (파일 ID)',
nullable: true,
type: String,
})
photoFileId!: string | null;
@ApiProperty({
description: '아이의 고유색 (#RRGGBB) · self 면 null',
nullable: true,
type: String,
})
tint!: string | null;
@ApiProperty({ description: '추억 보관함의 아이인지' }) archived!: boolean;
}
/** 기록·댓글을 쓴 사람 */
export class DiaryAuthorDto {
@ApiProperty({ description: '회원 ID' }) userId!: string;
@ApiProperty({
description: '닉네임 — 모르면 null',
nullable: true,
type: String,
})
nickname!: string | null;
@ApiProperty({
description: '프로필 사진 파일 ID — GET /api/files/{id}',
nullable: true,
type: String,
})
photoFileId!: string | null;
static from(userId: string, user?: User | null): DiaryAuthorDto {
return {
userId,
nickname: user?.nickname ?? null,
photoFileId: user?.photoFileId ?? null,
};
}
}
/** 다이어리 응답을 만들 때 필요한 주변 정보 */
export interface DiaryViewContext {
/** 응답을 받는 회원 */
readonly viewerId: string;
/** 이 다이어리에 함께하는 사람 (회원 정보 포함) */
readonly members: readonly DiaryMember[];
/** 대상 아이 (id → 아이) */
readonly pets: ReadonlyMap<string, TargetPetSource>;
}
/** 다이어리 응답 (앱의 Diary) */
export class DiaryResponseDto {
@ApiProperty() id!: string;
@ApiProperty() name!: string;
@ApiProperty({ type: [String] }) targetIds!: string[];
@ApiProperty({ example: '#FF7500' }) ribbon!: string;
@ApiProperty({ example: '#FF8A5B' }) tint!: string;
@ApiProperty({
description: '표지 사진 파일 ID — GET /api/files/{id}',
nullable: true,
type: String,
})
coverFileId!: string | null;
@ApiProperty({
description: '내가 끈 알림 (함께하는 사람마다 따로)',
enum: DIARY_NOTICES,
isArray: true,
})
mutedNotices!: DiaryNotice[];
@ApiProperty({ description: '만든 때 (ISO 8601 UTC)' })
createdAt!: string;
@ApiProperty({ description: '함께 쓰는 다이어리인지' }) shared!: boolean;
@ApiProperty({ description: '주인 (회원 ID)' }) ownerId!: string;
@ApiProperty({ description: '내가 주인인지' }) isOwner!: boolean;
@ApiProperty({
description: '초대 코드 — 내가 주인이고 공유 중일 때만, 아니면 null',
nullable: true,
type: String,
example: 'RUP-7KQ2MX',
})
inviteCode!: string | null;
@ApiProperty({
description: '함께하는 사람 — 주인이 맨 앞, 나머지는 들어온 순서',
type: [DiaryMemberDto],
})
members!: DiaryMemberDto[];
@ApiProperty({
description: '대상 — targetIds 순서. 더 이상 없는 대상은 빠진다',
type: [DiaryTargetDto],
})
targets!: DiaryTargetDto[];
static from(diary: Diary, context: DiaryViewContext): DiaryResponseDto {
const isOwner = diary.ownerId === context.viewerId;
const me = context.members.find((m) => m.userId === context.viewerId);
const owner = context.members.find((m) => m.userId === diary.ownerId);
return {
id: diary.id,
name: diary.name,
targetIds: diary.targetIds,
ribbon: diary.ribbon,
tint: diary.tint,
coverFileId: diary.coverFileId,
mutedNotices: me?.mutedNotices ?? [],
createdAt: diary.createdAt.toISOString(),
shared: diary.shared,
ownerId: diary.ownerId,
isOwner,
inviteCode: isOwner && diary.shared ? diary.inviteCode : null,
members: toMemberViews(context.members, diary.ownerId),
targets: resolveTargets(
diary.targetIds,
diary.ownerId,
context.pets,
owner?.user ?? null,
),
};
}
}
/** 초대 코드로 본 다이어리 — 참여하기 전에 보여 준다 */
export class DiaryInvitationDto {
@ApiProperty() diaryId!: string;
@ApiProperty({ description: '다이어리 이름' }) name!: string;
@ApiProperty({
description: '주인 닉네임 — 모르면 null',
nullable: true,
type: String,
})
ownerNickname!: string | null;
@ApiProperty({ description: '지금 함께하는 사람 수 (주인 포함)' })
memberCount!: number;
@ApiProperty({ description: '함께할 수 있는 최대 인원 (주인 포함)' })
maxMembers!: number;
@ApiProperty({ description: '내가 이미 함께하고 있는지' })
isMember!: boolean;
}
/** 기록 응답 (앱의 DiaryPost) */
export class DiaryPostResponseDto {
@ApiProperty() id!: string;
@ApiProperty() diaryId!: string;
@ApiProperty({ description: '기록한 사람 (회원 ID)' }) authorId!: string;
@ApiProperty({ description: '기록한 날 (YYYY-MM-DD)' }) date!: string;
@ApiProperty({ description: '쓴 때 (ISO 8601 UTC)' }) createdAt!: string;
@ApiProperty() text!: string;
@ApiProperty({ enum: MOODS, nullable: true }) mood!: Mood | null;
@ApiProperty({ nullable: true, type: String })
photoFileId!: string | null;
@ApiProperty({ description: '기록한 사람', type: DiaryAuthorDto })
author!: DiaryAuthorDto;
/** [post] 는 author 를 함께 읽어 와야 닉네임·사진이 채워진다 */
static from(post: DiaryPost): DiaryPostResponseDto {
return {
id: post.id,
diaryId: post.diaryId,
authorId: post.authorId,
date: post.date,
createdAt: post.createdAt.toISOString(),
text: post.text,
mood: post.mood,
photoFileId: post.photoFileId,
author: DiaryAuthorDto.from(post.authorId, post.author),
};
}
}
/** 댓글 응답 (앱의 DiaryComment) */
export class DiaryCommentResponseDto {
@ApiProperty() id!: string;
@ApiProperty() postId!: string;
@ApiProperty({ description: '쓴 사람 (회원 ID)' }) authorId!: string;
@ApiProperty() text!: string;
@ApiProperty({ description: '쓴 때 (ISO 8601 UTC)' }) createdAt!: string;
@ApiProperty({ description: '쓴 사람', type: DiaryAuthorDto })
author!: DiaryAuthorDto;
/** [comment] 는 author 를 함께 읽어 와야 닉네임·사진이 채워진다 */
static from(comment: DiaryComment): DiaryCommentResponseDto {
return {
id: comment.id,
postId: comment.postId,
authorId: comment.authorId,
text: comment.text,
createdAt: comment.createdAt.toISOString(),
author: DiaryAuthorDto.from(comment.authorId, comment.author),
};
}
}
@@ -0,0 +1,217 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryColumn,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
import { User } from '@/modules/user/entities/user.entity';
import { DIARY_INVITE_CODE } from '../invite-code';
/** 다이어리 규칙·입력 한도 — 앱의 diary.dart 상수와 맞춘다 */
export const DIARY_LIMITS = {
/** 책장에 꽂을 수 있는 다이어리 수 */
maxDiaries: 6,
/** 공유 다이어리에 함께하는 사람 수 (주인 포함) */
maxMembers: 4,
name: 18,
targets: 20,
postText: 2000,
comment: 50,
} as const;
/** 다이어리별 알림 — 새 글 / 내 글에 새 댓글 / 오늘 기록 잊지 않게 (앱의 DiaryNotice) */
export const DIARY_NOTICES = ['newPost', 'newComment', 'reminder'] as const;
export type DiaryNotice = (typeof DIARY_NOTICES)[number];
/** 기록할 때 고르는 오늘의 기분 (앱의 Mood) */
export const MOODS = [
'happy',
'excited',
'calm',
'meh',
'sad',
'sick',
] as const;
export type Mood = (typeof MOODS)[number];
/** 다이어리 한 권 */
@Entity('diaries')
export class Diary {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
@Column({ type: 'varchar', length: DIARY_LIMITS.name })
name!: string;
/** 누구의 다이어리인지 — 아이 id 또는 'self'. 아이를 지우면 그 아이만 뗀다(다이어리는 남긴다) */
@Column({ type: 'text', array: true, default: '{}' })
targetIds!: string[];
/** 책갈피 리본 색 `#RRGGBB` */
@Column({ type: 'varchar', length: 7 })
ribbon!: string;
/** 표지 사진이 없을 때 바탕색 `#RRGGBB` — 만들 때 앱이 고른다 */
@Column({ type: 'varchar', length: 7 })
tint!: string;
/** 표지 사진 — 참조 중인 파일은 지울 수 없게 둔다 (UploadService.releaseIfUnused) */
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'cover_file_id' })
coverFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
coverFileId!: string | null;
/** 함께 쓰는 다이어리인지 — 켜면 초대 코드로 다른 회원이 참여한다 */
@Column({ type: 'boolean', default: false })
shared!: boolean;
/** 초대 코드 `RUP-XXXXXX` — 공유 중일 때만 있다. 공유를 끄면 지워 예전 코드로는 들어올 수 없다 */
@Column({
type: 'varchar',
length: DIARY_INVITE_CODE.length,
nullable: true,
unique: true,
})
inviteCode!: string | null;
/** 함께하는 사람 — 주인도 한 줄로 들어 있다 */
@OneToMany(() => DiaryMember, (member) => member.diary)
members?: DiaryMember[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
/**
* 다이어리에 함께하는 사람 — 주인도 다이어리를 만들 때 한 줄로 들어간다.
* 알림 설정은 사람마다 다르므로 여기에 둔다.
*/
@Entity('diary_members')
export class DiaryMember {
@PrimaryColumn({ type: 'uuid' })
diaryId!: string;
@Index()
@PrimaryColumn({ type: 'uuid' })
userId!: string;
@ManyToOne(() => Diary, (diary) => diary.members, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'diary_id' })
diary?: Diary;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: User;
/** 끈 알림 (기본은 모두 켜짐) */
@Column({ type: 'text', array: true, default: '{}' })
mutedNotices!: DiaryNotice[];
@CreateDateColumn({ type: 'timestamptz' })
joinedAt!: Date;
}
/** 다이어리의 기록 한 건 — 한 사람이 한 다이어리에 하루 한 번 */
@Entity('diary_posts')
@Unique(['diaryId', 'authorId', 'date'])
export class DiaryPost {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => Diary, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'diary_id' })
diary?: Diary;
@Index()
@Column({ type: 'uuid' })
diaryId!: string;
/** 기록한 사람 — 다이어리에 함께하는 사람. 나가도 기록은 남는다 */
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author?: User;
@Index()
@Column({ type: 'uuid' })
authorId!: string;
/** 기록한 날 (앱 기준 날짜, YYYY-MM-DD) — 하루 한 번 쓰기와 달력에 쓴다 */
@Column({ type: 'date' })
date!: string;
@Column({ type: 'varchar', length: DIARY_LIMITS.postText })
text!: string;
@Column({ type: 'varchar', length: 10, nullable: true })
mood!: Mood | null;
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'photo_file_id' })
photoFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
photoFileId!: string | null;
/** 같은 날 기록끼리 순서를 정한다 */
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
/** 기록에 단 댓글 */
@Entity('diary_comments')
export class DiaryComment {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => DiaryPost, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'post_id' })
post?: DiaryPost;
@Index()
@Column({ type: 'uuid' })
postId!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author?: User;
@Index()
@Column({ type: 'uuid' })
authorId!: string;
@Column({ type: 'varchar', length: DIARY_LIMITS.comment })
text!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
+4
View File
@@ -0,0 +1,4 @@
import { inviteCodeFormat } from '@/shared/utils/invite-code';
/** 공유 다이어리 초대 코드 `RUP-XXXXXX` (뒷부분 6자) */
export const DIARY_INVITE_CODE = inviteCodeFormat(6);
@@ -15,6 +15,7 @@ import {
} from '@nestjs/terminus';
import type { Response } from 'express';
import { Public } from '@/common/decorators/public.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
// DB ping 타임아웃 — 기본값 1000ms 는 너무 짧다.
@@ -32,7 +33,9 @@ const DB_PING_TIMEOUT_MS = 5_000;
* - 실패 503: `{ status: 'error', error: { database: { status: 'down', message } }, ... }`
*
* 주기적으로 호출되므로 rate limit 대상에서도 제외한다 (한도를 소진시켜 실제 요청을 막으면 안 된다).
* 모니터링 도구는 로그인하지 않으므로 인증에서도 제외한다 (@Public).
*/
@Public()
@SkipThrottle()
@SkipTransform()
@ApiTags('Health')
@@ -0,0 +1,14 @@
import { formatCardCode, randomCardCode } from './card-code';
describe('명함 코드', () => {
it('7자리를 0000-000 형식으로 쓴다', () => {
expect(formatCardCode(4_281_170)).toBe('4281-170');
expect(formatCardCode(42)).toBe('0000-042');
});
it('무작위 코드도 같은 형식이다', () => {
for (let i = 0; i < 20; i += 1) {
expect(randomCardCode()).toMatch(/^\d{4}-\d{3}$/);
}
});
});
+17
View File
@@ -0,0 +1,17 @@
import { randomInt } from 'crypto';
/** 명함 코드 자릿수 — "0000-000" (앱이 보여 주는 형식) */
const CODE_DIGITS = 7;
const CODE_SPACE = 10 ** CODE_DIGITS;
const CODE_HEAD = 4;
/** 0 ~ 9,999,999 → "0000-000" */
export function formatCardCode(value: number): string {
const digits = String(value % CODE_SPACE).padStart(CODE_DIGITS, '0');
return `${digits.slice(0, CODE_HEAD)}-${digits.slice(CODE_HEAD)}`;
}
/** 새 명함 코드 — 추측하기 어렵게 무작위로 뽑는다 (겹치면 호출한 쪽이 다시 뽑는다) */
export function randomCardCode(): string {
return formatCardCode(randomInt(0, CODE_SPACE));
}
@@ -0,0 +1,87 @@
import type { Pet } from '@/modules/pet/entities/pet.entity';
import {
DETAIL_SHARE_TTL_MS,
effectiveShareMode,
petSnapshot,
} from './card-snapshot';
const pet = {
name: '콩이',
species: '강아지',
breed: '말티즈',
gender: 'male',
birthDate: '2021-09-14',
weight: 3.2,
weightUnit: 'kg',
microchip: '410123456789012',
tint: '#FF9B6A',
neutered: true,
bloodType: 'DEA 1.1',
vet: '드림 동물병원',
memo: '간식 좋아함',
allergies: ['닭고기'],
conditions: [],
watchConditions: ['슬개골'],
} as unknown as Pet;
describe('petSnapshot', () => {
it('간략히로 공유하면 건강 정보·병원·메모를 넘기지 않는다', () => {
const snapshot = petSnapshot(pet, 'simple');
expect(snapshot).toEqual({
name: '콩이',
species: '강아지',
breed: '말티즈',
gender: 'male',
birthDate: '2021-09-14',
weight: 3.2,
weightUnit: 'kg',
microchip: '410123456789012',
tint: '#FF9B6A',
});
expect(JSON.stringify(snapshot)).not.toContain('드림 동물병원');
});
it('상세히로 공유하면 상세 명함의 정보까지 넘긴다', () => {
expect(petSnapshot(pet, 'detail')).toMatchObject({
neutered: true,
bloodType: 'DEA 1.1',
vet: '드림 동물병원',
memo: '간식 좋아함',
allergies: ['닭고기'],
watchConditions: ['슬개골'],
});
});
});
describe('내보낼 공유 모양', () => {
const now = new Date('2026-10-20T00:00:00Z');
const setAt = (msAgo: number) => new Date(now.getTime() - msAgo);
it('간략히는 언제나 간략히', () => {
expect(
effectiveShareMode({ shareMode: 'simple', shareModeSetAt: null }, now),
).toBe('simple');
});
it('상세히는 정한 때부터 7일까지만 상세히, 그 뒤는 간략히', () => {
expect(
effectiveShareMode(
{ shareMode: 'detail', shareModeSetAt: setAt(DETAIL_SHARE_TTL_MS) },
now,
),
).toBe('detail');
expect(
effectiveShareMode(
{ shareMode: 'detail', shareModeSetAt: setAt(DETAIL_SHARE_TTL_MS + 1) },
now,
),
).toBe('simple');
});
it('정한 때를 모르는 상세히(규칙이 생기기 전)는 지난 것으로 본다', () => {
expect(
effectiveShareMode({ shareMode: 'detail', shareModeSetAt: null }, now),
).toBe('simple');
});
});
@@ -0,0 +1,71 @@
// 공유할 명함 정보 만들기 — 부수효과 없는 순수 함수
import type { Pet } from '@/modules/pet/entities/pet.entity';
import type { CardShareMode, PetCard } from './entities/pet-card.entity';
import type {
SharedCardStyle,
SharedPetSnapshot,
} from './entities/received-card.entity';
/** 상세히 공유가 유효한 기간 — 공유 모양을 정한 때부터 7일 */
export const DETAIL_SHARE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/**
* 받는 사람에게 실제로 내보낼 모양.
* 상세히(건강·병원·메모 포함)는 공유 시트를 연 뒤 7일 동안만 상세히로 내보내고, 그 뒤로는 간략히로 내보낸다 —
* 오래전에 퍼진 명함 코드로 건강 정보가 계속 새지 않게 한다. 다시 공유 시트를 열면(공유 모양을 다시 정하면) 7일이 새로 시작한다.
* 정한 때를 모르는 상세히(이 규칙이 생기기 전의 명함)는 이미 지난 것으로 본다.
* 미리보기와 보관함 저장(사본) 모두 이 모양을 쓴다.
*/
export function effectiveShareMode(
card: Pick<PetCard, 'shareMode' | 'shareModeSetAt'>,
now: Date,
): CardShareMode {
if (card.shareMode !== 'detail') return card.shareMode;
if (card.shareModeSetAt === null) return 'simple';
const age = now.getTime() - card.shareModeSetAt.getTime();
return age > DETAIL_SHARE_TTL_MS ? 'simple' : 'detail';
}
/**
* 공유한 모양에 맞춰 아이 정보를 잘라 낸다.
* 간략히 명함에 보이지 않는 정보(건강·병원·메모)는 상세히로 공유했을 때만 넘긴다 — 받는 사람에게 더 보여 주지 않는다.
*/
export function petSnapshot(pet: Pet, mode: CardShareMode): SharedPetSnapshot {
const basics: SharedPetSnapshot = {
name: pet.name,
species: pet.species,
breed: pet.breed,
gender: pet.gender,
birthDate: pet.birthDate,
weight: pet.weight,
weightUnit: pet.weightUnit,
microchip: pet.microchip,
tint: pet.tint,
};
if (mode === 'simple') return basics;
return {
...basics,
neutered: pet.neutered,
bloodType: pet.bloodType,
vet: pet.vet,
memo: pet.memo,
allergies: pet.allergies,
conditions: pet.conditions,
watchConditions: pet.watchConditions,
};
}
/** 명함의 꾸미기 값 — 배경 사진은 따로 다룬다 */
export function cardStyleSnapshot(card: PetCard): SharedCardStyle {
return {
tint: card.tint,
tint2: card.tint2,
nameBg: card.nameBg,
nameBgAsset: card.nameBgAsset,
textLight: card.textLight,
scrim: card.scrim,
scrimTone: card.scrimTone,
};
}
@@ -0,0 +1,171 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsOptional,
IsUUID,
Matches,
ValidateNested,
} from 'class-validator';
import { IsHexColor } from '@/common/validators';
import { SCRIM_TONES } from '../entities/pet-card.entity';
import type { PetCard, ScrimTone } from '../entities/pet-card.entity';
/**
* 앱에 들어 있는 배경 그림 경로 (assets/svg/card_bg/bg_1.svg 등) — 경로 탈출·다른 형식을 막는다.
* 앱 번들 안의 경로를 가리킬 뿐 서버가 이 파일을 내려주지 않으므로 svg 도 받는다.
*/
const ASSET_PATH_PATTERN =
/^assets\/[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*\.(png|jpg|jpeg|webp|svg)$/;
/** 명함 꾸미기 값 (앱의 CardStyle) — 배경은 색·그림·사진 중 하나만 */
export class CardStyleDto {
@ApiProperty({
description: '상단 그라데이션 첫 색 (#RRGGBB)',
example: '#FF9B6A',
})
@IsHexColor()
tint!: string;
@ApiProperty({
description: '상단 그라데이션 둘째 색 (#RRGGBB)',
example: '#FFC59B',
})
@IsHexColor()
tint2!: string;
@ApiPropertyOptional({
description: '배경색 (#RRGGBB) — 없으면 흰색',
nullable: true,
type: String,
})
@IsOptional()
@IsHexColor()
nameBg?: string | null;
@ApiPropertyOptional({
description: '앱에 들어 있는 배경 그림 경로 (assets/…)',
nullable: true,
type: String,
})
@IsOptional()
@Matches(ASSET_PATH_PATTERN, {
message: '배경 그림 정보가 올바르지 않습니다.',
})
nameBgAsset?: string | null;
@ApiPropertyOptional({
description: '배경 사진 파일 ID (POST /api/files 로 올린 이미지)',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
nameBgFileId?: string | null;
@ApiProperty({ description: '밝은 글자 (어두운 배경용)', example: false })
@IsBoolean()
textLight!: boolean;
@ApiProperty({ description: '글자 뒤 막 덮기', example: false })
@IsBoolean()
scrim!: boolean;
@ApiProperty({ enum: SCRIM_TONES, example: 'light' })
@IsIn(SCRIM_TONES, { message: '막 색이 올바르지 않습니다.' })
scrimTone!: ScrimTone;
}
/** 새 명함 */
export class CreatePetCardDto {
@ApiProperty({ description: '명함을 만들 아이 ID' })
@IsUUID('4', { message: '아이 정보가 올바르지 않습니다.' })
petId!: string;
@ApiProperty({ type: CardStyleDto })
@ValidateNested()
@Type(() => CardStyleDto)
style!: CardStyleDto;
}
/** 명함 꾸미기 바꾸기 — 꾸미기 값을 통째로 바꾼다 */
export class UpdatePetCardDto {
@ApiProperty({ type: CardStyleDto })
@ValidateNested()
@Type(() => CardStyleDto)
style!: CardStyleDto;
}
/** 대표 명함 지정 */
export class SetRepresentativeDto {
@ApiProperty({ description: '대표로 둘 명함 ID (그 아이의 명함이어야 한다)' })
@IsUUID('4', { message: '명함 정보가 올바르지 않습니다.' })
cardId!: string;
}
/** 명함 꾸미기 응답 */
export class CardStyleResponseDto {
@ApiProperty() tint!: string;
@ApiProperty() tint2!: string;
@ApiProperty({ nullable: true, type: String }) nameBg!: string | null;
@ApiProperty({ nullable: true, type: String }) nameBgAsset!: string | null;
@ApiProperty({ nullable: true, type: String }) nameBgFileId!: string | null;
@ApiProperty() textLight!: boolean;
@ApiProperty() scrim!: boolean;
@ApiProperty({ enum: SCRIM_TONES }) scrimTone!: ScrimTone;
}
/** 펫 명함 응답 (앱의 PetCard) */
export class PetCardResponseDto {
@ApiProperty() id!: string;
@ApiProperty() petId!: string;
@ApiProperty({ description: '명함 코드', example: '0428-117' })
code!: string;
@ApiProperty({ type: CardStyleResponseDto })
style!: CardStyleResponseDto;
@ApiProperty({ description: '아이의 대표 명함인지' })
isRepresentative!: boolean;
static from(card: PetCard): PetCardResponseDto {
return {
id: card.id,
petId: card.petId,
code: card.code,
style: {
tint: card.tint,
tint2: card.tint2,
nameBg: card.nameBg,
nameBgAsset: card.nameBgAsset,
nameBgFileId: card.nameBgFileId,
textLight: card.textLight,
scrim: card.scrim,
scrimTone: card.scrimTone,
},
isRepresentative: card.isRepresentative,
};
}
}
/**
* 꾸미기 DTO → 컬럼 값. 배경은 하나만 남긴다 — 사진 > 그림 > 색 순으로 우선한다
* (앱도 하나를 고르면 나머지를 비운다).
*/
export function toStyleColumns(style: CardStyleDto) {
const file = style.nameBgFileId ?? null;
const asset = file ? null : (style.nameBgAsset ?? null);
return {
tint: style.tint,
tint2: style.tint2,
nameBg: file || asset ? null : (style.nameBg ?? null),
nameBgAsset: asset,
nameBgFileId: file,
textLight: style.textLight,
scrim: style.scrim,
scrimTone: style.scrimTone,
};
}
@@ -0,0 +1,138 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { CARD_SHARE_MODES, SCRIM_TONES } from '../entities/pet-card.entity';
import type { CardShareMode, ScrimTone } from '../entities/pet-card.entity';
import { RECEIVED_NOTE_MAX_LENGTH } from '../entities/received-card.entity';
import type {
ReceivedCard,
SharedCardStyle,
SharedPetSnapshot,
} from '../entities/received-card.entity';
/** 명함 코드 "0000-000" */
export const CARD_CODE_PATTERN = /^\d{4}-\d{3}$/;
/** 공유한 모양 정하기 — 공유 시트를 열 때 앱이 보낸다 */
export class ShareModeDto {
@ApiProperty({ enum: CARD_SHARE_MODES, example: 'detail' })
@IsIn(CARD_SHARE_MODES, { message: '공유 모양이 올바르지 않습니다.' })
mode!: CardShareMode;
}
/** 명함 받기 — 코드와 나의 메모 */
export class ReceiveCardDto {
@ApiProperty({ description: '명함 코드 (0000-000)', example: '0428-117' })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@Matches(CARD_CODE_PATTERN, { message: '명함 코드가 올바르지 않습니다.' })
code!: string;
@ApiPropertyOptional({
description: `나의 메모 (최대 ${RECEIVED_NOTE_MAX_LENGTH}자)`,
nullable: true,
type: String,
})
@IsOptional()
@Transform(({ value }: { value: unknown }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
})
@IsString()
@MaxLength(RECEIVED_NOTE_MAX_LENGTH, {
message: `메모는 ${RECEIVED_NOTE_MAX_LENGTH}자 이하로 입력해 주세요.`,
})
note?: string | null;
}
/** 공유된 아이 정보 — 간략히면 건강 정보·병원·메모가 없다 */
export class SharedPetSnapshotDto implements SharedPetSnapshot {
@ApiProperty() name!: string;
@ApiProperty() species!: string;
@ApiProperty() breed!: string;
@ApiProperty({ example: 'male' }) gender!: string;
@ApiProperty({ example: '2021-09-14' }) birthDate!: string;
@ApiProperty() weight!: number;
@ApiProperty({ example: 'kg' }) weightUnit!: string;
@ApiProperty({ nullable: true, type: String }) microchip!: string | null;
@ApiProperty({ example: '#FF9B6A' }) tint!: string;
@ApiPropertyOptional() neutered?: boolean;
@ApiPropertyOptional({ nullable: true, type: String }) bloodType?:
| string
| null;
@ApiPropertyOptional({ nullable: true, type: String }) vet?: string | null;
@ApiPropertyOptional({ nullable: true, type: String }) memo?: string | null;
@ApiPropertyOptional({ type: [String] }) allergies?: string[];
@ApiPropertyOptional({ type: [String] }) conditions?: string[];
@ApiPropertyOptional({ type: [String] }) watchConditions?: string[];
}
/** 공유된 꾸미기 값 */
export class SharedCardStyleDto implements SharedCardStyle {
@ApiProperty() tint!: string;
@ApiProperty() tint2!: string;
@ApiProperty({ nullable: true, type: String }) nameBg!: string | null;
@ApiProperty({ nullable: true, type: String }) nameBgAsset!: string | null;
@ApiProperty() textLight!: boolean;
@ApiProperty() scrim!: boolean;
@ApiProperty({ enum: SCRIM_TONES }) scrimTone!: ScrimTone;
}
/**
* 받기 전 미리보기 — 사진은 GET /shared-pet-cards/{code}/images/{pet|sender|background} 로 받는다
* (아직 내 보관함에 없어 파일 id 를 주지 않는다).
*/
export class SharedCardPreviewDto {
@ApiProperty({ example: '0428-117' }) code!: string;
@ApiProperty({ enum: CARD_SHARE_MODES }) mode!: CardShareMode;
@ApiProperty({ nullable: true, type: String }) senderNickname!: string | null;
@ApiProperty() hasSenderPhoto!: boolean;
@ApiProperty({ type: SharedPetSnapshotDto }) pet!: SharedPetSnapshotDto;
@ApiProperty() hasPetPhoto!: boolean;
@ApiProperty({ type: SharedCardStyleDto }) style!: SharedCardStyleDto;
@ApiProperty() hasBackgroundPhoto!: boolean;
}
/** 보관함의 받은 명함 — 사진은 내 소유 사본이라 GET /api/files/{id} 로 받는다 */
export class ReceivedCardResponseDto {
@ApiProperty() id!: string;
@ApiProperty({ example: '0428-117' }) code!: string;
@ApiProperty({ enum: CARD_SHARE_MODES }) mode!: CardShareMode;
@ApiProperty({ nullable: true, type: String }) senderNickname!: string | null;
@ApiProperty({ nullable: true, type: String }) senderPhotoFileId!:
| string
| null;
@ApiProperty({ type: SharedPetSnapshotDto }) pet!: SharedPetSnapshotDto;
@ApiProperty({ nullable: true, type: String }) petPhotoFileId!: string | null;
@ApiProperty({ type: SharedCardStyleDto }) style!: SharedCardStyleDto;
@ApiProperty({ nullable: true, type: String }) backgroundFileId!:
| string
| null;
@ApiProperty({ nullable: true, type: String }) note!: string | null;
@ApiProperty({ description: '받은 때 (ISO 8601 UTC)' }) receivedAt!: string;
static from(card: ReceivedCard): ReceivedCardResponseDto {
return {
id: card.id,
code: card.code,
mode: card.mode,
senderNickname: card.senderNickname,
senderPhotoFileId: card.senderPhotoFileId,
pet: card.pet,
petPhotoFileId: card.petPhotoFileId,
style: card.style,
backgroundFileId: card.backgroundFileId,
note: card.note,
receivedAt: card.createdAt.toISOString(),
};
}
}
@@ -0,0 +1,120 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Pet } from '@/modules/pet/entities/pet.entity';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
import { User } from '@/modules/user/entities/user.entity';
/** 한 사람이 만들 수 있는 펫 명함 수 — 앱의 kMaxPetCards */
export const MAX_PET_CARDS = 10;
/** 명함을 공유한 모양 — 간략히(이름·종·생일 등만) / 상세히(건강 정보·병원·메모까지) (앱의 CardViewMode) */
export const CARD_SHARE_MODES = ['simple', 'detail'] as const;
export type CardShareMode = (typeof CARD_SHARE_MODES)[number];
/** 글자 뒤에 덮는 막의 색 (앱의 ScrimTone) */
export const SCRIM_TONES = ['light', 'dark'] as const;
export type ScrimTone = (typeof SCRIM_TONES)[number];
/**
* 펫 명함 — 어느 아이의 명함인지 + 꾸미기 값(앱의 CardStyle).
* 이름·품종 등 내용은 아이 정보를 그대로 쓰고 모양만 저장한다.
* 배경은 색(nameBg) · 앱에 들어 있는 그림(nameBgAsset) · 올린 사진(nameBgFileId) 중 하나만 쓴다.
*/
@Entity('pet_cards')
// 아이마다 대표 명함은 하나
@Index('UQ_pet_cards_representative', ['petId'], {
unique: true,
where: '"is_representative"',
})
export class PetCard {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
/** 아이를 지우면 그 아이의 명함도 지운다 */
@ManyToOne(() => Pet, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'pet_id' })
pet?: Pet;
@Column({ type: 'uuid' })
petId!: string;
/** 명함 코드 "0000-000" — 서버가 발급한다. 명함 받기에서 이 코드로 찾는다 */
@Column({ type: 'varchar', length: 8, unique: true })
code!: string;
/** 카드 상단 그라데이션 두 색 `#RRGGBB` */
@Column({ type: 'varchar', length: 7 })
tint!: string;
@Column({ type: 'varchar', length: 7 })
tint2!: string;
/** 배경색 — null 이면 흰색 */
@Column({ type: 'varchar', length: 7, nullable: true })
nameBg!: string | null;
/** 앱에 들어 있는 배경 그림 경로 (assets/…) */
@Column({ type: 'varchar', length: 120, nullable: true })
nameBgAsset!: string | null;
/** 올린 배경 사진 — 참조 중인 파일은 지울 수 없게 둔다 (UploadService.releaseIfUnused) */
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'name_bg_file_id' })
nameBgFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
nameBgFileId!: string | null;
/** 밝은 글자 (어두운 배경용) */
@Column({ type: 'boolean', default: false })
textLight!: boolean;
/** 글자 뒤에 은은한 막을 덮을지 */
@Column({ type: 'boolean', default: false })
scrim!: boolean;
@Column({ type: 'varchar', length: 5, default: 'light' })
scrimTone!: ScrimTone;
/** 아이의 대표 명함인지 */
@Column({ type: 'boolean', default: false })
isRepresentative!: boolean;
/**
* 공유한 모양 — 명함을 받은 사람에게 이 모양에 담긴 정보만 넘긴다.
* 공유 시트를 열 때 앱이 정한다. 한 번도 공유하지 않았으면 정보가 적은 간략히.
*/
@Column({ type: 'varchar', length: 6, default: 'simple' })
shareMode!: CardShareMode;
/**
* 공유 모양을 마지막으로 정한 때 — 상세히 공유는 이때부터 7일만 상세히로 내보낸다
* (card-snapshot.ts 의 effectiveShareMode). 이 값이 생기기 전의 명함은 null.
*/
@Column({ type: 'timestamptz', nullable: true })
shareModeSetAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,138 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
import { User } from '@/modules/user/entities/user.entity';
import { PetCard } from './pet-card.entity';
import type { CardShareMode, ScrimTone } from './pet-card.entity';
/** 받은 명함에 남기는 나의 메모 최대 길이 (시안 maxLength 200) */
export const RECEIVED_NOTE_MAX_LENGTH = 200;
/** 보관함에 둘 수 있는 받은 명함 수 — 넘으면 CARD_005 */
export const MAX_RECEIVED_CARDS = 100;
/**
* 받은 명함에 담는 아이 정보 — 받을 때 공유한 모양에 맞춰 잘라 낸 사본.
* 간략히는 이름·종·품종·성별·생일·몸무게·등록번호만, 상세히는 건강 정보·병원·메모까지.
*/
export interface SharedPetSnapshot {
name: string;
species: string;
breed: string;
gender: string;
birthDate: string;
weight: number;
weightUnit: string;
microchip: string | null;
/** 아이의 고유색 — 사진이 없을 때 아바타 바탕 */
tint: string;
neutered?: boolean;
bloodType?: string | null;
vet?: string | null;
memo?: string | null;
allergies?: string[];
conditions?: string[];
watchConditions?: string[];
}
/** 받은 명함의 꾸미기 값 — 배경 사진은 별도 컬럼(받는 사람 소유 사본) */
export interface SharedCardStyle {
tint: string;
tint2: string;
nameBg: string | null;
nameBgAsset: string | null;
textLight: boolean;
scrim: boolean;
scrimTone: ScrimTone;
}
/**
* 받은 명함(보관함) — 받는 순간의 사본이다. 보낸 사람이 아이 정보를 바꾸거나 명함을 지워도 그대로 남는다.
* 사진도 받는 사람 소유로 복사해 둔다 (원본이 지워져도 보이도록).
* 같은 명함은 보관함에 한 번만 둔다 — 원래 명함이 지워져 source_card_id 가 빈 사본은 세지 않는다 (CARD_004).
*/
@Entity('received_cards')
@Index('UQ_received_cards_owner_source', ['ownerId', 'sourceCardId'], {
unique: true,
where: '"source_card_id" IS NOT NULL',
})
export class ReceivedCard {
@PrimaryGeneratedColumn('uuid')
id!: string;
/** 받은 사람 — 탈퇴하면 함께 지운다 */
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
/** 원래 명함 — 보낸 사람이 지우면 비운다 (사본은 남는다) */
@ManyToOne(() => PetCard, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'source_card_id' })
sourceCard?: PetCard | null;
@Index()
@Column({ type: 'uuid', nullable: true })
sourceCardId!: string | null;
/** 받을 때의 명함 코드 */
@Column({ type: 'varchar', length: 8 })
code!: string;
/** 공유된 모양 — 보관함에서도 이 모양으로 보여 준다 */
@Column({ type: 'varchar', length: 6 })
mode!: CardShareMode;
/** 보낸 사람의 닉네임 (받은 때 기준) */
@Column({ type: 'varchar', length: 20, nullable: true })
senderNickname!: string | null;
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'sender_photo_file_id' })
senderPhotoFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
senderPhotoFileId!: string | null;
@Column({ type: 'jsonb' })
pet!: SharedPetSnapshot;
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'pet_photo_file_id' })
petPhotoFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
petPhotoFileId!: string | null;
@Column({ type: 'jsonb' })
style!: SharedCardStyle;
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'background_file_id' })
backgroundFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
backgroundFileId!: string | null;
/** 받은 사람이 남긴 메모 */
@Column({ type: 'varchar', length: RECEIVED_NOTE_MAX_LENGTH, nullable: true })
note!: string | null;
/** 받은 때 — 보관함 순서와 "받은 날짜" */
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PetModule } from '@/modules/pet/pet.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { PetCard } from './entities/pet-card.entity';
import { ReceivedCard } from './entities/received-card.entity';
import { PetCardService } from './pet-card.service';
import {
PetCardsController,
RepresentativeCardController,
} from './pet-cards.controller';
import { ReceivedCardService } from './received-card.service';
import {
ReceivedCardsController,
SharedPetCardsController,
} from './received-cards.controller';
@Module({
imports: [
TypeOrmModule.forFeature([PetCard, ReceivedCard]),
PetModule,
UploadModule,
],
controllers: [
PetCardsController,
RepresentativeCardController,
SharedPetCardsController,
ReceivedCardsController,
],
providers: [PetCardService, ReceivedCardService],
})
export class PetCardModule {}
@@ -0,0 +1,98 @@
import { DataSource, EntityManager, Repository } from 'typeorm';
import { PetService } from '@/modules/pet/pet.service';
import { UploadService } from '@/modules/upload/upload.service';
import { CardStyleDto, toStyleColumns } from './dto/pet-card.dto';
import { PetCard } from './entities/pet-card.entity';
import { PetCardService } from './pet-card.service';
const style = (overrides: Partial<CardStyleDto> = {}): CardStyleDto => ({
tint: '#FF9B6A',
tint2: '#FFC59B',
textLight: false,
scrim: false,
scrimTone: 'light',
...overrides,
});
describe('toStyleColumns', () => {
it('배경은 하나만 남긴다 — 사진 > 그림 > 색', () => {
expect(
toStyleColumns(
style({
nameBg: '#FFFFFF',
nameBgAsset: 'assets/a.png',
nameBgFileId: 'f',
}),
),
).toMatchObject({ nameBg: null, nameBgAsset: null, nameBgFileId: 'f' });
expect(
toStyleColumns(style({ nameBg: '#FFFFFF', nameBgAsset: 'assets/a.png' })),
).toMatchObject({ nameBg: null, nameBgAsset: 'assets/a.png' });
expect(toStyleColumns(style({ nameBg: '#FFFFFF' }))).toMatchObject({
nameBg: '#FFFFFF',
nameBgAsset: null,
nameBgFileId: null,
});
});
});
describe('PetCardService', () => {
const cards = { findOneBy: jest.fn(), update: jest.fn() };
const manager = { update: jest.fn(), find: jest.fn() };
const dataSource = {
transaction: jest.fn((work: (m: EntityManager) => Promise<unknown>) =>
work(manager as unknown as EntityManager),
),
};
const pets = { registerListener: jest.fn(), findOwned: jest.fn() };
const uploads = { assertOwnedImage: jest.fn(), releaseIfUnused: jest.fn() };
const service = new PetCardService(
cards as unknown as Repository<PetCard>,
dataSource as unknown as DataSource,
pets as unknown as PetService,
uploads as unknown as UploadService,
);
beforeEach(() => jest.clearAllMocks());
it('다른 아이의 명함은 대표로 정할 수 없다 (VAL_001)', async () => {
cards.findOneBy.mockResolvedValue({ id: 'c1', petId: 'pet-b' });
await expect(
service.setRepresentative('o', 'pet-a', 'c1'),
).rejects.toMatchObject({ code: 'VAL_001' });
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('대표를 바꾸면 그 아이의 기존 대표를 먼저 푼다', async () => {
cards.findOneBy.mockResolvedValue({ id: 'c1', petId: 'pet-a' });
await service.setRepresentative('o', 'pet-a', 'c1');
expect(manager.update).toHaveBeenNthCalledWith(
1,
PetCard,
{ petId: 'pet-a', isRepresentative: true },
{ isRepresentative: false },
);
expect(manager.update).toHaveBeenNthCalledWith(
2,
PetCard,
{ id: 'c1' },
{ isRepresentative: true },
);
});
it('아이를 지울 때 그 아이 명함의 배경 사진을 정리 대상으로 돌려준다', async () => {
manager.find.mockResolvedValue([{ nameBgFileId: 'bg-1' }]);
await expect(
service.onPetDeleting({
ownerId: 'o',
petId: 'pet-a',
manager: manager as unknown as EntityManager,
}),
).resolves.toEqual(['bg-1']);
});
});
@@ -0,0 +1,209 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, IsNull, Not, Repository } from 'typeorm';
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import {
PetLifecycleEvent,
PetLifecycleListener,
} from '@/modules/pet/pet-lifecycle';
import { PetService } from '@/modules/pet/pet.service';
import { UploadService } from '@/modules/upload/upload.service';
import { isUniqueViolation } from '@/shared/utils/db-error';
import { LOCK_SCOPE, lockFor } from '@/shared/utils/db-lock';
import { randomCardCode } from './card-code';
import {
CreatePetCardDto,
PetCardResponseDto,
UpdatePetCardDto,
toStyleColumns,
} from './dto/pet-card.dto';
import { MAX_PET_CARDS, PetCard } from './entities/pet-card.entity';
import type { CardShareMode } from './entities/pet-card.entity';
/** 명함 코드가 겹치면 다시 뽑는 횟수 — 천만 개 중에서 뽑으므로 거의 겹치지 않는다 */
const CODE_RETRY = 5;
// 펫 명함 — 만들기·꾸미기·삭제·대표 명함
@Injectable()
export class PetCardService implements OnModuleInit, PetLifecycleListener {
constructor(
@InjectRepository(PetCard)
private readonly cards: Repository<PetCard>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly pets: PetService,
private readonly uploads: UploadService,
) {}
onModuleInit(): void {
this.pets.registerListener(this);
}
/** 내 명함 목록 (만든 순) — 추억 보관함의 아이 명함도 함께 온다 */
async list(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<PetCardResponseDto>> {
const qb = this.cards
.createQueryBuilder('card')
.where('card.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
[
{
column: 'card.created_at',
cast: 'timestamptz',
read: (c) => c.createdAt.toISOString(),
},
{ column: 'card.id', cast: 'uuid', read: (c) => c.id },
],
query,
);
return mapPage(page, (c) => PetCardResponseDto.from(c));
}
/** 새 명함 — 최대 10장 (CARD_001). 코드는 서버가 발급한다 */
async create(
ownerId: string,
dto: CreatePetCardDto,
): Promise<PetCardResponseDto> {
await this.pets.findOwned(ownerId, dto.petId);
await this.uploads.assertOwnedImage(dto.style.nameBgFileId, ownerId);
for (let attempt = 1; ; attempt += 1) {
try {
return await this.insertCard(ownerId, dto);
} catch (error) {
// 코드가 겹쳤으면 다시 뽑는다 (그 밖의 유니크 위반은 없다)
if (!isUniqueViolation(error) || attempt >= CODE_RETRY) throw error;
}
}
}
async update(
ownerId: string,
cardId: string,
dto: UpdatePetCardDto,
): Promise<PetCardResponseDto> {
const card = await this.findOwnedCard(ownerId, cardId);
await this.uploads.assertOwnedImage(dto.style.nameBgFileId, ownerId);
const previousBg = card.nameBgFileId;
const saved = await this.cards.save(
this.cards.merge(card, toStyleColumns(dto.style)),
);
if (previousBg !== saved.nameBgFileId) {
await this.uploads.releaseIfUnused([previousBg]);
}
return PetCardResponseDto.from(saved);
}
/** 명함 삭제 — 대표 명함이었으면 대표 지정도 함께 사라진다 */
async remove(ownerId: string, cardId: string): Promise<void> {
const card = await this.findOwnedCard(ownerId, cardId);
await this.cards.delete({ id: card.id });
await this.uploads.releaseIfUnused([card.nameBgFileId]);
}
/** 아이의 대표 명함 정하기 — 그 아이의 명함이어야 한다 */
async setRepresentative(
ownerId: string,
petId: string,
cardId: string,
): Promise<void> {
const card = await this.findOwnedCard(ownerId, cardId);
if (card.petId !== petId) {
throw new BusinessException('VAL_001', {
context: { reason: 'card-of-other-pet', petId, cardId },
});
}
await this.dataSource.transaction(async (manager) => {
await manager.update(
PetCard,
{ petId, isRepresentative: true },
{ isRepresentative: false },
);
await manager.update(PetCard, { id: cardId }, { isRepresentative: true });
});
}
/**
* 공유 모양 정하기 — 공유 시트를 열 때 앱이 보낸다. 받는 사람에게는 이 모양의 정보만 간다.
* 정한 때도 남긴다 — 상세히는 그때부터 7일만 상세히로 내보낸다 (card-snapshot.ts 의 effectiveShareMode)
*/
async setShareMode(
ownerId: string,
cardId: string,
mode: CardShareMode,
): Promise<void> {
const result = await this.cards.update(
{ id: cardId, ownerId },
{ shareMode: mode, shareModeSetAt: new Date() },
);
if (!result.affected) {
throw new BusinessException('RES_001', { context: { cardId } });
}
}
/** 대표 명함 지정 풀기 — 앱은 그 아이의 첫 명함을 보여 준다 */
async clearRepresentative(ownerId: string, petId: string): Promise<void> {
await this.pets.findOwned(ownerId, petId);
await this.cards.update(
{ petId, ownerId, isRepresentative: true },
{ isRepresentative: false },
);
}
/** 아이를 지우면 명함은 FK 로 함께 지워진다 — 배경 사진은 커밋 뒤 정리하도록 돌려준다 */
async onPetDeleting({
petId,
manager,
}: PetLifecycleEvent): Promise<Array<string | null>> {
const withPhoto = await manager.find(PetCard, {
where: { petId, nameBgFileId: Not(IsNull()) },
select: { nameBgFileId: true },
});
return withPhoto.map((c) => c.nameBgFileId);
}
private async insertCard(
ownerId: string,
dto: CreatePetCardDto,
): Promise<PetCardResponseDto> {
return this.dataSource.transaction(async (manager) => {
// 동시에 만들어 한도를 넘지 않도록 회원 단위로 잠그고 센다 (명함이 하나도 없을 때도 잠긴다)
await lockFor(manager, LOCK_SCOPE.petCards, ownerId);
const count = await manager.countBy(PetCard, { ownerId });
if (count >= MAX_PET_CARDS) {
throw new BusinessException('CARD_001', { context: { count } });
}
const card = await manager.save(
manager.create(PetCard, {
ownerId,
petId: dto.petId,
code: randomCardCode(),
...toStyleColumns(dto.style),
isRepresentative: false,
}),
);
return PetCardResponseDto.from(card);
});
}
private async findOwnedCard(
ownerId: string,
cardId: string,
): Promise<PetCard> {
const card = await this.cards.findOneBy({ id: cardId, ownerId });
if (!card) throw new BusinessException('RES_001', { context: { cardId } });
return card;
}
}
@@ -0,0 +1,157 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import {
CreatePetCardDto,
PetCardResponseDto,
SetRepresentativeDto,
UpdatePetCardDto,
} from './dto/pet-card.dto';
import { ShareModeDto } from './dto/received-card.dto';
import { PetCardService } from './pet-card.service';
// 펫 명함 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('PetCard')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('pet-cards')
export class PetCardsController {
constructor(private readonly cards: PetCardService) {}
@Get()
@ApiOperation({ summary: '내 명함 목록 (만든 순, 커서 페이지네이션)' })
@ApiPaginatedResponse(PetCardResponseDto, '명함 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<PetCardResponseDto>> {
return this.cards.list(user.userId, query);
}
@Post()
@ApiOperation({ summary: '새 명함 (최대 10장, 코드는 서버가 발급)' })
@ApiStandardResponse(PetCardResponseDto, {
status: 201,
description: '만든 명함',
})
@ApiErrorResponse('CARD_001', '명함이 가득 참 (최대 10장)')
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreatePetCardDto,
): Promise<PetCardResponseDto> {
return this.cards.create(user.userId, dto);
}
@Put(':id')
@ApiOperation({ summary: '명함 꾸미기 바꾸기' })
@ApiStandardResponse(PetCardResponseDto, { description: '바꾼 명함' })
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePetCardDto,
): Promise<PetCardResponseDto> {
return this.cards.update(user.userId, id, dto);
}
@Put(':id/share-mode')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '공유 모양 정하기',
description:
'공유 시트를 열 때 보낸다. 명함을 받는 사람에게는 이 모양(간략히·상세히)의 정보만 간다',
})
@ApiNoContentResponse({ description: '저장 완료' })
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
async setShareMode(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ShareModeDto,
): Promise<void> {
await this.cards.setShareMode(user.userId, id, dto.mode);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '명함 삭제' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.cards.remove(user.userId, id);
}
}
// 아이의 대표 명함 — 아이의 하위 리소스로 둔다
@ApiTags('PetCard')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('pets')
export class RepresentativeCardController {
constructor(private readonly cards: PetCardService) {}
@Put(':petId/representative-card')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '대표 명함 정하기' })
@ApiNoContentResponse({ description: '대표 명함으로 정함' })
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '그 아이의 명함이 아님')
async set(
@CurrentUser() user: AuthUser,
@Param('petId', ParseUUIDPipe) petId: string,
@Body() dto: SetRepresentativeDto,
): Promise<void> {
await this.cards.setRepresentative(user.userId, petId, dto.cardId);
}
@Delete(':petId/representative-card')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '대표 명함 지정 풀기' })
@ApiNoContentResponse({ description: '지정을 풀었음' })
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
async clear(
@CurrentUser() user: AuthUser,
@Param('petId', ParseUUIDPipe) petId: string,
): Promise<void> {
await this.cards.clearRepresentative(user.userId, petId);
}
}
@@ -0,0 +1,230 @@
import { DataSource, QueryFailedError, Repository } from 'typeorm';
import { UploadService } from '@/modules/upload/upload.service';
import { PetCard } from './entities/pet-card.entity';
import { ReceivedCard } from './entities/received-card.entity';
import { ReceivedCardService } from './received-card.service';
const SENDER = 'sender-1';
const VIEWER = 'viewer-1';
function card(overrides: Partial<PetCard> = {}): PetCard {
return {
id: 'card-1',
ownerId: SENDER,
code: '0428-117',
shareMode: 'simple',
shareModeSetAt: null,
tint: '#FF9B6A',
tint2: '#FFC59B',
nameBg: null,
nameBgAsset: null,
nameBgFileId: 'bg-1',
textLight: false,
scrim: false,
scrimTone: 'light',
owner: { nickname: '초코아빠', photoFileId: 'sender-photo' },
pet: {
name: '초코',
species: '강아지',
breed: '닥스훈트',
gender: 'male',
birthDate: '2021-05-14',
weight: 7,
weightUnit: 'kg',
microchip: null,
tint: '#C9794A',
photoFileId: 'pet-photo',
vet: '드림 동물병원',
},
...overrides,
} as unknown as PetCard;
}
describe('ReceivedCardService', () => {
const cards = { findOne: jest.fn() };
const received = {
save: jest.fn(),
create: jest.fn((v: Partial<ReceivedCard>) => v),
};
const uploads = {
copyFor: jest.fn((id: string | null) =>
Promise.resolve(id === null ? null : `copy-of-${id}`),
),
releaseIfUnused: jest.fn(),
};
// 트랜잭션 안팎에서 같은 가짜 manager 를 쓴다 (잠금은 query 로 온다)
const manager = {
existsBy: jest.fn(),
countBy: jest.fn(),
query: jest.fn(),
create: jest.fn((_entity: unknown, v: Partial<ReceivedCard>) => v),
save: jest.fn(),
};
const dataSource = {
manager,
transaction: jest.fn((run: (m: typeof manager) => Promise<unknown>) =>
run(manager),
),
};
const service = new ReceivedCardService(
cards as unknown as Repository<PetCard>,
received as unknown as Repository<ReceivedCard>,
dataSource as unknown as DataSource,
uploads as unknown as UploadService,
);
beforeEach(() => {
jest.clearAllMocks();
manager.existsBy.mockResolvedValue(false);
manager.countBy.mockResolvedValue(0);
});
it('형식이 틀린 코드는 DB 를 보지 않고 CARD_002', async () => {
await expect(service.preview(VIEWER, 'abc')).rejects.toMatchObject({
code: 'CARD_002',
});
expect(cards.findOne).not.toHaveBeenCalled();
});
it('내 명함은 받을 수 없다 (CARD_003)', async () => {
cards.findOne.mockResolvedValue(card());
await expect(service.preview(SENDER, '0428-117')).rejects.toMatchObject({
code: 'CARD_003',
});
});
it('미리보기는 공유한 모양의 정보만 담고 사진 id 는 주지 않는다', async () => {
cards.findOne.mockResolvedValue(card());
const preview = await service.preview(VIEWER, '0428-117');
expect(preview.mode).toBe('simple');
expect(preview.pet).not.toHaveProperty('vet');
expect(preview).toMatchObject({
senderNickname: '초코아빠',
hasSenderPhoto: true,
hasPetPhoto: true,
hasBackgroundPhoto: true,
});
expect(JSON.stringify(preview)).not.toContain('pet-photo');
});
it('받으면 사진을 내 소유로 복사해 사본을 저장한다', async () => {
cards.findOne.mockResolvedValue(card());
manager.save.mockImplementation((v: Partial<ReceivedCard>) =>
Promise.resolve({ ...v, id: 'r1', createdAt: new Date() }),
);
const result = await service.receive(VIEWER, {
code: '0428-117',
note: '산책 친구',
});
expect(uploads.copyFor).toHaveBeenCalledWith('sender-photo', VIEWER);
expect(uploads.copyFor).toHaveBeenCalledWith('pet-photo', VIEWER);
expect(uploads.copyFor).toHaveBeenCalledWith('bg-1', VIEWER);
expect(result).toMatchObject({
petPhotoFileId: 'copy-of-pet-photo',
senderPhotoFileId: 'copy-of-sender-photo',
backgroundFileId: 'copy-of-bg-1',
note: '산책 친구',
});
});
it('저장하지 못하면 복사한 사진을 지운다', async () => {
cards.findOne.mockResolvedValue(card());
manager.save.mockRejectedValue(new Error('db down'));
await expect(service.receive(VIEWER, { code: '0428-117' })).rejects.toThrow(
'db down',
);
expect(uploads.releaseIfUnused).toHaveBeenCalledWith([
'copy-of-sender-photo',
'copy-of-pet-photo',
'copy-of-bg-1',
]);
});
it('사진 복사가 하나라도 실패하면 이미 만든 사본을 지운다', async () => {
cards.findOne.mockResolvedValue(card());
uploads.copyFor.mockImplementation((id: string | null) =>
id === 'pet-photo'
? Promise.reject(new Error('disk full'))
: Promise.resolve(id === null ? null : `copy-of-${id}`),
);
await expect(service.receive(VIEWER, { code: '0428-117' })).rejects.toThrow(
'disk full',
);
expect(uploads.releaseIfUnused).toHaveBeenCalledWith([
'copy-of-sender-photo',
null,
'copy-of-bg-1',
]);
expect(manager.save).not.toHaveBeenCalled();
});
it('이미 보관함에 있는 명함은 사진을 복사하지 않고 CARD_004', async () => {
cards.findOne.mockResolvedValue(card());
manager.existsBy.mockResolvedValue(true);
await expect(
service.receive(VIEWER, { code: '0428-117' }),
).rejects.toMatchObject({ code: 'CARD_004' });
expect(uploads.copyFor).not.toHaveBeenCalled();
});
it('보관함이 가득 차면(100장) CARD_005', async () => {
cards.findOne.mockResolvedValue(card());
manager.countBy.mockResolvedValue(100);
await expect(
service.receive(VIEWER, { code: '0428-117' }),
).rejects.toMatchObject({ code: 'CARD_005' });
});
it('동시에 같은 명함을 받아 유니크 제약에 걸리면 사본을 지우고 CARD_004', async () => {
cards.findOne.mockResolvedValue(card());
uploads.copyFor.mockImplementation((id: string | null) =>
Promise.resolve(id === null ? null : `copy-of-${id}`),
);
manager.save.mockRejectedValue(
new QueryFailedError(
'INSERT',
[],
Object.assign(new Error('duplicate'), { code: '23505' }),
),
);
await expect(
service.receive(VIEWER, { code: '0428-117' }),
).rejects.toMatchObject({ code: 'CARD_004' });
expect(uploads.releaseIfUnused).toHaveBeenCalledWith([
'copy-of-sender-photo',
'copy-of-pet-photo',
'copy-of-bg-1',
]);
});
it('상세히 공유는 정한 지 7일이 지나면 간략히로 보인다', async () => {
const day = 24 * 60 * 60 * 1000;
cards.findOne.mockResolvedValue(
card({ shareMode: 'detail', shareModeSetAt: new Date(Date.now() - day) }),
);
const fresh = await service.preview(VIEWER, '0428-117');
expect(fresh.mode).toBe('detail');
expect(fresh.pet).toHaveProperty('vet', '드림 동물병원');
cards.findOne.mockResolvedValue(
card({
shareMode: 'detail',
shareModeSetAt: new Date(Date.now() - 8 * day),
}),
);
const stale = await service.preview(VIEWER, '0428-117');
expect(stale.mode).toBe('simple');
expect(stale.pet).not.toHaveProperty('vet');
});
});
@@ -0,0 +1,226 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { FileDownload, UploadService } from '@/modules/upload/upload.service';
import { isUniqueViolation } from '@/shared/utils/db-error';
import { LOCK_SCOPE, lockFor } from '@/shared/utils/db-lock';
import {
cardStyleSnapshot,
effectiveShareMode,
petSnapshot,
} from './card-snapshot';
import {
CARD_CODE_PATTERN,
ReceiveCardDto,
ReceivedCardResponseDto,
SharedCardPreviewDto,
} from './dto/received-card.dto';
import { PetCard } from './entities/pet-card.entity';
import {
MAX_RECEIVED_CARDS,
ReceivedCard,
} from './entities/received-card.entity';
/** 공유된 명함에서 내려받을 수 있는 사진 */
export const SHARED_IMAGE_KINDS = ['pet', 'sender', 'background'] as const;
export type SharedImageKind = (typeof SHARED_IMAGE_KINDS)[number];
// 명함 받기 — 코드로 다른 회원의 명함을 미리 보고, 보관함에 사본으로 저장한다
@Injectable()
export class ReceivedCardService {
private readonly logger = new Logger(ReceivedCardService.name);
constructor(
@InjectRepository(PetCard)
private readonly cards: Repository<PetCard>,
@InjectRepository(ReceivedCard)
private readonly received: Repository<ReceivedCard>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly uploads: UploadService,
) {}
/** 받기 전 미리보기 — 아직 보관하지 않는다. 상세히는 7일이 지나면 간략히로 보인다 */
async preview(viewerId: string, code: string): Promise<SharedCardPreviewDto> {
const card = await this.findShared(viewerId, code);
const pet = card.pet!;
const mode = effectiveShareMode(card, new Date());
return {
code: card.code,
mode,
senderNickname: card.owner?.nickname ?? null,
hasSenderPhoto: card.owner?.photoFileId != null,
pet: petSnapshot(pet, mode),
hasPetPhoto: pet.photoFileId !== null,
style: cardStyleSnapshot(card),
hasBackgroundPhoto: card.nameBgFileId !== null,
};
}
/** 미리보기 사진 — 코드를 아는 회원에게만, 그 명함에 보이는 사진만 */
async previewImage(
viewerId: string,
code: string,
kind: SharedImageKind,
): Promise<FileDownload> {
const card = await this.findShared(viewerId, code);
const fileId = sharedFileId(card, kind);
if (fileId === null) {
throw new BusinessException('RES_001', { context: { code, kind } });
}
return this.uploads.downloadUnchecked(fileId);
}
/**
* 보관함에 저장 — 받는 순간의 사본을 만들고 사진도 내 소유로 복사한다.
* 같은 명함이 이미 있으면 CARD_004, 보관함이 가득 찼으면(100장) CARD_005.
*/
async receive(
viewerId: string,
dto: ReceiveCardDto,
): Promise<ReceivedCardResponseDto> {
const card = await this.findShared(viewerId, dto.code);
const pet = card.pet!;
const mode = effectiveShareMode(card, new Date());
// 사진을 복사하기 전에 먼저 걸러 본다 — 확정은 아래 트랜잭션에서 잠그고 다시 한다
await this.assertRoom(this.dataSource.manager, viewerId, card.id);
let copies: Array<string | null> = [];
try {
// 하나가 실패해도 나머지 복사가 끝나기를 기다려 만들어진 사본을 모두 지울 수 있게 한다
const settled = await Promise.allSettled([
this.uploads.copyFor(card.owner?.photoFileId ?? null, viewerId),
this.uploads.copyFor(pet.photoFileId, viewerId),
this.uploads.copyFor(card.nameBgFileId, viewerId),
]);
copies = settled.map((r) => (r.status === 'fulfilled' ? r.value : null));
const failed = settled.find((r) => r.status === 'rejected');
if (failed) throw failed.reason;
const [senderPhotoFileId, petPhotoFileId, backgroundFileId] = copies;
const saved = await this.dataSource.transaction(async (manager) => {
// 동시에 받아 한도를 넘거나 같은 명함이 두 번 들어가지 않게 회원 단위로 잠그고 다시 센다
await lockFor(manager, LOCK_SCOPE.receivedCards, viewerId);
await this.assertRoom(manager, viewerId, card.id);
return manager.save(
manager.create(ReceivedCard, {
ownerId: viewerId,
sourceCardId: card.id,
code: card.code,
mode,
senderNickname: card.owner?.nickname ?? null,
senderPhotoFileId,
pet: petSnapshot(pet, mode),
petPhotoFileId,
style: cardStyleSnapshot(card),
backgroundFileId,
note: dto.note ?? null,
}),
);
});
this.logger.log(
`명함 받기: ${saved.id} (명함 ${card.id} → 회원 ${viewerId})`,
);
return ReceivedCardResponseDto.from(saved);
} catch (error) {
// 저장하지 못했으면 복사한 사진도 지운다
await this.uploads.releaseIfUnused(copies);
if (isUniqueViolation(error)) {
throw new BusinessException('CARD_004', {
context: { cardId: card.id },
});
}
throw error;
}
}
/** 보관함에 더 넣을 수 있는지 — 같은 명함이 있으면 CARD_004, 가득 찼으면 CARD_005 */
private async assertRoom(
manager: EntityManager,
ownerId: string,
sourceCardId: string,
): Promise<void> {
if (await manager.existsBy(ReceivedCard, { ownerId, sourceCardId })) {
throw new BusinessException('CARD_004', {
context: { cardId: sourceCardId },
});
}
const count = await manager.countBy(ReceivedCard, { ownerId });
if (count >= MAX_RECEIVED_CARDS) {
throw new BusinessException('CARD_005', { context: { count } });
}
}
/** 보관함 (받은 순) */
async list(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ReceivedCardResponseDto>> {
const qb = this.received
.createQueryBuilder('received')
.where('received.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
[
{
column: 'received.created_at',
cast: 'timestamptz',
read: (r) => r.createdAt.toISOString(),
},
{ column: 'received.id', cast: 'uuid', read: (r) => r.id },
],
query,
);
return mapPage(page, (r) => ReceivedCardResponseDto.from(r));
}
/** 보관함에서 삭제 — 복사해 둔 사진도 정리한다 */
async remove(ownerId: string, receivedId: string): Promise<void> {
const card = await this.received.findOneBy({ id: receivedId, ownerId });
if (!card) {
throw new BusinessException('RES_001', { context: { receivedId } });
}
await this.received.delete({ id: card.id });
await this.uploads.releaseIfUnused([
card.senderPhotoFileId,
card.petPhotoFileId,
card.backgroundFileId,
]);
}
/** 코드로 공유된 명함 찾기 — 없거나 형식이 틀리면 CARD_002, 내 명함이면 CARD_003 */
private async findShared(viewerId: string, code: string): Promise<PetCard> {
const card = CARD_CODE_PATTERN.test(code)
? await this.cards.findOne({
where: { code },
relations: { pet: true, owner: true },
})
: null;
if (!card?.pet) {
throw new BusinessException('CARD_002', { context: { code } });
}
if (card.ownerId === viewerId) {
throw new BusinessException('CARD_003', { context: { code } });
}
return card;
}
}
/** 공유된 명함에서 [kind] 사진의 파일 id */
function sharedFileId(card: PetCard, kind: SharedImageKind): string | null {
switch (kind) {
case 'pet':
return card.pet?.photoFileId ?? null;
case 'sender':
return card.owner?.photoFileId ?? null;
case 'background':
return card.nameBgFileId;
}
}
@@ -0,0 +1,156 @@
import {
Body,
Controller,
Delete,
Get,
Header,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
StreamableFile,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { InviteCodeLookup } from '@/common/decorators/invite-code-lookup.decorator';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import {
FILE_CACHE_CONTROL,
toStreamableFile,
} from '@/modules/upload/file-response';
import {
ReceiveCardDto,
ReceivedCardResponseDto,
SharedCardPreviewDto,
} from './dto/received-card.dto';
import {
ReceivedCardService,
SHARED_IMAGE_KINDS,
} from './received-card.service';
import type { SharedImageKind } from './received-card.service';
// 공유된 명함 — 코드를 아는 회원이 받기 전에 미리 본다
@ApiTags('PetCard')
@ApiBearerAuth()
@InviteCodeLookup()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('shared-pet-cards')
export class SharedPetCardsController {
constructor(private readonly received: ReceivedCardService) {}
@Get(':code')
@ApiOperation({
summary: '명함 미리보기 (받기 전)',
description: '보낸 사람이 공유한 모양(간략히·상세히)의 정보만 온다',
})
@ApiParam({ name: 'code', example: '0428-117' })
@ApiStandardResponse(SharedCardPreviewDto, { description: '공유된 명함' })
@ApiErrorResponse('CARD_002', '코드에 맞는 명함이 없음')
@ApiErrorResponse('CARD_003', '내 명함')
preview(
@CurrentUser() user: AuthUser,
@Param('code') code: string,
): Promise<SharedCardPreviewDto> {
return this.received.preview(user.userId, code);
}
@Get(':code/images/:kind')
@SkipTransform() // 파일 본문 — { success, data } 로 감싸면 깨진다
@Header('Cache-Control', FILE_CACHE_CONTROL)
@ApiOperation({ summary: '미리보기 사진 (아이·보낸 사람·배경)' })
@ApiParam({ name: 'kind', enum: SHARED_IMAGE_KINDS })
@ApiOkResponse({ description: '사진 본문' })
@ApiErrorResponse('CARD_002', '코드에 맞는 명함이 없음')
@ApiErrorResponse('RES_001', '그 사진이 없음')
async image(
@CurrentUser() user: AuthUser,
@Param('code') code: string,
@Param('kind') kind: string,
): Promise<StreamableFile> {
if (!isSharedImageKind(kind)) {
throw new BusinessException('RES_001', { context: { kind } });
}
return toStreamableFile(
await this.received.previewImage(user.userId, code, kind),
);
}
}
// 받은 명함 보관함
@ApiTags('PetCard')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('received-cards')
export class ReceivedCardsController {
constructor(private readonly received: ReceivedCardService) {}
@Get()
@ApiOperation({ summary: '받은 명함 목록 (받은 순, 커서 페이지네이션)' })
@ApiPaginatedResponse(ReceivedCardResponseDto, '받은 명함 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ReceivedCardResponseDto>> {
return this.received.list(user.userId, query);
}
@Post()
@InviteCodeLookup()
@ApiOperation({
summary: '명함 받기 (보관함에 저장)',
description:
'받는 순간의 사본을 저장한다 — 보낸 사람이 정보를 바꾸거나 명함을 지워도 그대로 남는다',
})
@ApiStandardResponse(ReceivedCardResponseDto, {
status: 201,
description: '보관한 명함',
})
@ApiErrorResponse('CARD_002', '코드에 맞는 명함이 없음')
@ApiErrorResponse('CARD_003', '내 명함')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
receive(
@CurrentUser() user: AuthUser,
@Body() dto: ReceiveCardDto,
): Promise<ReceivedCardResponseDto> {
return this.received.receive(user.userId, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '보관함에서 삭제' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '받은 명함을 찾을 수 없음')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.received.remove(user.userId, id);
}
}
function isSharedImageKind(kind: string): kind is SharedImageKind {
return (SHARED_IMAGE_KINDS as readonly string[]).includes(kind);
}
@@ -0,0 +1,211 @@
import {
ApiProperty,
ApiPropertyOptional,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
import { IsHexColor, IsIsoDate, IsShortStringList } from '@/common/validators';
import { PET_GENDERS, PET_LIMITS, WEIGHT_UNITS } from '../entities/pet.entity';
import type { PetGender, WeightUnit } from '../entities/pet.entity';
/** 앞뒤 공백을 떼고, 비면 null — 선택 입력(혈액형·병원·메모)은 빈 칸을 "없음"으로 저장한다 */
const trimToNull = ({ value }: { value: unknown }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
};
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** 아이 등록 — 앱의 등록 화면 입력값 (PetInput) */
export class CreatePetDto {
@ApiProperty({
description:
'목록 띠·칩에 쓰는 고유색 (#RRGGBB) — 앱이 등록 순서대로 정한다',
example: '#FF9B6A',
})
@IsHexColor()
tint!: string;
@ApiProperty({
description: `이름 (최대 ${PET_LIMITS.name}자)`,
example: '콩이',
})
@Transform(trim)
@IsString({ message: '이름은 문자로 입력해 주세요.' })
@IsNotEmpty({ message: '이름을 입력해 주세요.' })
@MaxLength(PET_LIMITS.name, {
message: `이름은 ${PET_LIMITS.name}자 이하로 입력해 주세요.`,
})
name!: string;
@ApiProperty({ description: '대분류 (강아지·고양이 …)', example: '강아지' })
@IsString({ message: '종류를 골라 주세요.' })
@IsNotEmpty({ message: '종류를 골라 주세요.' })
@MaxLength(PET_LIMITS.species, { message: '종류가 올바르지 않습니다.' })
species!: string;
@ApiProperty({
description: '세부 품종 — 고르지 않았으면 빈 문자열',
example: '말티즈',
})
@Transform(trim)
@IsString({ message: '품종은 문자로 입력해 주세요.' })
@MaxLength(PET_LIMITS.breed, {
message: `품종은 ${PET_LIMITS.breed}자 이하로 입력해 주세요.`,
})
breed!: string;
@ApiProperty({ description: '성별', enum: PET_GENDERS, example: 'male' })
@IsIn(PET_GENDERS, { message: '성별을 골라 주세요.' })
gender!: PetGender;
@ApiProperty({ description: '중성화 여부', example: true })
@IsBoolean({ message: '중성화 여부를 골라 주세요.' })
neutered!: boolean;
@ApiProperty({ description: '생일 (YYYY-MM-DD)', example: '2021-09-14' })
@IsIsoDate()
birthDate!: string;
@ApiProperty({ description: '몸무게 (weightUnit 단위)', example: 3.2 })
@IsNumber(
{ allowNaN: false, allowInfinity: false },
{ message: '몸무게는 숫자로 입력해 주세요.' },
)
@Min(0, { message: '몸무게는 0 이상이어야 합니다.' })
@Max(PET_LIMITS.maxWeight, { message: '몸무게가 너무 큽니다.' })
weight!: number;
@ApiProperty({
description: '몸무게 단위',
enum: WEIGHT_UNITS,
example: 'kg',
})
@IsIn(WEIGHT_UNITS, { message: '몸무게 단위를 골라 주세요.' })
weightUnit!: WeightUnit;
@ApiPropertyOptional({
description: '사진 파일 ID (POST /api/files 로 올린 이미지). 없으면 null',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
photoFileId?: string | null;
@ApiPropertyOptional({
description: '동물등록번호',
nullable: true,
type: String,
})
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(PET_LIMITS.microchip, { message: '동물등록번호가 너무 깁니다.' })
microchip?: string | null;
@ApiPropertyOptional({ description: '혈액형', nullable: true, type: String })
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(PET_LIMITS.bloodType, { message: '혈액형이 너무 깁니다.' })
bloodType?: string | null;
@ApiPropertyOptional({
description: '다니는 병원',
nullable: true,
type: String,
})
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(PET_LIMITS.vet, {
message: `병원 이름은 ${PET_LIMITS.vet}자 이하로 입력해 주세요.`,
})
vet?: string | null;
@ApiPropertyOptional({ description: '메모', nullable: true, type: String })
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(PET_LIMITS.memo, {
message: `메모는 ${PET_LIMITS.memo}자 이하로 입력해 주세요.`,
})
memo?: string | null;
@ApiProperty({ description: '알레르기', type: [String], example: ['닭고기'] })
@IsShortStringList(PET_LIMITS.listItems, PET_LIMITS.listItemLength)
allergies!: string[];
@ApiProperty({ description: '현재 보유 질환', type: [String] })
@IsShortStringList(PET_LIMITS.listItems, PET_LIMITS.listItemLength)
conditions!: string[];
@ApiProperty({ description: '염려되는 질환', type: [String] })
@IsShortStringList(PET_LIMITS.listItems, PET_LIMITS.listItemLength)
watchConditions!: string[];
}
/**
* 아이 정보 수정 — 보낸 값만 바꾼다.
* 고유색은 앱이 정한 값이라 바꾸지 않고, 보관 기록은 /archive 로만 바꾼다.
*/
export class UpdatePetDto extends PartialType(
OmitType(CreatePetDto, ['tint'] as const),
) {}
/** 추억 보관함으로 옮기기 */
export class ArchivePetDto {
@ApiProperty({
description: '함께한 첫날 (YYYY-MM-DD)',
example: '2015-04-02',
})
@IsIsoDate()
from!: string;
@ApiProperty({ description: '마지막 날 (YYYY-MM-DD)', example: '2026-10-01' })
@IsIsoDate()
to!: string;
@ApiPropertyOptional({
description: '함께한 시간에 대한 메모',
nullable: true,
type: String,
})
@IsOptional()
@Transform(trimToNull)
@ValidateIf((dto: ArchivePetDto) => dto.memo !== null)
@IsString()
@MaxLength(PET_LIMITS.memo, {
message: `메모는 ${PET_LIMITS.memo}자 이하로 입력해 주세요.`,
})
memo?: string | null;
}
/** 우리 아이 순서 바꾸기 — [ids] 순서로 두고, 빠진 아이는 원래 순서대로 뒤에 둔다 */
export class ReorderPetsDto {
@ApiProperty({ description: '앞에서부터 놓을 아이 id', type: [String] })
@IsArray({ message: '순서 정보가 올바르지 않습니다.' })
@ArrayMaxSize(500, { message: '순서 정보가 올바르지 않습니다.' })
@IsUUID('4', { each: true, message: '순서 정보가 올바르지 않습니다.' })
ids!: string[];
}
@@ -0,0 +1,126 @@
import { ApiProperty } from '@nestjs/swagger';
import { PET_GENDERS, WEIGHT_UNITS } from '../entities/pet.entity';
import type { Pet, PetGender, WeightUnit } from '../entities/pet.entity';
/** 추억 보관함 기록 */
export class PetArchiveDto {
@ApiProperty({
description: '함께한 첫날 (YYYY-MM-DD)',
example: '2015-04-02',
})
from!: string;
@ApiProperty({ description: '마지막 날 (YYYY-MM-DD)', example: '2026-10-01' })
to!: string;
@ApiProperty({ description: '보관한 때 (ISO 8601 UTC)' })
archivedAt!: string;
@ApiProperty({ description: '메모', nullable: true, type: String })
memo!: string | null;
}
/** 반려동물 — 앱의 Pet 모델과 같은 모양 */
export class PetResponseDto {
@ApiProperty({ description: '아이 ID' })
id!: string;
@ApiProperty({ description: '고유색 (#RRGGBB)', example: '#FF9B6A' })
tint!: string;
@ApiProperty({ example: '콩이' })
name!: string;
@ApiProperty({ description: '대분류', example: '강아지' })
species!: string;
@ApiProperty({
description: '세부 품종 (없으면 빈 문자열)',
example: '말티즈',
})
breed!: string;
@ApiProperty({ enum: PET_GENDERS })
gender!: PetGender;
@ApiProperty()
neutered!: boolean;
@ApiProperty({ description: '생일 (YYYY-MM-DD)', example: '2021-09-14' })
birthDate!: string;
@ApiProperty({ example: 3.2 })
weight!: number;
@ApiProperty({ enum: WEIGHT_UNITS })
weightUnit!: WeightUnit;
@ApiProperty({
description: '사진 파일 ID — GET /api/files/{id}',
nullable: true,
type: String,
})
photoFileId!: string | null;
@ApiProperty({ nullable: true, type: String })
microchip!: string | null;
@ApiProperty({ nullable: true, type: String })
bloodType!: string | null;
@ApiProperty({ nullable: true, type: String })
vet!: string | null;
@ApiProperty({ nullable: true, type: String })
memo!: string | null;
@ApiProperty({ type: [String] })
allergies!: string[];
@ApiProperty({ type: [String] })
conditions!: string[];
@ApiProperty({ type: [String] })
watchConditions!: string[];
@ApiProperty({
description: '추억 보관함 기록 — 함께 지내는 아이는 null',
nullable: true,
type: PetArchiveDto,
})
archive!: PetArchiveDto | null;
static from(pet: Pet): PetResponseDto {
const { archiveFrom, archiveTo, archivedAt } = pet;
return {
id: pet.id,
tint: pet.tint,
name: pet.name,
species: pet.species,
breed: pet.breed,
gender: pet.gender,
neutered: pet.neutered,
birthDate: pet.birthDate,
weight: pet.weight,
weightUnit: pet.weightUnit,
photoFileId: pet.photoFileId,
microchip: pet.microchip,
bloodType: pet.bloodType,
vet: pet.vet,
memo: pet.memo,
allergies: pet.allergies,
conditions: pet.conditions,
watchConditions: pet.watchConditions,
archive:
archiveFrom !== null && archiveTo !== null && archivedAt !== null
? {
from: archiveFrom,
to: archiveTo,
archivedAt: archivedAt.toISOString(),
memo: pet.archiveMemo,
}
: null,
};
}
}
@@ -0,0 +1,134 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
import { User } from '@/modules/user/entities/user.entity';
/** 입력 한도 — 앱 화면에는 제한이 없어 넉넉하게 두고, 과도한 입력만 막는다 */
export const PET_LIMITS = {
name: 30,
species: 20,
breed: 60,
microchip: 30,
bloodType: 20,
vet: 60,
memo: 1000,
listItems: 50,
listItemLength: 30,
maxWeight: 100_000,
} as const;
export const PET_GENDERS = ['male', 'female'] as const;
export type PetGender = (typeof PET_GENDERS)[number];
export const WEIGHT_UNITS = ['kg', 'g'] as const;
export type WeightUnit = (typeof WEIGHT_UNITS)[number];
/** 등록한 반려동물 — 추억 보관함으로 옮긴 아이도 같은 테이블에 남는다(archive* 값이 채워진다) */
@Entity('pets')
export class Pet {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
/** 우리 아이 순서 — 작은 값이 앞 */
@Column({ type: 'integer' })
sortOrder!: number;
/** 목록 띠·칩에 쓰는 아이의 고유색 `#RRGGBB` (앱이 등록 순서대로 정한다) */
@Column({ type: 'varchar', length: 7 })
tint!: string;
@Column({ type: 'varchar', length: PET_LIMITS.name })
name!: string;
/** 대분류 — 강아지·고양이 … */
@Column({ type: 'varchar', length: PET_LIMITS.species })
species!: string;
/** 세부 품종 — 고르지 않았으면 빈 문자열 */
@Column({ type: 'varchar', length: PET_LIMITS.breed, default: '' })
breed!: string;
@Column({ type: 'varchar', length: 6 })
gender!: PetGender;
@Column({ type: 'boolean' })
neutered!: boolean;
/** 생일 (시각 없음, YYYY-MM-DD) */
@Column({ type: 'date' })
birthDate!: string;
@Column({ type: 'double precision' })
weight!: number;
@Column({ type: 'varchar', length: 2 })
weightUnit!: WeightUnit;
/** 아이 사진 — 참조 중인 파일은 지울 수 없게 둔다 (UploadService.releaseIfUnused) */
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'photo_file_id' })
photoFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
photoFileId!: string | null;
@Column({ type: 'varchar', length: PET_LIMITS.microchip, nullable: true })
microchip!: string | null;
@Column({ type: 'varchar', length: PET_LIMITS.bloodType, nullable: true })
bloodType!: string | null;
@Column({ type: 'varchar', length: PET_LIMITS.vet, nullable: true })
vet!: string | null;
@Column({ type: 'varchar', length: PET_LIMITS.memo, nullable: true })
memo!: string | null;
@Column({ type: 'text', array: true, default: '{}' })
allergies!: string[];
/** 현재 보유 질환 */
@Column({ type: 'text', array: true, default: '{}' })
conditions!: string[];
/** 염려되는 질환 */
@Column({ type: 'text', array: true, default: '{}' })
watchConditions!: string[];
/** 추억 보관함 — 함께한 첫날·마지막 날·보관한 때·메모. 함께 지내는 아이는 모두 null */
@Column({ type: 'date', nullable: true })
archiveFrom!: string | null;
@Column({ type: 'date', nullable: true })
archiveTo!: string | null;
@Column({ type: 'timestamptz', nullable: true })
archivedAt!: Date | null;
@Column({ type: 'varchar', length: PET_LIMITS.memo, nullable: true })
archiveMemo!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
+27
View File
@@ -0,0 +1,27 @@
import { EntityManager } from 'typeorm';
/** 아이에게 생긴 일 — 다른 도메인이 자기 기록을 맞춘다 */
export interface PetLifecycleEvent {
readonly ownerId: string;
readonly petId: string;
/** 아이를 바꾸는 트랜잭션 — 같은 트랜잭션에서 정리해야 반쯤 지워진 상태가 남지 않는다 */
readonly manager: EntityManager;
}
/**
* 아이의 삭제·보관을 따라 정리해야 하는 도메인(일정·다이어리·명함)이 구현한다.
* 각 모듈이 onModuleInit 에서 PetService.registerListener 로 등록한다 —
* 반려동물 모듈이 다른 도메인을 알지 않아도 되고(의존 방향: 다른 도메인 → 반려동물), 정리는 한 트랜잭션에 묶인다.
*/
export interface PetLifecycleListener {
/**
* 아이를 지우기 직전. 그 아이를 대상에서 떼는 등 정리를 한다.
* 정리하면서 더 쓰지 않게 된 파일 id 를 돌려주면 커밋한 뒤 지운다.
*/
onPetDeleting?(
event: PetLifecycleEvent,
): Promise<ReadonlyArray<string | null> | void>;
/** 아이를 추억 보관함으로 옮긴 직후 */
onPetArchived?(event: PetLifecycleEvent): Promise<void>;
}
+22
View File
@@ -0,0 +1,22 @@
import { reorderIds } from './pet-order';
describe('reorderIds', () => {
const current = ['a', 'b', 'c', 'd'];
it('요청한 순서로 앞에 두고 빠진 아이는 원래 순서대로 뒤에 둔다', () => {
expect(reorderIds(current, ['c', 'a'])).toEqual(['c', 'a', 'b', 'd']);
});
it('모르는 id 와 중복은 무시한다', () => {
expect(reorderIds(current, ['x', 'b', 'b', 'a'])).toEqual([
'b',
'a',
'c',
'd',
]);
});
it('빈 요청이면 순서가 그대로다', () => {
expect(reorderIds(current, [])).toEqual(current);
});
});
+15
View File
@@ -0,0 +1,15 @@
// 우리 아이 순서 — 부수효과 없는 순수 함수 (앱의 reorderPets 와 같은 규칙)
/**
* [requested] 순서로 줄 세운 id 목록. 빠진 아이(추억 보관함 등)는 원래 순서대로 뒤에 붙인다.
* [current] 에 없는 id 는 무시하고, 중복은 처음 나온 자리만 쓴다.
*/
export function reorderIds(
current: readonly string[],
requested: readonly string[],
): string[] {
const known = new Set(current);
const front = [...new Set(requested)].filter((id) => known.has(id));
const placed = new Set(front);
return [...front, ...current.filter((id) => !placed.has(id))];
}
@@ -0,0 +1,63 @@
import { resolveTargets } from './pet-targets';
import type { TargetPetSource } from './pet-targets';
const OWNER = 'owner-1';
function pet(overrides: Partial<TargetPetSource> = {}): TargetPetSource {
return {
id: 'pet-1',
ownerId: OWNER,
name: '콩이',
photoFileId: 'pet-photo',
tint: '#FF9B6A',
archivedAt: null,
...overrides,
};
}
describe('대상 표시', () => {
const owner = { nickname: '콩이맘', photoFileId: 'owner-photo' };
it('targetIds 순서대로 아이와 주인 자신(self)을 보여 준다', () => {
const pets = new Map([
['pet-1', pet()],
['pet-2', pet({ id: 'pet-2', name: '두부', archivedAt: new Date() })],
]);
expect(
resolveTargets(['pet-2', 'self', 'pet-1'], OWNER, pets, owner),
).toEqual([
{
id: 'pet-2',
name: '두부',
photoFileId: 'pet-photo',
tint: '#FF9B6A',
archived: true,
},
{
id: 'self',
name: '콩이맘',
photoFileId: 'owner-photo',
tint: null,
archived: false,
},
{
id: 'pet-1',
name: '콩이',
photoFileId: 'pet-photo',
tint: '#FF9B6A',
archived: false,
},
]);
});
it('더 이상 없는 아이와 주인의 아이가 아닌 아이는 뺀다', () => {
const pets = new Map([['pet-x', pet({ id: 'pet-x', ownerId: 'someone' })]]);
expect(resolveTargets(['gone', 'pet-x'], OWNER, pets, owner)).toEqual([]);
});
it('주인 정보를 모르면 self 도 뺀다', () => {
expect(resolveTargets(['self'], OWNER, new Map(), null)).toEqual([]);
});
});
+69
View File
@@ -0,0 +1,69 @@
// 일정·다이어리의 대상 표시 — 부수효과 없는 순수 함수.
// 함께 쓰는 사람에게는 대상 id 만으로는 누구인지 보이지 않으므로 이름·사진을 붙여 보낸다.
import { SELF_TARGET_ID } from '@/common/validators';
/** 회원의 보이는 정보 — 닉네임·프로필 사진 */
export interface ProfileSource {
readonly nickname: string | null;
readonly photoFileId: string | null;
}
/** 대상이 될 수 있는 아이 (PetService.findTargetPets) */
export interface TargetPetSource {
readonly id: string;
readonly ownerId: string;
readonly name: string;
readonly photoFileId: string | null;
readonly tint: string;
readonly archivedAt: Date | null;
}
/** 이름·사진을 붙인 대상 하나 */
export interface TargetView {
/** 아이 id 또는 "self" */
readonly id: string;
/** 아이 이름 · self 면 주인 닉네임 */
readonly name: string | null;
readonly photoFileId: string | null;
/** 아이의 고유색 · self 면 null */
readonly tint: string | null;
/** 추억 보관함의 아이인지 */
readonly archived: boolean;
}
/**
* 대상 — targetIds 순서대로. 아이는 기록 주인([ownerId])의 아이만, "self" 는 주인 자신이다.
* 더 이상 찾을 수 없는 대상은 뺀다.
*/
export function resolveTargets(
targetIds: readonly string[],
ownerId: string,
pets: ReadonlyMap<string, TargetPetSource>,
owner: ProfileSource | null,
): TargetView[] {
const views: TargetView[] = [];
for (const id of targetIds) {
if (id === SELF_TARGET_ID) {
if (owner === null) continue;
views.push({
id,
name: owner.nickname,
photoFileId: owner.photoFileId,
tint: null,
archived: false,
});
continue;
}
const pet = pets.get(id);
if (!pet || pet.ownerId !== ownerId) continue;
views.push({
id,
name: pet.name,
photoFileId: pet.photoFileId,
tint: pet.tint,
archived: pet.archivedAt !== null,
});
}
return views;
}
+152
View File
@@ -0,0 +1,152 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import {
ArchivePetDto,
CreatePetDto,
ReorderPetsDto,
UpdatePetDto,
} from './dto/pet-input.dto';
import { PetResponseDto } from './dto/pet-response.dto';
import { PetService } from './pet.service';
// 반려동물 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Pet')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('pets')
export class PetController {
constructor(private readonly petService: PetService) {}
@Get()
@ApiOperation({
summary: '우리 아이 목록 (커서 페이지네이션, 정한 순서대로)',
description:
'추억 보관함의 아이도 함께 온다 — archive 가 null 이 아니면 보관된 아이',
})
@ApiPaginatedResponse(PetResponseDto, '아이 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<PetResponseDto>> {
return this.petService.list(user.userId, query);
}
@Post()
@ApiOperation({ summary: '아이 등록' })
@ApiStandardResponse(PetResponseDto, {
status: 201,
description: '등록한 아이',
})
@ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 이미지 파일이 아님')
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreatePetDto,
): Promise<PetResponseDto> {
return this.petService.create(user.userId, dto);
}
@Put('order')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '우리 아이 순서 바꾸기',
description: 'ids 순서로 앞에 두고, 빠진 아이는 원래 순서대로 뒤에 둔다',
})
@ApiNoContentResponse({ description: '순서 저장 완료' })
@ApiErrorResponse('VAL_001', '내 아이가 아닌 id 가 섞여 있음')
async reorder(
@CurrentUser() user: AuthUser,
@Body() dto: ReorderPetsDto,
): Promise<void> {
await this.petService.reorder(user.userId, dto);
}
@Patch(':id')
@ApiOperation({ summary: '아이 정보 수정 (보낸 값만)' })
@ApiStandardResponse(PetResponseDto, { description: '수정한 아이' })
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePetDto,
): Promise<PetResponseDto> {
return this.petService.update(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '아이 삭제',
description:
'명함은 함께 지워지고, 일정·다이어리에서는 그 아이만 대상에서 뗀다',
})
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.petService.remove(user.userId, id);
}
@Put(':id/archive')
@ApiOperation({
summary: '추억 보관함으로 옮기기',
description: '정보·명함·다이어리는 그대로 두고, 일정에서는 그 아이를 뗀다',
})
@ApiStandardResponse(PetResponseDto, { description: '보관한 아이' })
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '날짜 형식 오류 · 첫날이 마지막 날보다 늦음')
archive(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ArchivePetDto,
): Promise<PetResponseDto> {
return this.petService.archive(user.userId, id, dto);
}
@Delete(':id/archive')
@ApiOperation({ summary: '추억 보관함에서 다시 불러오기' })
@ApiStandardResponse(PetResponseDto, { description: '다시 불러온 아이' })
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
restore(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<PetResponseDto> {
return this.petService.restore(user.userId, id);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UploadModule } from '@/modules/upload/upload.module';
import { Pet } from './entities/pet.entity';
import { PetController } from './pet.controller';
import { PetService } from './pet.service';
@Module({
imports: [TypeOrmModule.forFeature([Pet]), UploadModule],
controllers: [PetController],
providers: [PetService],
exports: [PetService],
})
export class PetModule {}
+136
View File
@@ -0,0 +1,136 @@
import { DataSource, EntityManager, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { UploadService } from '@/modules/upload/upload.service';
import { Pet } from './entities/pet.entity';
import { PetLifecycleListener } from './pet-lifecycle';
import { PetService } from './pet.service';
const OWNER = 'owner-1';
const PET_ID = 'pet-1';
function pet(overrides: Partial<Pet> = {}): Pet {
return {
id: PET_ID,
ownerId: OWNER,
photoFileId: 'photo-1',
archiveFrom: null,
archiveTo: null,
archivedAt: null,
archiveMemo: null,
...overrides,
} as Pet;
}
describe('PetService', () => {
const manager = {
delete: jest.fn(),
save: jest.fn((v: unknown) => Promise.resolve(v)),
merge: jest.fn((_e: unknown, a: object, b: object) => ({ ...a, ...b })),
};
const dataSource = {
transaction: jest.fn((work: (m: EntityManager) => Promise<unknown>) =>
work(manager as unknown as EntityManager),
),
};
const repo = { findOneBy: jest.fn(), count: jest.fn() };
const uploads = { releaseIfUnused: jest.fn(), assertOwnedImage: jest.fn() };
let service: PetService;
beforeEach(() => {
jest.clearAllMocks();
service = new PetService(
repo as unknown as Repository<Pet>,
dataSource as unknown as DataSource,
uploads as unknown as UploadService,
);
});
it('아이를 지우면 리스너가 같은 트랜잭션에서 정리하고, 커밋 뒤 쓰지 않는 파일을 지운다', async () => {
repo.findOneBy.mockResolvedValue(pet());
const order: string[] = [];
const listener: PetLifecycleListener = {
onPetDeleting: jest.fn((event) => {
order.push('listener');
expect(event).toEqual({ ownerId: OWNER, petId: PET_ID, manager });
return Promise.resolve(['card-bg-1']);
}),
};
manager.delete.mockImplementation(() => {
order.push('delete');
return Promise.resolve({ affected: 1 });
});
uploads.releaseIfUnused.mockImplementation(() => {
order.push('release');
return Promise.resolve();
});
service.registerListener(listener);
await service.remove(OWNER, PET_ID);
expect(order).toEqual(['listener', 'delete', 'release']);
expect(uploads.releaseIfUnused).toHaveBeenCalledWith([
'photo-1',
'card-bg-1',
]);
});
it('남의 아이는 지울 수 없다 (RES_001)', async () => {
repo.findOneBy.mockResolvedValue(null);
await expect(service.remove(OWNER, PET_ID)).rejects.toMatchObject({
code: 'RES_001',
});
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('보관하면 리스너에 알린다', async () => {
repo.findOneBy.mockResolvedValue(pet());
const onPetArchived = jest.fn(() => Promise.resolve());
service.registerListener({ onPetArchived });
const result = await service.archive(OWNER, PET_ID, {
from: '2021-09-14',
to: '2026-10-01',
memo: null,
});
expect(onPetArchived).toHaveBeenCalledWith(
expect.objectContaining({ ownerId: OWNER, petId: PET_ID }),
);
expect(result.archive).toMatchObject({
from: '2021-09-14',
to: '2026-10-01',
});
});
it('보관 기간의 첫날이 마지막 날보다 늦으면 VAL_001', async () => {
await expect(
service.archive(OWNER, PET_ID, { from: '2026-10-02', to: '2026-10-01' }),
).rejects.toBeInstanceOf(BusinessException);
expect(repo.findOneBy).not.toHaveBeenCalled();
});
describe('대상 확인', () => {
it('나(self)만 있으면 DB 를 보지 않는다', async () => {
await service.assertOwnedTargets(OWNER, ['self']);
expect(repo.count).not.toHaveBeenCalled();
});
it('내 아이가 아닌 id 가 섞이면 VAL_001', async () => {
repo.count.mockResolvedValue(1);
await expect(
service.assertOwnedTargets(OWNER, ['self', 'pet-a', 'pet-b']),
).rejects.toMatchObject({ code: 'VAL_001' });
});
it('같은 아이가 두 번 있어도 한 번으로 센다', async () => {
repo.count.mockResolvedValue(1);
await expect(
service.assertOwnedTargets(OWNER, ['pet-a', 'pet-a']),
).resolves.toBeUndefined();
});
});
});
+269
View File
@@ -0,0 +1,269 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { SELF_TARGET_ID } from '@/common/validators';
import { UploadService } from '@/modules/upload/upload.service';
import {
ArchivePetDto,
CreatePetDto,
ReorderPetsDto,
UpdatePetDto,
} from './dto/pet-input.dto';
import { PetResponseDto } from './dto/pet-response.dto';
import { Pet } from './entities/pet.entity';
import { PetLifecycleListener } from './pet-lifecycle';
import type { TargetPetSource } from './pet-targets';
import { reorderIds } from './pet-order';
// 반려동물 — 등록·수정·삭제·순서·추억 보관함
@Injectable()
export class PetService {
private readonly logger = new Logger(PetService.name);
private readonly listeners: PetLifecycleListener[] = [];
constructor(
@InjectRepository(Pet)
private readonly pets: Repository<Pet>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly uploads: UploadService,
) {}
/** 아이의 삭제·보관을 따라 정리할 도메인을 등록한다 (pet-lifecycle.ts) */
registerListener(listener: PetLifecycleListener): void {
this.listeners.push(listener);
}
/** 우리 아이 목록 — 정한 순서대로. 추억 보관함의 아이도 함께 온다(archive 값으로 구분) */
async list(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<PetResponseDto>> {
const qb = this.pets
.createQueryBuilder('pet')
.where('pet.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
[
{ column: 'pet.sort_order', cast: 'integer', read: (p) => p.sortOrder },
{ column: 'pet.id', cast: 'uuid', read: (p) => p.id },
],
query,
);
return mapPage(page, (p) => PetResponseDto.from(p));
}
async create(ownerId: string, dto: CreatePetDto): Promise<PetResponseDto> {
await this.uploads.assertOwnedImage(dto.photoFileId, ownerId);
const last = await this.pets.findOne({
where: { ownerId },
order: { sortOrder: 'DESC' },
select: { sortOrder: true },
});
const pet = await this.pets.save(
this.pets.create({
...dto,
ownerId,
sortOrder: (last?.sortOrder ?? -1) + 1,
photoFileId: dto.photoFileId ?? null,
microchip: dto.microchip ?? null,
bloodType: dto.bloodType ?? null,
vet: dto.vet ?? null,
memo: dto.memo ?? null,
archiveFrom: null,
archiveTo: null,
archivedAt: null,
archiveMemo: null,
}),
);
return PetResponseDto.from(pet);
}
async update(
ownerId: string,
petId: string,
dto: UpdatePetDto,
): Promise<PetResponseDto> {
const pet = await this.findOwned(ownerId, petId);
const previousPhoto = pet.photoFileId;
if (dto.photoFileId !== undefined) {
await this.uploads.assertOwnedImage(dto.photoFileId, ownerId);
}
// 보낸 값만 바꾼다 (undefined 는 그대로)
const changes = Object.fromEntries(
Object.entries(dto).filter(([, value]) => value !== undefined),
) as Partial<Pet>;
const saved = await this.pets.save(this.pets.merge(pet, changes));
if (previousPhoto !== saved.photoFileId) {
await this.uploads.releaseIfUnused([previousPhoto]);
}
return PetResponseDto.from(saved);
}
/**
* 아이를 지운다 — 명함은 FK 로 함께 지워지고, 일정·다이어리는 등록된 리스너가 그 아이를 대상에서 뗀다.
* 정리로 쓰지 않게 된 사진은 커밋한 뒤 지운다.
*/
async remove(ownerId: string, petId: string): Promise<void> {
const pet = await this.findOwned(ownerId, petId);
const released = await this.dataSource.transaction(async (manager) => {
const files: Array<string | null> = [pet.photoFileId];
for (const listener of this.listeners) {
const more = await listener.onPetDeleting?.({
ownerId,
petId,
manager,
});
if (more) files.push(...more);
}
await manager.delete(Pet, { id: petId, ownerId });
return files;
});
await this.uploads.releaseIfUnused(released);
this.logger.log(`아이 삭제: ${petId} (회원 ${ownerId})`);
}
/** 추억 보관함으로 옮긴다 — 정보·명함·다이어리는 그대로, 일정은 리스너가 정리한다 */
async archive(
ownerId: string,
petId: string,
dto: ArchivePetDto,
): Promise<PetResponseDto> {
if (dto.from > dto.to) {
throw new BusinessException('VAL_001', {
context: { reason: 'archive-range', from: dto.from, to: dto.to },
});
}
const pet = await this.findOwned(ownerId, petId);
const saved = await this.dataSource.transaction(async (manager) => {
const archived = await manager.save(
manager.merge(Pet, pet, {
archiveFrom: dto.from,
archiveTo: dto.to,
archivedAt: new Date(),
archiveMemo: dto.memo ?? null,
}),
);
for (const listener of this.listeners) {
await listener.onPetArchived?.({ ownerId, petId, manager });
}
return archived;
});
return PetResponseDto.from(saved);
}
/** 추억 보관함에서 다시 불러온다 — 보관할 때 지운 일정은 돌아오지 않는다 */
async restore(ownerId: string, petId: string): Promise<PetResponseDto> {
const pet = await this.findOwned(ownerId, petId);
const saved = await this.pets.save(
this.pets.merge(pet, {
archiveFrom: null,
archiveTo: null,
archivedAt: null,
archiveMemo: null,
}),
);
return PetResponseDto.from(saved);
}
/** 순서 바꾸기 — 요청에 없는 아이는 원래 순서대로 뒤에 둔다 */
async reorder(ownerId: string, dto: ReorderPetsDto): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const pets = await manager.find(Pet, {
where: { ownerId },
order: { sortOrder: 'ASC', id: 'ASC' },
select: { id: true, sortOrder: true },
// 동시에 순서를 바꾸면 한쪽 결과만 남게 잠근다
lock: { mode: 'pessimistic_write' },
});
const known = new Set(pets.map((p) => p.id));
const unknown = dto.ids.filter((id) => !known.has(id));
if (unknown.length > 0) {
throw new BusinessException('VAL_001', {
context: { reason: 'unknown-pet', petIds: unknown },
});
}
const order = reorderIds(
pets.map((p) => p.id),
dto.ids,
);
const current = new Map(pets.map((p) => [p.id, p.sortOrder]));
for (const [index, id] of order.entries()) {
if (current.get(id) !== index) {
await manager.update(Pet, { id }, { sortOrder: index });
}
}
});
}
/**
* 일정·다이어리 대상 확인 — 모두 "나(self)" 이거나 내 아이여야 한다. 하나라도 아니면 VAL_001.
* 추억 보관함의 아이도 내 아이로 본다 (보관된 아이의 다이어리를 고칠 수 있다).
*/
async assertOwnedTargets(
ownerId: string,
targetIds: readonly string[],
): Promise<void> {
const petIds = [...new Set(targetIds)].filter(
(id) => id !== SELF_TARGET_ID,
);
if (petIds.length === 0) return;
const found = await this.pets.count({
where: { ownerId, id: In(petIds) },
});
if (found !== petIds.length) {
throw new BusinessException('VAL_001', {
context: { reason: 'unknown-target', targetIds },
});
}
}
/**
* 대상 표시에 쓸 아이 (id → 아이) — "self" 는 건너뛴다. 주인은 따지지 않으므로
* 기록 주인의 아이인지는 resolveTargets 가 확인한다 (pet-targets.ts).
*/
async findTargetPets(
targetIds: readonly string[],
): Promise<Map<string, TargetPetSource>> {
const petIds = [...new Set(targetIds)].filter(
(id) => id !== SELF_TARGET_ID,
);
if (petIds.length === 0) return new Map();
const pets = await this.pets.find({
where: { id: In(petIds) },
select: {
id: true,
ownerId: true,
name: true,
photoFileId: true,
tint: true,
archivedAt: true,
},
});
return new Map(pets.map((p) => [p.id, p]));
}
/** 내 아이 하나 — 남의 아이나 없는 아이는 똑같이 RES_001 */
async findOwned(ownerId: string, petId: string): Promise<Pet> {
const pet = await this.pets.findOneBy({ id: petId, ownerId });
if (!pet) throw new BusinessException('RES_001', { context: { petId } });
return pet;
}
}
@@ -0,0 +1,51 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import type { FileAccessPolicy } from '@/modules/upload/file-access-policy';
import { UploadService } from '@/modules/upload/upload.service';
/**
* 일정을 연동한 사람의 사진 — 같은 묶음에 든 사람의 지금 프로필 사진과, 그 사람 아이들의 사진.
* (연동된 일정의 주인·대상 표시에 쓴다)
*/
const GROUP_MEMBER_CAN_READ_FILE = `
SELECT EXISTS (
SELECT 1
FROM calendar_group_members me
JOIN calendar_group_members other ON other.group_id = me.group_id
JOIN files f ON f.id = $2 AND f.owner_id = other.user_id
WHERE me.user_id = $1
AND (
EXISTS (
SELECT 1 FROM users u
WHERE u.id = other.user_id AND u.photo_file_id = f.id
)
OR EXISTS (
SELECT 1 FROM pets p
WHERE p.owner_id = other.user_id AND p.photo_file_id = f.id
)
)
) AS allowed`;
// 일정 연동의 사진 내려받기 규칙 — UploadService 에 등록한다 (file-access-policy.ts)
@Injectable()
export class CalendarFileAccess implements FileAccessPolicy, OnModuleInit {
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly uploads: UploadService,
) {}
onModuleInit(): void {
this.uploads.registerAccessPolicy(this);
}
async canRead(userId: string, fileId: string): Promise<boolean> {
const rows = await this.dataSource.query<{ allowed: boolean }[]>(
GROUP_MEMBER_CAN_READ_FILE,
[userId, fileId],
);
return rows[0]?.allowed === true;
}
}
@@ -0,0 +1,122 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { InviteCodeLookup } from '@/common/decorators/invite-code-lookup.decorator';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { CalendarSharingService } from './calendar-sharing.service';
import {
CalendarInvitationDto,
CalendarSharingDto,
JoinCalendarDto,
} from './dto/calendar-sharing.dto';
// 일정 연동 — 내 연동 상태와 연동 해제. 라우팅과 입출력만 담당한다
@ApiTags('Schedule')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller()
export class CalendarSharingController {
constructor(private readonly sharing: CalendarSharingService) {}
@Get('calendar-sharing')
@ApiOperation({
summary: '내 일정 연동 상태 (초대 코드·함께 보는 사람)',
description: '초대 코드가 아직 없으면 이때 만든다',
})
@ApiStandardResponse(CalendarSharingDto, { description: '연동 상태' })
get(@CurrentUser() user: AuthUser): Promise<CalendarSharingDto> {
return this.sharing.getSharing(user.userId);
}
@Delete('calendar-membership')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '연동 해제 (나만 빠진다)',
description:
'내 일정은 그대로 남고, 다른 사람의 일정은 더 이상 보이지 않는다. 연동 중이 아니어도 204',
})
@ApiNoContentResponse({ description: '해제 완료' })
async leave(@CurrentUser() user: AuthUser): Promise<void> {
await this.sharing.leave(user.userId);
}
}
// 일정 연동 초대 — 코드를 받은 회원이 연동하기 전에 미리 본다
@ApiTags('Schedule')
@ApiBearerAuth()
@InviteCodeLookup()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('calendar-invitations')
export class CalendarInvitationsController {
constructor(private readonly sharing: CalendarSharingService) {}
@Get(':code')
@ApiOperation({
summary: '초대 코드로 상대 미리보기 (연동 전)',
description: '코드는 대소문자를 가리지 않는다',
})
@ApiParam({ name: 'code', example: 'RUP-7KQ2MX' })
@ApiStandardResponse(CalendarInvitationDto, { description: '코드 주인' })
@ApiErrorResponse('SCHED_001', '코드에 맞는 회원이 없음')
preview(
@CurrentUser() user: AuthUser,
@Param('code') code: string,
): Promise<CalendarInvitationDto> {
return this.sharing.previewInvitation(user.userId, code);
}
}
// 일정 연동하기
@ApiTags('Schedule')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('calendar-memberships')
export class CalendarMembershipsController {
constructor(private readonly sharing: CalendarSharingService) {}
@Post()
@InviteCodeLookup()
@ApiOperation({
summary: '초대 코드로 일정 연동',
description:
'확인 순서: SCHED_001(없는 코드) → SCHED_003(내 코드) → SCHED_005(이미 연동된 사람) → SCHED_004(다른 사람과 연동 중) → SCHED_002(상대 쪽이 가득 참). 상대가 아직 아무와도 연동하지 않았으면 둘이 새로 묶인다',
})
@ApiStandardResponse(CalendarSharingDto, {
status: 201,
description: '연동한 뒤의 내 연동 상태',
})
@ApiErrorResponse('SCHED_001', '코드에 맞는 회원이 없음')
@ApiErrorResponse('SCHED_003', '내 초대 코드')
@ApiErrorResponse('SCHED_005', '이미 연동된 사람')
@ApiErrorResponse('SCHED_004', '이미 다른 사람과 연동 중')
@ApiErrorResponse('SCHED_002', '이미 4명이 연동됨')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
join(
@CurrentUser() user: AuthUser,
@Body() dto: JoinCalendarDto,
): Promise<CalendarSharingDto> {
return this.sharing.join(user.userId, dto);
}
}
@@ -0,0 +1,228 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { CALENDAR_INVITE_CODE } from '@/modules/user/calendar-invite-code';
import { User } from '@/modules/user/entities/user.entity';
import { retryOnUniqueViolation } from '@/shared/utils/db-error';
import { LOCK_SCOPE, lockFor } from '@/shared/utils/db-lock';
import {
CalendarInvitationDto,
CalendarSharingDto,
JoinCalendarDto,
} from './dto/calendar-sharing.dto';
import {
CALENDAR_GROUP_MAX_MEMBERS,
CalendarGroup,
CalendarGroupMember,
} from './entities/calendar-group.entity';
/** 초대 코드가 겹쳤을 때 다시 뽑는 횟수 — 32^6 가지라 실제로 겹치는 일은 드물다 */
const CODE_RETRY = 5;
/** 연동 변경은 전체를 한 줄로 세운다 — 참여·해제는 드물어 잠금 하나로 충분하다 */
const SHARING_LOCK_KEY = 'all';
/**
* 일정 연동 — 최대 4명이 한 달력을 함께 본다. 사람마다 초대 코드가 하나 있고(가입할 때 만든다),
* 남의 코드를 입력하면 그 사람의 묶음에 들어간다(없으면 둘이 새 묶음). 해제하면 나만 빠진다.
*
* 초대 코드는 한 번 쓰면 바뀐다 — 연동하면 코드 주인의 코드를, 해제하면 나간 사람의 코드를 새로 뽑는다.
* 예전에 받은 코드로 언제든 다시 들어오지 못하게 한다 (코드만 알면 승인 없이 달력 전체를 보고 고친다).
*
* 회원 탈퇴는 FK(CASCADE)로 묶음에서 빠지므로 한 명만 남은 묶음이 생길 수 있다 —
* 읽을 때는 연동되지 않은 것으로 보고, 지우는 것은 참여·해제할 때 한다 (GET 은 아무것도 쓰지 않는다).
*/
@Injectable()
export class CalendarSharingService {
private readonly logger = new Logger(CalendarSharingService.name);
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/** 내 일정을 함께 보는 사람 (나 포함) — 연동되지 않았으면 나만. 일정을 읽고 고치는 범위다 */
async memberIds(userId: string): Promise<string[]> {
const group = await this.groupOf(this.dataSource.manager, userId);
return group.length >= 2 ? group.map((m) => m.userId) : [userId];
}
/** 내 연동 상태 — 읽기만 한다 */
async getSharing(userId: string): Promise<CalendarSharingDto> {
const me = await this.users.findOneBy({ id: userId });
if (!me) throw new BusinessException('RES_001', { context: { userId } });
const group = await this.groupOf(this.dataSource.manager, userId, true);
return CalendarSharingDto.from(userId, me, me.calendarInviteCode, group);
}
/** 초대 코드로 미리 보기 — 연동하기 전. 없는 코드는 SCHED_001 */
async previewInvitation(
userId: string,
rawCode: string,
): Promise<CalendarInvitationDto> {
const owner = await this.findCodeOwner(this.dataSource.manager, rawCode);
const isMine = owner.id === userId;
const theirs = await this.groupOf(this.dataSource.manager, owner.id);
const linked = theirs.length >= 2;
return {
ownerNickname: owner.nickname,
memberCount: linked ? theirs.length : 1,
maxMembers: CALENDAR_GROUP_MAX_MEMBERS,
isMine,
isMember: !isMine && linked && theirs.some((m) => m.userId === userId),
};
}
/**
* 초대 코드로 연동 — 이 순서로 확인한다:
* 없는 코드(SCHED_001) → 내 코드(SCHED_003) → 이미 같은 묶음(SCHED_005) →
* 내가 다른 사람과 연동 중(SCHED_004) → 상대 묶음이 가득 참(SCHED_002).
* 상대가 아직 아무와도 연동하지 않았으면 둘이 새 묶음을 만든다. 연동하면 코드 주인의 코드를 바꾼다.
*/
async join(
userId: string,
dto: JoinCalendarDto,
): Promise<CalendarSharingDto> {
const ownerId = await retryOnUniqueViolation(
() =>
this.dataSource.transaction(async (manager) => {
await lockFor(manager, LOCK_SCOPE.calendarSharing, SHARING_LOCK_KEY);
// 코드는 잠근 뒤에 찾는다 — 먼저 들어온 사람이 이 코드를 쓰고 바꿨다면 SCHED_001 이 된다
const owner = await this.findCodeOwner(manager, dto.code);
if (owner.id === userId) {
throw new BusinessException('SCHED_003', { context: { userId } });
}
const mine = await this.linkedGroupOf(manager, userId);
const theirs = await this.linkedGroupOf(manager, owner.id);
const myGroupId = mine.length > 0 ? mine[0].groupId : null;
const groupId = theirs.length > 0 ? theirs[0].groupId : null;
if (groupId !== null && myGroupId === groupId) {
throw new BusinessException('SCHED_005', { context: { groupId } });
}
if (myGroupId !== null) {
throw new BusinessException('SCHED_004', {
context: { groupId: myGroupId },
});
}
if (theirs.length >= CALENDAR_GROUP_MAX_MEMBERS) {
throw new BusinessException('SCHED_002', {
context: { groupId, members: theirs.length },
});
}
if (groupId !== null) {
await manager.insert(CalendarGroupMember, { groupId, userId });
} else {
const group = await manager.save(manager.create(CalendarGroup, {}));
await manager.insert(CalendarGroupMember, {
groupId: group.id,
userId: owner.id,
});
// 코드 주인이 먼저 든 것으로 둔다 — 기본값 now() 는 트랜잭션 시작 시각이라 둘이 같아진다
await manager.insert(CalendarGroupMember, {
groupId: group.id,
userId,
joinedAt: () => 'clock_timestamp()',
});
}
// 쓴 코드는 바꾼다 — 겹치면(유니크 위반) 처음부터 다시 한다
await this.rotateCode(manager, owner.id);
return owner.id;
}),
CODE_RETRY,
);
this.logger.log(`일정 연동: 회원 ${userId} → 회원 ${ownerId} 의 묶음`);
return this.getSharing(userId);
}
/**
* 연동 해제 — 나만 빠진다(내 일정은 그대로). 한 명만 남으면 묶음을 지운다.
* 빠졌으면 내 코드를 바꾼다 (예전 묶음 사람들이 갖고 있던 내 코드로 다시 묶이지 않게).
* 연동 중이 아니면 아무 일도 없다.
*/
async leave(userId: string): Promise<void> {
const left = await retryOnUniqueViolation(
() =>
this.dataSource.transaction(async (manager) => {
await lockFor(manager, LOCK_SCOPE.calendarSharing, SHARING_LOCK_KEY);
const mine = await manager.findOneBy(CalendarGroupMember, { userId });
if (!mine) return false;
await manager.delete(CalendarGroupMember, { userId });
const remaining = await manager.countBy(CalendarGroupMember, {
groupId: mine.groupId,
});
if (remaining < 2) {
await manager.delete(CalendarGroup, { id: mine.groupId });
}
await this.rotateCode(manager, userId);
return true;
}),
CODE_RETRY,
);
if (left) this.logger.log(`일정 연동 해제: 회원 ${userId}`);
}
// ── 도우미 ─────────────────────────────────────────────
/** [userId] 가 든 묶음의 사람들 (연동한 순서) — 묶음이 없으면 [] */
private async groupOf(
manager: EntityManager,
userId: string,
withProfiles = false,
): Promise<CalendarGroupMember[]> {
const mine = await manager.findOneBy(CalendarGroupMember, { userId });
if (!mine) return [];
return manager.find(CalendarGroupMember, {
where: { groupId: mine.groupId },
relations: { user: withProfiles },
order: { joinedAt: 'ASC', userId: 'ASC' },
});
}
/** 연동된 묶음 (2명 이상) — 혼자 남은 묶음은 지우고 [] 를 돌려준다. 잠금 안에서만 부른다 */
private async linkedGroupOf(
manager: EntityManager,
userId: string,
): Promise<CalendarGroupMember[]> {
const group = await this.groupOf(manager, userId);
if (group.length === 1) {
await manager.delete(CalendarGroup, { id: group[0].groupId });
return [];
}
return group;
}
/** 새 초대 코드로 바꾼다 — 겹치면 유니크 위반이 나고, 호출한 쪽이 트랜잭션을 다시 한다 */
private async rotateCode(
manager: EntityManager,
userId: string,
): Promise<void> {
await manager.update(
User,
{ id: userId },
{ calendarInviteCode: CALENDAR_INVITE_CODE.random() },
);
}
/** 초대 코드의 주인 — 형식이 틀리거나 없는 코드는 SCHED_001 */
private async findCodeOwner(
manager: EntityManager,
rawCode: string,
): Promise<User> {
const code = CALENDAR_INVITE_CODE.normalize(rawCode);
const owner =
code === null
? null
: await manager.findOneBy(User, { calendarInviteCode: code });
if (!owner) {
throw new BusinessException('SCHED_001', { context: { code: rawCode } });
}
return owner;
}
}
@@ -0,0 +1,141 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import type { ProfileSource } from '@/modules/pet/pet-targets';
import { CALENDAR_GROUP_MAX_MEMBERS } from '../entities/calendar-group.entity';
/** 초대 코드 입력 길이 상한 — 형식 검사는 서비스가 한다 (틀린 코드는 SCHED_001) */
const INVITE_CODE_INPUT_MAX_LENGTH = 20;
/** 초대 코드로 연동하기 */
export class JoinCalendarDto {
@ApiProperty({
description: '상대의 초대 코드 (RUP-XXXXXX, 대소문자 구분 없음)',
example: 'RUP-7KQ2MX',
})
@IsString({ message: '초대 코드를 입력해 주세요.' })
@IsNotEmpty({ message: '초대 코드를 입력해 주세요.' })
@MaxLength(INVITE_CODE_INPUT_MAX_LENGTH, {
message: '초대 코드가 올바르지 않습니다.',
})
code!: string;
}
/** 일정을 함께 보는 사람 */
export class CalendarMemberDto {
@ApiProperty({ description: '회원 ID' }) userId!: string;
@ApiProperty({
description: '닉네임 — 모르면 null',
nullable: true,
type: String,
})
nickname!: string | null;
@ApiProperty({
description: '프로필 사진 파일 ID — GET /api/files/{id}',
nullable: true,
type: String,
})
photoFileId!: string | null;
@ApiProperty({ description: '나인지' }) isMe!: boolean;
}
/** 묶음에 든 사람 한 줄 (calendar_group_members + users) */
export interface CalendarMemberSource {
readonly userId: string;
readonly joinedAt: Date;
readonly user?: ProfileSource | null;
}
/** 내 일정 연동 상태 */
export class CalendarSharingDto {
@ApiProperty({
description: '내 초대 코드 — 상대가 이 코드를 입력하면 연동된다',
example: 'RUP-7KQ2MX',
})
inviteCode!: string;
@ApiProperty({
description: '다른 사람과 연동되어 있는지 (나 포함 2명 이상)',
})
linked!: boolean;
@ApiProperty({
description: '함께 볼 수 있는 최대 인원 (나 포함)',
example: CALENDAR_GROUP_MAX_MEMBERS,
})
maxMembers!: number;
@ApiProperty({
description:
'함께 보는 사람 — 내가 맨 앞, 나머지는 연동한 순서. 연동되지 않았으면 나만',
type: [CalendarMemberDto],
})
members!: CalendarMemberDto[];
/**
* [group] 은 내 묶음의 사람들 (회원 정보 포함). 두 명 미만이면 연동되지 않은 것으로 본다.
*/
static from(
meId: string,
me: ProfileSource,
inviteCode: string,
group: readonly CalendarMemberSource[],
): CalendarSharingDto {
const linked = group.length >= 2;
const others = linked
? group
.filter((m) => m.userId !== meId)
.sort(
(a, b) =>
a.joinedAt.getTime() - b.joinedAt.getTime() ||
a.userId.localeCompare(b.userId),
)
: [];
return {
inviteCode,
linked,
maxMembers: CALENDAR_GROUP_MAX_MEMBERS,
members: [
{
userId: meId,
nickname: me.nickname,
photoFileId: me.photoFileId,
isMe: true,
},
...others.map((m) => ({
userId: m.userId,
nickname: m.user?.nickname ?? null,
photoFileId: m.user?.photoFileId ?? null,
isMe: false,
})),
],
};
}
}
/** 초대 코드로 본 상대 — 연동하기 전에 보여 준다 */
export class CalendarInvitationDto {
@ApiProperty({
description: '코드 주인의 닉네임 — 모르면 null',
nullable: true,
type: String,
})
ownerNickname!: string | null;
@ApiProperty({
description:
'코드 주인과 지금 함께 보는 사람 수 (주인 포함, 연동 전이면 1)',
})
memberCount!: number;
@ApiProperty({ description: '함께 볼 수 있는 최대 인원 (나 포함)' })
maxMembers!: number;
@ApiProperty({ description: '내 코드인지' }) isMine!: boolean;
@ApiProperty({ description: '코드 주인과 이미 연동되어 있는지' })
isMember!: boolean;
}
@@ -0,0 +1,361 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import {
IsHexColor,
IsHourMinute,
IsIsoDate,
IsTargetIdList,
} from '@/common/validators';
import {
EVENT_CATEGORIES,
EVENT_KINDS,
SCHEDULE_LIMITS,
} from '../entities/schedule-details';
import type {
EventCategory,
EventKind,
ScheduleDetails,
} from '../entities/schedule-details';
import { REPEAT_MODES, REPEAT_UNITS } from '../entities/repeat-rule.entity';
import type {
RepeatMode,
RepeatRule,
RepeatRuleCompletion,
RepeatUnit,
} from '../entities/repeat-rule.entity';
import type { ScheduleEvent } from '../entities/schedule-event.entity';
import { toOwnership } from '../schedule-view';
import type { ScheduleViewContext } from '../schedule-view';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** 앞뒤 공백을 떼고, 비면 null */
const trimToNull = ({ value }: { value: unknown }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
};
/** 일정 내용 — 날짜 일정과 반복 규칙이 같이 쓴다 (앱의 EventDetails) */
export class EventDetailsDto {
@ApiProperty({
description: `일정 이름 (최대 ${SCHEDULE_LIMITS.title}자)`,
example: '심장사상충 약',
})
@Transform(trim)
@IsString({ message: '일정 이름을 입력해 주세요.' })
@IsNotEmpty({ message: '일정 이름을 입력해 주세요.' })
@MaxLength(SCHEDULE_LIMITS.title, {
message: `일정 이름은 ${SCHEDULE_LIMITS.title}자 이하로 입력해 주세요.`,
})
title!: string;
@ApiProperty({ enum: EVENT_KINDS, example: 'general' })
@IsIn(EVENT_KINDS, { message: '일정 종류가 올바르지 않습니다.' })
kind!: EventKind;
@ApiProperty({ enum: EVENT_CATEGORIES, example: 'med' })
@IsIn(EVENT_CATEGORIES, { message: '일정 분류가 올바르지 않습니다.' })
category!: EventCategory;
@ApiProperty({
description: '대상 — 아이 id 또는 "self"(나). 1개 이상',
type: [String],
example: ['self'],
})
@IsTargetIdList(SCHEDULE_LIMITS.targets)
targetIds!: string[];
@ApiPropertyOptional({
description: '시작 시각 HH:mm — 없으면(null) 하루 종일',
nullable: true,
type: String,
example: '08:00',
})
@IsOptional()
@IsHourMinute()
start?: string | null;
@ApiPropertyOptional({
description: '종료 시각 HH:mm (선택)',
nullable: true,
type: String,
})
@IsOptional()
@IsHourMinute()
end?: string | null;
@ApiPropertyOptional({ description: '장소', nullable: true, type: String })
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(SCHEDULE_LIMITS.place, {
message: `장소는 ${SCHEDULE_LIMITS.place}자 이하로 입력해 주세요.`,
})
place?: string | null;
@ApiPropertyOptional({
description: '라벨 색 #RRGGBB — 없으면 분류 기본 색',
nullable: true,
type: String,
})
@IsOptional()
@IsHexColor()
labelColor?: string | null;
}
/** 새 날짜 일정 하나 */
export class NewEventDto extends EventDetailsDto {
@ApiProperty({ description: '날짜 (YYYY-MM-DD)', example: '2026-10-11' })
@IsIsoDate()
date!: string;
}
/** 날짜 일정 여러 건 만들기 — 기간 설정은 날짜마다 한 건씩 */
export class CreateEventsDto {
@ApiProperty({
description: `만들 일정 (1~${SCHEDULE_LIMITS.eventsPerRequest}건 — 더 많으면 나눠 보낸다)`,
type: [NewEventDto],
})
@IsArray({ message: '일정 정보가 올바르지 않습니다.' })
@ArrayMinSize(1, { message: '일정 정보가 올바르지 않습니다.' })
@ArrayMaxSize(SCHEDULE_LIMITS.eventsPerRequest, {
message: `한 번에 ${SCHEDULE_LIMITS.eventsPerRequest}건까지 만들 수 있습니다.`,
})
@ValidateNested({ each: true })
@Type(() => NewEventDto)
events!: NewEventDto[];
}
/** 반복 주기 — "2주마다" (앱의 RepeatSpec) */
export class RepeatSpecDto {
@ApiProperty({ enum: REPEAT_MODES, example: 'every' })
@IsIn(REPEAT_MODES, { message: '반복 방식이 올바르지 않습니다.' })
mode!: RepeatMode;
@ApiProperty({ enum: REPEAT_UNITS, example: 'week' })
@IsIn(REPEAT_UNITS, { message: '반복 단위가 올바르지 않습니다.' })
unit!: RepeatUnit;
@ApiProperty({
description: `주기 N (1~${SCHEDULE_LIMITS.maxRepeatN})`,
example: 2,
})
@IsInt({ message: '반복 주기는 정수로 입력해 주세요.' })
@Min(1, { message: '반복 주기는 1 이상이어야 합니다.' })
@Max(SCHEDULE_LIMITS.maxRepeatN, {
message: `반복 주기는 ${SCHEDULE_LIMITS.maxRepeatN} 이하여야 합니다.`,
})
n!: number;
}
/** 반복 규칙 내용 바꾸기 — 주기와 내용을 통째로 바꾼다 */
export class UpdateRuleDto {
@ApiProperty({ type: RepeatSpecDto })
@ValidateNested()
@Type(() => RepeatSpecDto)
spec!: RepeatSpecDto;
@ApiProperty({ type: EventDetailsDto })
@ValidateNested()
@Type(() => EventDetailsDto)
details!: EventDetailsDto;
}
/** 반복 규칙 만들기 */
export class CreateRuleDto extends UpdateRuleDto {
@ApiProperty({
description: '반복을 시작하는 날 (YYYY-MM-DD)',
example: '2026-10-11',
})
@IsIsoDate()
from!: string;
}
/** 일정 내용 응답 */
export class EventDetailsResponseDto {
@ApiProperty() title!: string;
@ApiProperty({ enum: EVENT_KINDS }) kind!: EventKind;
@ApiProperty({ enum: EVENT_CATEGORIES }) category!: EventCategory;
@ApiProperty({ type: [String] }) targetIds!: string[];
@ApiProperty({ nullable: true, type: String }) start!: string | null;
@ApiProperty({ nullable: true, type: String }) end!: string | null;
@ApiProperty({ nullable: true, type: String }) place!: string | null;
@ApiProperty({ nullable: true, type: String }) labelColor!: string | null;
static from(details: ScheduleDetails): EventDetailsResponseDto {
return {
title: details.title,
kind: details.kind,
category: details.category,
targetIds: details.targetIds,
start: details.startTime,
end: details.endTime,
place: details.place,
labelColor: details.labelColor,
};
}
}
/** 일정 주인 — 연동된 사람의 일정도 보이므로 누구의 일정인지 함께 보낸다 */
export class ScheduleOwnerDto {
@ApiProperty({ description: '회원 ID' }) userId!: string;
@ApiProperty({
description: '닉네임 — 모르면 null',
nullable: true,
type: String,
})
nickname!: string | null;
@ApiProperty({
description: '프로필 사진 파일 ID — GET /api/files/{id}',
nullable: true,
type: String,
})
photoFileId!: string | null;
}
/** 일정 대상 — 일정 주인의 아이 또는 주인 자신(self) */
export class ScheduleTargetDto {
@ApiProperty({ description: '아이 ID 또는 "self"' }) id!: string;
@ApiProperty({
description: '아이 이름 · self 면 일정 주인의 닉네임',
nullable: true,
type: String,
})
name!: string | null;
@ApiProperty({
description: '아이 사진 · self 면 일정 주인의 프로필 사진 (파일 ID)',
nullable: true,
type: String,
})
photoFileId!: string | null;
@ApiProperty({
description: '아이의 고유색 (#RRGGBB) · self 면 null',
nullable: true,
type: String,
})
tint!: string | null;
}
/** 날짜 일정 응답 */
export class ScheduleEventResponseDto extends EventDetailsResponseDto {
@ApiProperty() id!: string;
@ApiProperty({ description: '날짜 (YYYY-MM-DD)' })
date!: string;
@ApiProperty({ description: '완료 표시' })
isDone!: boolean;
@ApiProperty({ description: '일정 주인 (회원 ID)' })
ownerId!: string;
@ApiProperty({ description: '일정 주인', type: ScheduleOwnerDto })
owner!: ScheduleOwnerDto;
@ApiProperty({
description: '대상 — targetIds 순서. 더 이상 없는 대상은 빠진다',
type: [ScheduleTargetDto],
})
targets!: ScheduleTargetDto[];
static fromEvent(
event: ScheduleEvent,
context: ScheduleViewContext,
): ScheduleEventResponseDto {
return {
id: event.id,
date: event.date,
isDone: event.isDone,
...EventDetailsResponseDto.from(event.details),
...toOwnership(event.ownerId, event.details.targetIds, context),
};
}
}
/** 반복 규칙 응답 (앱의 RepeatRule) */
export class RepeatRuleResponseDto {
@ApiProperty() id!: string;
@ApiProperty({ description: '반복을 시작한 날 (YYYY-MM-DD)' })
from!: string;
@ApiProperty({ type: RepeatSpecDto })
spec!: RepeatSpecDto;
@ApiProperty({ type: EventDetailsResponseDto })
details!: EventDetailsResponseDto;
@ApiProperty({ description: '반복 일정 주인 (회원 ID)' })
ownerId!: string;
@ApiProperty({ description: '반복 일정 주인', type: ScheduleOwnerDto })
owner!: ScheduleOwnerDto;
@ApiProperty({
description: '대상 — details.targetIds 순서. 더 이상 없는 대상은 빠진다',
type: [ScheduleTargetDto],
})
targets!: ScheduleTargetDto[];
static from(
rule: RepeatRule,
context: ScheduleViewContext,
): RepeatRuleResponseDto {
return {
id: rule.id,
from: rule.fromDate,
spec: { mode: rule.mode, unit: rule.unit, n: rule.n },
details: EventDetailsResponseDto.from(rule.details),
...toOwnership(rule.ownerId, rule.details.targetIds, context),
};
}
}
/** 반복 일정의 날짜별 완료 표시 응답 */
export class RuleCompletionResponseDto {
@ApiProperty() ruleId!: string;
@ApiProperty({ description: '완료한 날 (YYYY-MM-DD)' })
date!: string;
static from(completion: RepeatRuleCompletion): RuleCompletionResponseDto {
return { ruleId: completion.ruleId, date: completion.date };
}
}
/** 일정 내용 DTO → 컬럼 값 */
export function toDetailsColumns(dto: EventDetailsDto): ScheduleDetails {
return {
title: dto.title,
kind: dto.kind,
category: dto.category,
targetIds: [...new Set(dto.targetIds)],
startTime: dto.start ?? null,
// 하루 종일 일정에는 종료 시각을 두지 않는다
endTime: dto.start ? (dto.end ?? null) : null,
place: dto.place ?? null,
labelColor: dto.labelColor ?? null,
};
}
@@ -0,0 +1,55 @@
import {
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryColumn,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { User } from '@/modules/user/entities/user.entity';
/** 일정을 함께 보는 사람 수 (나 포함) */
export const CALENDAR_GROUP_MAX_MEMBERS = 4;
/**
* 일정 연동 묶음 — 묶인 사람들은 서로의 일정·반복 일정·완료 표시를 보고 고친다.
* 두 명 이상일 때만 "연동됨"이다. 한 명만 남으면 묶음을 지운다.
*/
@Entity('calendar_groups')
export class CalendarGroup {
@PrimaryGeneratedColumn('uuid')
id!: string;
@OneToMany(() => CalendarGroupMember, (member) => member.group)
members?: CalendarGroupMember[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
/** 묶음에 든 사람 — 한 사람은 한 묶음에만 든다 (UNIQUE user_id) */
@Entity('calendar_group_members')
@Unique(['userId'])
export class CalendarGroupMember {
@PrimaryColumn({ type: 'uuid' })
groupId!: string;
@PrimaryColumn({ type: 'uuid' })
userId!: string;
@ManyToOne(() => CalendarGroup, (group) => group.members, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'group_id' })
group?: CalendarGroup;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: User;
@CreateDateColumn({ type: 'timestamptz' })
joinedAt!: Date;
}

Some files were not shown because too many files have changed in this diff Show More