42 Commits

Author SHA1 Message Date
ttipo 2485feb0e4 docs(flutter): 출시 빌드 항목 순서 바로잡기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:59:34 +09:00
ttipo 19ee38a635 feat(flutter): 데이터 모드도 빌드할 때 지정하고 휴대폰 시험 빌드 절차 정리
빌드에는 개발자 PC 의 flutter/.env 가 asset 으로 묶여 들어가, 서버 주소는 덮어써도 데이터 모드는
그 .env 를 따랐다. --dart-define=DATA_MODE 로 정할 수 있게 하고 출시 명령에 넣는다.
운영 서버 전에는 PC 개발 서버(http)에 붙는 profile 빌드로 휴대폰에서 시험한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:59:23 +09:00
ttipo 4f8e3f0b68 fix(flutter): 출시 빌드의 인터넷 권한을 앱 설정에 직접 선언
지금은 스캐너 라이브러리의 설정이 합쳐지며 들어가 있어 동작하지만, 그 라이브러리를 빼면
출시 빌드만 조용히 서버에 닿지 못한다 (debug·profile 설정에는 따로 들어 있어 개발 중에는 드러나지 않는다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:41:21 +09:00
ttipo e3ed0fa800 docs(flutter): 업로드 키 명령에서 PowerShell 이 쪼개는 -J 옵션 빼기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:25:54 +09:00
ttipo a8861a0313 docs(flutter): 업로드 키 명령이 한글 콘솔에서 확인 질문을 되풀이하지 않게 -dname 추가
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:24:37 +09:00
ttipo 163cb16c6a docs(flutter): 업로드 키 만들기 안내에 Windows 명령과 경로 표기 추가
PATH 에 keytool 이 없는 Windows 에서 예시 명령이 바로 실패했다. Android Studio 에 들어 있는 keytool 경로,
PowerShell 에서 ~ 대신 USERPROFILE, key.properties 에서는 역슬래시 대신 슬래시를 써야 한다는 점을 적는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:22:01 +09:00
ttipo cffc5a5d06 fix(backend): 운영 점검 반영 — CORS 콤마 목록·운영 Swagger 닫기·마이그레이션 로그·업로드 디렉터리 확인
- CORS_ORIGIN 을 문자열 통째로 비교해 예시 파일 안내대로 콤마로 여러 개를 주면 어느 것도 맞지 않았고,
  운영에서도 localhost 두 곳이 늘 허용됐다 → 콤마 목록으로 읽고 localhost 는 개발·테스트에서만 더한다
- Swagger 가 운영에서도 공개돼 API 전체 구조가 드러났다 → 개발·테스트에서만 연다
- 운영은 DB 로그를 끄면서 기동 때 적용한 마이그레이션 기록도 남지 않았다 → migration 로그만 남긴다
- 업로드 디렉터리에 쓸 수 없어도 기동돼 첫 업로드에서야 500 이 났다 → 기동할 때 확인해 멈춘다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:04:05 +09:00
ttipo f60654bed6 chore(docker): 운영 배포 점검 반영 — 헬스체크·업로드 권한·빌드 컨텍스트·e2e 직렬 실행
운영 스택을 따로 띄워 확인하다 드러난 것들을 막는다.
- 업로드 디렉터리가 root 소유로 생기면 사진 업로드가 전부 500 인데 헬스체크(/)는 healthy 였다
  → 운영 헬스체크를 /api/health(DB) + 업로드 쓰기 권한으로, 이미지에 node 소유 디렉터리를 만들어 둔다
- 빌드 컨텍스트에 호스트(Windows) node_modules 와 backend/.env.* 가 들어가고 있었다 → .dockerignore
- CI e2e 는 스위트마다 빈 DB 에 마이그레이션을 동시에 돌려 충돌하고 재연결 타이밍으로만 통과했다 → --runInBand
- dev 헬스체크는 쿼리 로그가 쌓이지 않게 / 로 둔다
- 첫 배포 준비(env 두 파일·proxy 네트워크·업로드 디렉터리·백업·셸 APP_ENV 주의)를 docker 스킬에 적는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:56:24 +09:00
ttipo 0b978a9610 docs(flutter): 출시 빌드 절차와 앱 링크·출시 설정 골격 정리
서버 주소를 빌드 때 넘기는 방법, 업로드 키 서명, 버전·아이콘·시작 화면을 바꿀 때 함께 고칠 곳을
한 곳에 모아 둔다. 앱 ID 는 스토어에 올린 뒤 바꿀 수 없고 scheme 은 두 플랫폼에 함께 등록되어
있어, 모르고 한쪽만 고치면 되돌리기 어렵다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:52:24 +09:00
ttipo be58689dff feat(flutter): 로그인 전에 받은 앱 링크를 로그인 뒤 이어서 연다
QR 을 기기 카메라로 찍어 앱이 열렸는데 로그인 전이면 인트로·로그인으로 가며 링크가 사라졌다.
링크를 기억해 두었다가 진입(인트로·로그인·온보딩)을 마치고 홈으로 갈 때 대신 연다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:48:00 +09:00
ttipo 65c2016ab5 chore(flutter): 출시 준비 — 앱 이름·패키지 ID·아이콘·시작 화면·서명·서버 주소 확인
템플릿 기본값(tma_app · com.example)으로 남아 있던 앱 정체성과 출시 빌드 설정을 갖춘다.
- 앱 이름 rup, 패키지(번들) ID com.ttipo.rup (Android·iOS — 스토어에 올린 뒤에는 바꿀 수 없다). 웹 제목도 rup
- 앱 아이콘: 브랜드 주황 + 흰 워드마크 (원본 branding/, flutter_launcher_icons 로 크기별 생성 · Android 적응형 아이콘)
- 앱이 뜨는 동안의 화면을 첫 화면(스플래시)과 같은 노란색으로 — 흰 화면이 번쩍이지 않게 (Android 12+ 포함, iOS)
- Android 출시 서명: android/key.properties(커밋 안 함, 예시 key.properties.example). 없으면 경고와 함께 디버그 키
- 서버 주소는 --dart-define=API_URL 이 .env 보다 먼저. 출시 빌드가 http·개발용 주소를 가리키면 시작하지 않는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:42:45 +09:00
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
349 changed files with 25608 additions and 2163 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절).
- 매핑 로직(응답 래퍼·상태 역매핑)을 바꾸면 해당 분기 테스트를 추가한다.
+15
View File
@@ -41,6 +41,21 @@ sudo docker compose --env-file .env.production up -d --build
sudo docker compose --env-file .env.production logs -f backend
```
첫 배포 전에 서버에서 한 번 준비한다.
- env 두 파일 — 루트 `.env.production`, `backend/.env.production` (`JWT_SECRET` 32자 이상, 소셜 로그인 값). placeholder 가 남으면 기동 실패
- 외부 네트워크 `proxy` — 리버스 프록시(TLS 종료)와 backend·frontend 가 이 네트워크로 만난다. 없으면 `docker network create proxy`
- 업로드 디렉터리 — backend 는 uid 1000 으로 돈다. `sudo install -d -o 1000 -g 1000 ./data/uploads` (`UPLOAD_DATA_DIR`).
root 소유로 생기면 업로드가 전부 실패하고 backend 가 unhealthy 가 된다
- 마이그레이션은 backend 가 기동할 때 자동 적용된다 (`migrationsRun`). 별도 명령이 없다
- 백업 — `DB_DATA_DIR` 와 `UPLOAD_DATA_DIR` 를 **함께** 백업한다 (DB 가 파일을 id 로 가리킨다)
- 셸에 `APP_ENV` 같은 변수를 export 하지 않는다 — compose 는 셸 변수를 `--env-file` 보다 우선한다.
`APP_ENV=development` 가 남아 있으면 운영 명령이 development 설정(개발용 로그인 열림)으로 뜬다.
`sudo docker compose --env-file .env.production config | grep NODE_ENV` 로 `production` 인지 확인한다
배포 후 `sudo docker compose --env-file .env.production ps` 에서 backend 가 `healthy` 인지 확인한다
(헬스체크는 `/api/health` — DB 연결과 업로드 디렉터리 쓰기 권한을 함께 본다).
---
## 프론트엔드는 호스트에서 (개발 환경)
+86 -30
View File
@@ -16,18 +16,28 @@ 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=`readApiBaseUrl()`(빌드 때 넘긴 `--dart-define=API_URL` → `.env` 의 `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 한다 |
| 앱 링크 | `core/config/app_links.dart` + `features/links/` | `rup://app/<경로>` — Android 는 호스트를 버리므로 **경로로** 무엇을 열지 정한다. `LinkLandingPage` 가 코드를 확인해 받기·참여·연동으로 잇는다. 로그인 전에 받은 링크는 라우터가 기억했다가 진입을 마치면 연다 |
| 함께 쓰는 기록 새로 읽기 | `features/shell/.../shared_data_refresher.dart` | 앱으로 돌아올 때 + 보는 동안 3분마다. 뒤에서 읽으므로 `kSilentExtra` 로 실패 알림을 끈다 |
| 출시 설정 | `android/app/build.gradle.kts` · `ios/Runner` · `branding/` | 앱 ID `com.ttipo.rup`·이름 `rup`·아이콘·시작 화면·서명 — 아래 "출시 빌드" |
| 도메인 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 +172,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 +211,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);
// 읽기
@@ -223,6 +229,51 @@ await storage.deleteAll();
---
## 출시 빌드
```bash
# Android — 스토어 업로드용 (서버 주소는 빌드 때 넘긴다)
flutter build appbundle --release --dart-define=API_URL=https://<도메인>/api --dart-define=DATA_MODE=api
# 기기에서 릴리스 동작만 확인할 때
flutter build apk --release --dart-define=API_URL=https://<도메인>/api --dart-define=DATA_MODE=api
# iOS — macOS 에서, Xcode 로 팀·서명을 고른 뒤
flutter build ipa --release --dart-define=API_URL=https://<도메인>/api --dart-define=DATA_MODE=api
```
- **서버 주소**: 출시 빌드는 `.env` 대신 `--dart-define=API_URL` 로 넘긴다. https 가 아니거나 개발용 주소(`localhost`·`10.0.2.2` 등)면
**앱이 시작하지 않는다**(`releaseApiUrlProblem()`) — 사용자 기기에서 모든 요청이 조용히 실패하는 것보다 낫다.
- **데이터 모드**: 빌드에는 개발자 PC 의 `flutter/.env` 가 asset 으로 묶인다. `--dart-define=DATA_MODE=api` 로 정해 두어 그 `.env` 가 `local` 이어도 출시 빌드가 기기 저장으로 바뀌지 않게 한다.
- **Android 서명**: `android/key.properties.example` → `android/key.properties` 복사 후 업로드 키 경로·비밀번호를 채운다.
파일이 없으면 디버그 키로 서명되어(빌드 로그에 경고) **스토어에 올릴 수 없다.** `key.properties`·키 파일은 커밋하지 않는다(`.gitignore`).
업로드 키를 잃어버리면 같은 앱으로 업데이트를 올릴 수 없으니 별도로 백업한다.
- **버전**: `pubspec.yaml` 의 `version: 이름+빌드번호` 하나만 올린다 — Android `versionCode`·iOS `CFBundleVersion` 이 이를 따른다.
스토어는 같은 빌드 번호를 두 번 받지 않는다.
- **앱 ID**: `com.ttipo.rup`(Android `applicationId` · iOS 번들 ID). **스토어에 올린 뒤에는 바꿀 수 없다.**
Dart 패키지 이름(`tma_app`, import 경로)은 앱 ID 와 무관해 그대로 둔다. 데스크톱 폴더(windows·linux·macos)는 출시 대상이 아니라 손대지 않았다.
- **아이콘**: 원본은 `branding/app_icon.png`(전체)·`app_icon_foreground.png`(적응형 앞면, 안전 영역 안). 바꾸면 `dart run flutter_launcher_icons` 로
크기별 아이콘을 다시 만든다. 바탕색은 `pubspec.yaml` 의 `adaptive_icon_background`.
- **시작 화면 색**: `#FFDE84` — Android `res/values/colors.xml` 의 `launch_background`(12+ 는 `values-v31` 스타일), iOS `LaunchScreen.storyboard`. 바꿀 때 함께 고친다.
- **앱 링크 scheme** `rup` 은 AndroidManifest 의 intent-filter 와 iOS `CFBundleURLTypes` 에 함께 등록되어 있다 — 하나만 바꾸면 한쪽 기기에서 QR 이 앱을 열지 못한다.
### 운영 서버 전 휴대폰 테스트 (Android)
출시 빌드는 https 만 받으므로, PC 개발 서버(http)에 붙이는 시험 빌드는 **profile** 로 만든다 (속도는 출시 빌드와 같고 디버그 키로 서명된다).
```bash
# 휴대폰과 PC 가 같은 공유기에 있어야 한다. <PC IP> 는 PowerShell `Get-NetIPConfiguration` 의 IPv4
flutter build apk --profile --target-platform android-arm64 --dart-define=API_URL=http://<PC IP>:3000/api --dart-define=DATA_MODE=api
adb install -r build/app/outputs/flutter-apk/app-profile.apk # USB 디버깅을 켜고 연결했을 때
```
- 개발 서버는 개발용 로그인이 열려 있어 기기마다 새 회원이 된다. 공유 기능은 에뮬레이터(`adb reverse tcp:3000 tcp:3000`)를 두 번째 사람으로 쓴다.
- PC 의 IP 가 바뀌면(공유기 재시작 등) 다시 빌드한다. 공유기의 "무선 기기 격리(AP isolation)" 가 켜져 있으면 휴대폰이 PC 에 닿지 않는다.
- 시험 빌드(디버그 키)와 출시 빌드(업로드 키)는 서명이 달라 덮어 설치되지 않는다 — 앱을 지우고 설치한다(기기에 남은 로그인도 지워진다).
- iPhone 은 Mac(Xcode)이 있어야 한다 — Mac 에서 `flutter run --profile` 에 같은 `--dart-define` 을 넘긴다. 운영 서버가 생기면 TestFlight 로 나눠 준다.
---
## 성능 & 최신 트렌드 추가 규칙
- **모델 직렬화**: `json_serializable` + `freezed` 사용 권장 (불변 모델)
@@ -242,9 +293,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 +307,7 @@ 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 에서는 돌지 않는다).
- [ ] **출시 빌드**: `--dart-define=API_URL=https://…` + `android/key.properties` + `pubspec.yaml` 빌드 번호 증가 (위 "출시 빌드").
+38 -17
View File
@@ -23,26 +23,37 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 |
| 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` |
| 전역 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_*` 로 조정 |
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. **개발·테스트에서만 열린다**(`isDevNodeEnv`) — 운영에서 API 구조를 공개하지 않는다 |
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트(루트 `CORS_ORIGIN`, 여러 개면 콤마 구분. 개발·테스트는 localhost 를 더 허용) |
| 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/` 에 추가
+5
View File
@@ -76,6 +76,11 @@ COMPOSE_PROFILES=
# ==========================================
# 파일 업로드 (로컬 디스크)
#
# UPLOAD_DATA_DIR : 업로드 사진이 저장되는 호스트 경로 (컨테이너의 /app/uploads 에 연결)
# Linux 호스트라면 백엔드(비-root, uid 1000)가 쓸 수 있게 미리 만든다:
# install -d -o 1000 -g 1000 ./data/uploads
# (Docker Desktop 의 Windows·macOS 경로는 소유권과 무관하게 쓸 수 있다)
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
+6
View File
@@ -77,6 +77,12 @@ COMPOSE_PROFILES=
# ==========================================
# 파일 업로드 (로컬 디스크)
#
# UPLOAD_DATA_DIR : 업로드 사진이 저장되는 호스트 경로 (컨테이너의 /app/uploads 에 연결)
# - 백엔드는 비-root(node, uid 1000)로 돈다. 디렉터리가 없으면 docker 가 root 소유로 만들어
# 업로드가 전부 실패하므로(헬스체크 unhealthy) **첫 배포 전에 한 번** 만들어 둔다.
# sudo install -d -o 1000 -g 1000 ./data/uploads
# - DB 는 파일을 id 로 가리킨다. 백업은 DB_DATA_DIR 와 이 디렉터리를 **함께** 한다.
# ==========================================
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
+4 -1
View File
@@ -57,8 +57,11 @@ jobs:
run: npm run test
# 표준 응답 래퍼·에러 코드·traceId 계약 검증 (.claude/rules/response-format.md)
# --runInBand: 스위트들이 같은 DB 를 쓰고, NODE_ENV=test 라 스위트마다 기동할 때 마이그레이션을 돌린다.
# 병렬로 돌면 빈 DB 에서 여러 스위트가 같은 테이블을 동시에 만들다 충돌한다(duplicate key ... pg_class).
# TypeORM 재연결(3초 뒤)로 대개 통과하지만 타이밍에 기대는 것이라 순서대로 돌린다.
- name: E2E test
run: npm run test:e2e
run: npm run test:e2e -- --runInBand
env:
NODE_ENV: test
# 서비스 컨테이너의 네트워크 별칭 (localhost 는 잡 컨테이너 자신을 가리킨다)
+13
View File
@@ -0,0 +1,13 @@
# 빌드 컨텍스트에서 제외한다 — 이미지 안에서 새로 만들거나, 이미지에 들어가면 안 되는 것
#
# node_modules : 호스트(Windows)에서 설치한 네이티브 바이너리(@swc/core-win32 등)가
# 이미지의 npm ci 결과를 덮어써 Linux 빌드가 깨진다
# .env* : 시크릿 — 값은 compose 의 env_file/environment 로만 주입한다 (.claude/rules/security.md)
# uploads : 사용자 파일 — 운영에서는 compose 볼륨(UPLOAD_DATA_DIR)에 있다
node_modules
dist
coverage
uploads
.env
.env.*
*.log
+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
+19 -1
View File
@@ -9,7 +9,25 @@
# 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=
# 개발용 로그인(AUTH_DEV_LOGIN)은 운영에서 값과 무관하게 코드로 닫힌다 — 여기에 두지 않는다.
+5
View File
@@ -31,6 +31,11 @@ RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# 업로드 저장 위치(compose 의 UPLOAD_DIR) — node 가 쓸 수 있게 소유권을 넘겨 둔다.
# named volume 은 이 소유권을 이어받는다. 호스트 경로(bind mount)는 호스트 디렉터리 소유권을 따르므로
# 운영 서버에서 미리 uid 1000 소유로 만들어 둔다 (.env.production.example 의 UPLOAD_DATA_DIR 참조)
RUN mkdir -p /app/uploads && chown node:node /app/uploads
EXPOSE 3000
# 비-root 실행 (보안 규칙)
+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']);
});
});
+32 -21
View File
@@ -13,9 +13,14 @@ import type {
import { Reflector } from '@nestjs/core';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { isDevNodeEnv } from '@/shared/config/node-env';
import { parseCommaList } from '@/shared/utils/env';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { winstonConfig } from '@/shared/logger/winston.config';
/** 개발·테스트에서만 더 허용하는 오리진 — 호스트에서 띄운 vite·Swagger. 운영에는 들어가지 않는다 */
const DEV_CORS_ORIGINS = ['http://localhost:5173', 'http://localhost:3000'];
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger(winstonConfig),
@@ -39,20 +44,20 @@ async function bootstrap() {
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
app.use(helmet());
// 2. 화이트리스트 기반 CORS 정책
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const corsOriginEnv = process.env.CORS_ORIGIN;
// 2. 화이트리스트 기반 CORS 정책 — 루트 env 의 CORS_ORIGIN(여러 개면 콤마 구분)
const isDevEnv = isDevNodeEnv(process.env.NODE_ENV);
const whitelist = new Set([
...parseCommaList(process.env.CORS_ORIGIN),
...(isDevEnv ? DEV_CORS_ORIGINS : []),
]);
app.enableCors({
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => {
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
const whitelist = [frontendUrl, 'http://localhost:3000'];
if (corsOriginEnv) whitelist.push(corsOriginEnv);
if (!origin || whitelist.indexOf(origin) !== -1) {
// 서버간의 통신·앱(origin이 undefined)이거나 명시된 리스트일 때만 허용
if (!origin || whitelist.has(origin)) {
callback(null, true);
} else {
// Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로,
@@ -80,15 +85,19 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
// 6. Swagger API 문서 설정
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription('The API documentation conforming to the global guidelines')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
// 6. Swagger API 문서 — 개발·테스트에서만 연다 (운영에서 API 전체 구조를 공개하지 않는다)
if (isDevEnv) {
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription(
'The API documentation conforming to the global guidelines',
)
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
}
// 7. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
// 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다.
@@ -100,10 +109,12 @@ async function bootstrap() {
const port = Number(process.env.PORT ?? 3000);
await app.listen(port, '0.0.0.0');
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
Logger.log(
`📘 Swagger docs at http://localhost:${port}/api-docs`,
'Bootstrap',
);
if (isDevEnv) {
Logger.log(
`📘 Swagger docs at http://localhost:${port}/api-docs`,
'Bootstrap',
);
}
}
void bootstrap();
@@ -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();
});
});
+90
View File
@@ -0,0 +1,90 @@
import { isDevNodeEnv } from '@/shared/config/node-env';
import { parseDurationMs } from '@/shared/utils/duration';
import { parseCommaList } from '@/shared/utils/env';
/**
* 인증 설정 — 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 parseCommaList(readEnv(env, key));
}
/** 소셜 제공자별 앱 정보 — 비어 있으면 그 제공자 로그인은 거절한다 */
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;
}
}

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