34 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
277 changed files with 16563 additions and 1277 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]] 참조.
+18 -6
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)가 여전히 중요하다.
## 네트워크 · 인프라
+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
+2
View File
@@ -29,3 +29,5 @@ NAVER_CLIENT_ID=
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 실행 (보안 규칙)
+19 -2
View File
@@ -3,11 +3,19 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
// 도메인 일정 모듈(modules/schedule)과 이름이 겹쳐 크론 모듈을 별칭으로 부른다
import { ScheduleModule as CronScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
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';
@@ -43,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,
@@ -55,6 +65,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
THROTTLE_DEFAULT_LIMIT,
),
},
{ name: INVITE_THROTTLER, ...INVITE_THROTTLE },
]),
// DB 접속 설정은 database.config.ts 하나만 수정한다 (CLI 와 공유)
TypeOrmModule.forRootAsync({
@@ -75,9 +86,10 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
],
providers: [
ShutdownLogger,
// 가드는 등록한 순서대로 돈다 — IP 한도 → 인증 → 코드 조회 한도(회원별)
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
useClass: IpThrottlerGuard,
},
// 전역 인증 — 모든 라우트가 기본으로 로그인을 요구한다. 예외는 @Public() 으로 연다.
// (rate limit 이 먼저 돌아 토큰 없는 대량 요청도 한도에 걸린다)
@@ -85,6 +97,11 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// 코드 조회 한도는 회원을 알아야 하므로 인증 다음에 둔다
{
provide: APP_GUARD,
useClass: InviteThrottlerGuard,
},
],
})
export class AppModule implements NestModule {
@@ -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);
@@ -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');
+61
View File
@@ -83,11 +83,72 @@ const DOMAIN_ERROR_CODES = {
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,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);
}
}
+14 -1
View File
@@ -1,4 +1,5 @@
import { applyDecorators } from '@nestjs/common';
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
@@ -61,11 +62,23 @@ 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;
/**
* 대상 목록 — 각 값은 "self" 이거나 아이 id(UUID). 기본은 1개 이상(일정), 다이어리는 0개도 된다.
* 대상 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}개까지예요.` }),
@@ -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,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"`,
);
}
}
+25 -7
View File
@@ -19,20 +19,38 @@ describe('인증 설정', () => {
);
});
it('JWT_SECRET 이 비어 있으면 개발용 키로 대신한다 (운영은 env 검증이 막는다)', () => {
expect(resolveJwtSecret({ JWT_SECRET: 'real-secret' })).toBe('real-secret');
expect(resolveJwtSecret({ JWT_SECRET: ' ' })).toMatch(/dev-only/);
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('개발용 로그인은 운영에서 항상 꺼진다', () => {
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);
expect(
isDevLoginEnabled({ NODE_ENV: 'production', AUTH_DEV_LOGIN: 'true' }),
).toBe(false);
for (const nodeEnv of ['production', 'staging', 'prod', '', undefined]) {
expect(
isDevLoginEnabled({ NODE_ENV: nodeEnv, AUTH_DEV_LOGIN: 'true' }),
).toBe(false);
}
});
it('소셜 설정은 비어 있으면 끈 것으로 본다', () => {
+16 -9
View File
@@ -1,4 +1,6 @@
import { isDevNodeEnv } from '@/shared/config/node-env';
import { parseDurationMs } from '@/shared/utils/duration';
import { parseCommaList } from '@/shared/utils/env';
/**
* 인증 설정 — backend/.env.{APP_ENV} 에서 읽는다 (루트에서 내려주지 않는 백엔드 고유 값).
@@ -15,8 +17,9 @@ const DEFAULT_ACCESS_TTL_MS = 3_600_000; // 1시간
const DEFAULT_REFRESH_TTL_MS = 1_209_600_000; // 14일
/**
* 개발·테스트 전용 서명 키 — JWT_SECRET 을 비워 둔 로컬·CI 에서만 쓴다.
* 운영은 env.validation 이 JWT_SECRET 을 필수로 요구하므로 이 값으로 기동할 수 없다.
* 개발·테스트 전용 서명 키 — 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';
@@ -26,7 +29,13 @@ function readEnv(env: Env, key: string): string | undefined {
}
export function resolveJwtSecret(env: Env = process.env): string {
return readEnv(env, 'JWT_SECRET') ?? DEV_ONLY_JWT_SECRET;
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 {
@@ -39,19 +48,17 @@ export function refreshTokenTtlMs(env: Env = process.env): number {
/**
* 개발용 로그인(소셜 앱 키 없이 기기 키로 가입) 허용 여부.
* 운영에서는 어떤 값을 주어도 꺼진다. 개발·테스트에서는 AUTH_DEV_LOGIN=false 로 끌 수 있다.
* NODE_ENV 가 development·test 일 때만 열린다 — 그 밖(staging·production·미설정 …)에서는 어떤 값을 주어도 닫힌다.
* 개발·테스트에서도 AUTH_DEV_LOGIN=false 로 끌 수 있다.
*/
export function isDevLoginEnabled(env: Env = process.env): boolean {
if (env.NODE_ENV === 'production') return false;
if (!isDevNodeEnv(env.NODE_ENV)) return false;
return readEnv(env, 'AUTH_DEV_LOGIN') !== 'false';
}
/** 콤마로 나눈 목록 (빈 칸 제거) */
function readList(env: Env, key: string): string[] {
return (readEnv(env, key) ?? '')
.split(',')
.map((v) => v.trim())
.filter((v) => v !== '');
return parseCommaList(readEnv(env, key));
}
/** 소셜 제공자별 앱 정보 — 비어 있으면 그 제공자 로그인은 거절한다 */
@@ -9,6 +9,7 @@ import {
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import {
@@ -36,6 +37,8 @@ import {
CreatePostDto,
DiaryPostResponseDto,
DiaryResponseDto,
DiarySharingDto,
MutedNoticesDto,
UpdateDiaryDto,
} from './dto/diary.dto';
@@ -48,7 +51,10 @@ export class DiariesController {
constructor(private readonly diary: DiaryService) {}
@Get()
@ApiOperation({ summary: '내 다이어리 목록 (만든 순, 커서 페이지네이션)' })
@ApiOperation({
summary: '내가 함께하는 다이어리 목록 (만든 순, 커서 페이지네이션)',
description: '내가 만든 다이어리와 초대 코드로 참여한 다이어리가 함께 온다',
})
@ApiPaginatedResponse(DiaryResponseDto, '다이어리 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@@ -59,7 +65,11 @@ export class DiariesController {
}
@Post()
@ApiOperation({ summary: '새 다이어리' })
@ApiOperation({
summary: '새 다이어리',
description:
'shared=true 면 초대 코드를 함께 만든다. 책장은 참여한 다이어리도 함께 센다',
})
@ApiStandardResponse(DiaryResponseDto, {
status: 201,
description: '만든 다이어리',
@@ -75,10 +85,11 @@ export class DiariesController {
@Patch(':id')
@ApiOperation({
summary: '다이어리 수정 (이름·대상·리본·표지·알림, 보낸 값만)',
summary: '다이어리 수정 (이름·대상·리본·표지, 보낸 값만) — 주인만',
})
@ApiStandardResponse(DiaryResponseDto, { description: '수정한 다이어리' })
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '주인이 아님')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
update(
@CurrentUser() user: AuthUser,
@@ -91,9 +102,12 @@ export class DiariesController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '다이어리 삭제 (기록·댓글 함께)' })
@ApiOperation({
summary: '다이어리 삭제 (기록·댓글·함께하는 사람 함께) — 주인만',
})
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '주인이 아님 (나가기를 쓴다)')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@@ -101,8 +115,79 @@ export class DiariesController {
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: '기록 남기기 (하루 한 번)' })
@ApiOperation({ summary: '기록 남기기 (함께하는 사람 누구나, 하루 한 번)' })
@ApiStandardResponse(DiaryPostResponseDto, {
status: 201,
description: '남긴 기록',
@@ -42,7 +42,7 @@ export class DiaryCommentsController {
@Get()
@ApiOperation({
summary: '내 다이어리들의 댓글 목록 (쓴 순, 커서 페이지네이션)',
summary: '내가 함께하는 다이어리들의 댓글 목록 (쓴 순, 커서 페이지네이션)',
})
@ApiPaginatedResponse(DiaryCommentResponseDto, '댓글 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
@@ -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);
}
}
@@ -48,7 +48,7 @@ export class DiaryPostsController {
@Get()
@ApiOperation({
summary: '내 다이어리들의 기록 목록 (쓴 순, 커서 페이지네이션)',
summary: '내가 함께하는 다이어리들의 기록 목록 (쓴 순, 커서 페이지네이션)',
})
@ApiPaginatedResponse(DiaryPostResponseDto, '기록 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
@@ -75,7 +75,7 @@ export class DiaryPostsController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '기록 삭제 (댓글 함께)' })
@ApiOperation({ summary: '기록 삭제 (댓글 함께, 내가 쓴 기록만)' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '기록을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 기록이 아님')
@@ -87,7 +87,7 @@ export class DiaryPostsController {
}
@Post(':id/comments')
@ApiOperation({ summary: '댓글 쓰기' })
@ApiOperation({ summary: '댓글 쓰기 (함께하는 사람 누구나)' })
@ApiStandardResponse(DiaryCommentResponseDto, {
status: 201,
description: '쓴 댓글',
@@ -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(),
}));
}
+24 -3
View File
@@ -3,23 +3,44 @@ 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, DiaryPost } from './entities/diary.entity';
import {
Diary,
DiaryComment,
DiaryMember,
DiaryPost,
} from './entities/diary.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Diary, DiaryPost, DiaryComment]),
TypeOrmModule.forFeature([
Diary,
DiaryMember,
DiaryPost,
DiaryComment,
User,
]),
PetModule,
UploadModule,
UserModule,
],
controllers: [
DiariesController,
DiaryPostsController,
DiaryCommentsController,
DiaryInvitationsController,
DiaryMembershipsController,
],
providers: [DiaryService],
providers: [DiaryService, DiaryFileAccess],
})
export class DiaryModule {}
+463 -84
View File
@@ -1,6 +1,15 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, IsNull, Not, Repository } from 'typeorm';
import {
DataSource,
EntityManager,
In,
IsNull,
Not,
ObjectLiteral,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
import {
@@ -15,14 +24,28 @@ import {
} 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 { 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';
@@ -30,9 +53,14 @@ 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,
@@ -45,134 +73,362 @@ const createdAtKeys = <T extends { createdAt: Date; id: string }>(
{ 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 {
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(
ownerId: string,
userId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryResponseDto>> {
const qb = this.diaries
.createQueryBuilder('diary')
.where('diary.owner_id = :ownerId', { ownerId });
const qb = onlyMemberOf(
this.diaries.createQueryBuilder('diary'),
'diary.id',
userId,
);
const page = await keysetPaginate(qb, createdAtKeys<Diary>('diary'), query);
return mapPage(page, (d) => DiaryResponseDto.from(d));
return {
items: await this.toResponses(userId, page.items),
pageInfo: page.pageInfo,
};
}
/** 새 다이어리 — 책장이 가득 찼으면 DIARY_001 (추억 보관함의 아이만 담은 다이어리는 세지 않는다) */
/** 새 다이어리 — 책장이 가득 찼으면 DIARY_001. 만든 사람이 주인으로 함께한다 */
async createDiary(
ownerId: string,
userId: string,
dto: CreateDiaryDto,
): Promise<DiaryResponseDto> {
await this.pets.assertOwnedTargets(ownerId, dto.targetIds);
await this.uploads.assertOwnedImage(dto.coverFileId, ownerId);
await this.pets.assertOwnedTargets(userId, dto.targetIds);
await this.uploads.assertOwnedImage(dto.coverFileId, userId);
const shared = dto.shared ?? false;
return this.dataSource.transaction(async (manager) => {
// 동시에 만들어 한도를 넘지 않도록 회원의 다이어리를 잠그고 센다
const existing = await manager.find(Diary, {
where: { ownerId },
select: { id: true, targetIds: true },
lock: { mode: 'pessimistic_write' },
});
const archived = await manager.find(Pet, {
where: { ownerId, archivedAt: Not(IsNull()) },
select: { id: true },
});
const shelf = countShelfDiaries(
existing.map((d) => d.targetIds),
new Set(archived.map((p) => p.id)),
);
if (shelf >= DIARY_LIMITS.maxDiaries) {
throw new BusinessException('DIARY_001', { context: { shelf } });
}
const diary = await withFreshInviteCode(() =>
this.dataSource.transaction(async (manager) => {
await this.assertShelfRoom(manager, userId);
const diary = await manager.save(
manager.create(Diary, {
ownerId,
name: dto.name,
targetIds: [...new Set(dto.targetIds)],
ribbon: dto.ribbon,
tint: dto.tint,
coverFileId: dto.coverFileId ?? null,
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 DiaryResponseDto.from(diary);
});
});
return saved;
}),
);
return this.toResponse(userId, diary);
}
/** 다이어리 수정 — 주인만 (함께하는 사람은 AUTH_003) */
async updateDiary(
ownerId: string,
userId: string,
diaryId: string,
dto: UpdateDiaryDto,
): Promise<DiaryResponseDto> {
const diary = await this.findOwnedDiary(ownerId, diaryId);
const diary = await this.findOwnedDiary(userId, diaryId);
const previousCover = diary.coverFileId;
if (dto.targetIds !== undefined) {
await this.pets.assertOwnedTargets(ownerId, dto.targetIds);
await this.pets.assertOwnedTargets(userId, dto.targetIds);
diary.targetIds = [...new Set(dto.targetIds)];
}
if (dto.coverFileId !== undefined) {
await this.uploads.assertOwnedImage(dto.coverFileId, ownerId);
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;
if (dto.mutedNotices !== undefined) {
diary.mutedNotices = [...new Set(dto.mutedNotices)];
}
const saved = await this.diaries.save(diary);
if (previousCover !== saved.coverFileId) {
await this.uploads.releaseIfUnused([previousCover]);
}
return DiaryResponseDto.from(saved);
return this.toResponse(userId, saved);
}
/** 다이어리를 지우면 그 안의 기록·댓글도 모두 지운다 (FK) — 쓰지 않게 된 사진도 정리한다 */
async deleteDiary(ownerId: string, diaryId: string): Promise<void> {
const diary = await this.findOwnedDiary(ownerId, diaryId);
/**
* 다이어리를 지우면 그 안의 기록·댓글과 함께하는 사람도 모두 지운다 (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 });
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(
ownerId: string,
userId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryPostResponseDto>> {
const qb = this.posts
.createQueryBuilder('post')
.innerJoin('post.diary', 'diary')
.where('diary.owner_id = :ownerId', { ownerId });
const qb = onlyMemberOf(
this.posts.createQueryBuilder('post'),
'post.diary_id',
userId,
).leftJoinAndSelect('post.author', 'author');
const page = await keysetPaginate(
qb,
createdAtKeys<DiaryPost>('post'),
@@ -181,13 +437,13 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
return mapPage(page, (p) => DiaryPostResponseDto.from(p));
}
/** 기록 남기기 — 한 사람이 한 다이어리에 하루 한 번 (DIARY_002) */
/** 기록 남기기 — 함께하는 사람 누구나. 한 사람이 한 다이어리에 하루 한 번 (DIARY_002) */
async createPost(
userId: string,
diaryId: string,
dto: CreatePostDto,
): Promise<DiaryPostResponseDto> {
await this.findOwnedDiary(userId, diaryId);
await this.findMemberDiary(userId, diaryId);
await this.uploads.assertOwnedImage(dto.photoFileId, userId);
try {
@@ -201,6 +457,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
photoFileId: dto.photoFileId ?? null,
}),
);
post.author = (await this.users.findOneBy({ id: userId })) ?? undefined;
return DiaryPostResponseDto.from(post);
} catch (error) {
if (isUniqueViolation(error)) {
@@ -230,16 +487,18 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
}
// ── 댓글 ───────────────────────────────────────────────
/** 내 다이어리들의 모든 댓글 (쓴 순서) */
/** 내가 함께하는 다이어리들의 모든 댓글 (쓴 순서) */
async listComments(
ownerId: string,
userId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryCommentResponseDto>> {
const qb = this.comments
.createQueryBuilder('comment')
.innerJoin('comment.post', 'post')
.innerJoin('post.diary', 'diary')
.where('diary.owner_id = :ownerId', { ownerId });
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'),
@@ -248,6 +507,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
return mapPage(page, (c) => DiaryCommentResponseDto.from(c));
}
/** 댓글 쓰기 — 함께하는 사람 누구나 */
async createComment(
userId: string,
postId: string,
@@ -257,6 +517,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
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);
}
@@ -275,6 +536,26 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
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({
@@ -289,30 +570,121 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
);
}
// ── 책장 ───────────────────────────────────────────────
/**
* 책장에 한 권 더 꽂을 수 있는지 — 내가 함께하는 다이어리를 센다(추억 보관함의 내 아이만 담은 다이어리는 빼고).
* 동시에 만들거나 참여해 한도를 넘지 않도록 회원 단위로 잠그고 센다 — 행 잠금과 달리 아직 한 권도 없을 때도 잠긴다.
*/
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,
}),
);
}
// ── 조회 도우미 ───────────────────────────────────────
private async findOwnedDiary(
ownerId: string,
/** 내가 함께하는 다이어리 — 아니면 없는 것과 똑같이 RES_001 */
private async findMemberDiary(
userId: string,
diaryId: string,
): Promise<Diary> {
const diary = await this.diaries.findOneBy({ id: diaryId, ownerId });
if (!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 this.posts.findOne({
where: { id: postId, diary: { ownerId: userId } },
});
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,
@@ -324,14 +696,21 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
return post;
}
/** 고칠 수 있는 댓글 — 내 다이어리에서 내가 쓴 댓글 */
/** 고칠 수 있는 댓글 — 내가 함께하는 다이어리에서 내가 쓴 댓글 (나간 뒤에는 RES_001) */
private async findOwnComment(
userId: string,
commentId: string,
): Promise<DiaryComment> {
const comment = await this.comments.findOne({
where: { id: commentId, post: { diary: { ownerId: userId } } },
});
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 } });
}
+220 -10
View File
@@ -8,6 +8,7 @@ import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
@@ -17,10 +18,15 @@ import {
} 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,
@@ -29,6 +35,9 @@ import type {
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** 초대 코드 입력 길이 상한 — 형식 검사는 서비스가 한다 (틀린 코드는 DIARY_003) */
const INVITE_CODE_INPUT_MAX_LENGTH = 20;
/** 새 다이어리 */
export class CreateDiaryDto {
@ApiProperty({
@@ -69,25 +78,65 @@ export class CreateDiaryDto {
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
coverFileId?: string | null;
@ApiPropertyOptional({
description:
'함께 쓰는 다이어리로 만들지 (기본 false) — 켜면 초대 코드가 생긴다',
default: false,
})
@IsOptional()
@IsBoolean({ message: '공유 설정이 올바르지 않습니다.' })
shared?: boolean;
}
/** 다이어리 수정 — 보낸 값만 바꾼다. 바탕색(tint)은 만들 때 정한 값을 유지한다 */
/**
* 다이어리 수정 — 보낸 값만 바꾼다. 바탕색(tint)은 만들 때 정한 값을 유지한다.
* 공유는 PUT /diaries/:id/sharing, 알림은 PUT /diaries/:id/muted-notices 로 바꾼다.
*/
export class UpdateDiaryDto extends PartialType(
OmitType(CreateDiaryDto, ['tint'] as const),
) {
@ApiPropertyOptional({
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,
})
@IsOptional()
@IsArray({ message: '알림 설정이 올바르지 않습니다.' })
@ArrayMaxSize(DIARY_NOTICES.length)
@ArrayMaxSize(DIARY_NOTICES.length, {
message: '알림 설정이 올바르지 않습니다.',
})
@IsIn(DIARY_NOTICES, {
each: true,
message: '알림 설정이 올바르지 않습니다.',
})
mutedNotices?: DiaryNotice[];
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;
}
/** 기록 남기기 — 하루 한 번 */
@@ -148,6 +197,93 @@ export class CommentDto {
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;
@@ -163,13 +299,44 @@ export class DiaryResponseDto {
})
coverFileId!: string | null;
@ApiProperty({ enum: DIARY_NOTICES, isArray: true })
@ApiProperty({
description: '내가 끈 알림 (함께하는 사람마다 따로)',
enum: DIARY_NOTICES,
isArray: true,
})
mutedNotices!: DiaryNotice[];
@ApiProperty({ description: '만든 때 (ISO 8601 UTC)' })
createdAt!: string;
static from(diary: Diary): DiaryResponseDto {
@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,
@@ -177,12 +344,45 @@ export class DiaryResponseDto {
ribbon: diary.ribbon,
tint: diary.tint,
coverFileId: diary.coverFileId,
mutedNotices: diary.mutedNotices,
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;
@@ -196,6 +396,10 @@ export class DiaryPostResponseDto {
@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,
@@ -206,6 +410,7 @@ export class DiaryPostResponseDto {
text: post.text,
mood: post.mood,
photoFileId: post.photoFileId,
author: DiaryAuthorDto.from(post.authorId, post.author),
};
}
}
@@ -218,6 +423,10 @@ export class DiaryCommentResponseDto {
@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,
@@ -225,6 +434,7 @@ export class DiaryCommentResponseDto {
authorId: comment.authorId,
text: comment.text,
createdAt: comment.createdAt.toISOString(),
author: DiaryAuthorDto.from(comment.authorId, comment.author),
};
}
}
@@ -5,6 +5,8 @@ import {
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryColumn,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
@@ -12,11 +14,14 @@ import {
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,
@@ -72,12 +77,26 @@ export class Diary {
@JoinColumn({ name: 'cover_file_id' })
coverFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
coverFileId!: string | null;
/** 끈 알림 (기본은 모두 켜짐) */
@Column({ type: 'text', array: true, default: '{}' })
mutedNotices!: DiaryNotice[];
/** 함께 쓰는 다이어리인지 — 켜면 초대 코드로 다른 회원이 참여한다 */
@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;
@@ -86,6 +105,35 @@ export class Diary {
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'])
@@ -101,11 +149,12 @@ export class DiaryPost {
@Column({ type: 'uuid' })
diaryId!: string;
/** 기록한 사람 — 지금은 다이어리 주인. 공유 다이어리가 생기면 멤버가 된다 */
/** 기록한 사람 — 다이어리에 함께하는 사람. 나가도 기록은 남는다 */
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author?: User;
@Index()
@Column({ type: 'uuid' })
authorId!: string;
@@ -123,6 +172,7 @@ export class DiaryPost {
@JoinColumn({ name: 'photo_file_id' })
photoFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
photoFileId!: string | null;
@@ -152,6 +202,7 @@ export class DiaryComment {
@JoinColumn({ name: 'author_id' })
author?: User;
@Index()
@Column({ type: 'uuid' })
authorId!: string;
+4
View File
@@ -0,0 +1,4 @@
import { inviteCodeFormat } from '@/shared/utils/invite-code';
/** 공유 다이어리 초대 코드 `RUP-XXXXXX` (뒷부분 6자) */
export const DIARY_INVITE_CODE = inviteCodeFormat(6);
@@ -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,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(),
};
}
}
@@ -16,6 +16,10 @@ 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];
@@ -75,6 +79,7 @@ export class PetCard {
@JoinColumn({ name: 'name_bg_file_id' })
nameBgFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
nameBgFileId!: string | null;
@@ -93,6 +98,20 @@ export class PetCard {
@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;
@@ -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;
}
@@ -4,15 +4,30 @@ 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]), PetModule, UploadModule],
controllers: [PetCardsController, RepresentativeCardController],
providers: [PetCardService],
imports: [
TypeOrmModule.forFeature([PetCard, ReceivedCard]),
PetModule,
UploadModule,
],
controllers: [
PetCardsController,
RepresentativeCardController,
SharedPetCardsController,
ReceivedCardsController,
],
providers: [PetCardService, ReceivedCardService],
})
export class PetCardModule {}
@@ -15,6 +15,7 @@ import {
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,
@@ -23,6 +24,7 @@ import {
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;
@@ -132,6 +134,24 @@ export class PetCardService implements OnModuleInit, PetLifecycleListener {
});
}
/**
* 공유 모양 정하기 — 공유 시트를 열 때 앱이 보낸다. 받는 사람에게는 이 모양의 정보만 간다.
* 정한 때도 남긴다 — 상세히는 그때부터 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);
@@ -158,16 +178,11 @@ export class PetCardService implements OnModuleInit, PetLifecycleListener {
dto: CreatePetCardDto,
): Promise<PetCardResponseDto> {
return this.dataSource.transaction(async (manager) => {
// 동시에 만들어 한도를 넘지 않도록 회원의 명함을 잠그고 센다
const existing = await manager.find(PetCard, {
where: { ownerId },
select: { id: true },
lock: { mode: 'pessimistic_write' },
});
if (existing.length >= MAX_PET_CARDS) {
throw new BusinessException('CARD_001', {
context: { count: existing.length },
});
// 동시에 만들어 한도를 넘지 않도록 회원 단위로 잠그고 센다 (명함이 하나도 없을 때도 잠긴다)
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(
@@ -36,6 +36,7 @@ import {
SetRepresentativeDto,
UpdatePetCardDto,
} from './dto/pet-card.dto';
import { ShareModeDto } from './dto/received-card.dto';
import { PetCardService } from './pet-card.service';
// 펫 명함 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@@ -86,6 +87,24 @@ export class PetCardsController {
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 — 본문이 없다
@@ -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);
}
@@ -86,6 +86,7 @@ export class Pet {
@JoinColumn({ name: 'photo_file_id' })
photoFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
photoFileId!: string | null;
@@ -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;
}
+27
View File
@@ -19,6 +19,7 @@ import {
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';
// 반려동물 — 등록·수정·삭제·순서·추억 보관함
@@ -233,6 +234,32 @@ export class PetService {
}
}
/**
* 대상 표시에 쓸 아이 (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 });
@@ -0,0 +1,51 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import type { FileAccessPolicy } from '@/modules/upload/file-access-policy';
import { UploadService } from '@/modules/upload/upload.service';
/**
* 일정을 연동한 사람의 사진 — 같은 묶음에 든 사람의 지금 프로필 사진과, 그 사람 아이들의 사진.
* (연동된 일정의 주인·대상 표시에 쓴다)
*/
const GROUP_MEMBER_CAN_READ_FILE = `
SELECT EXISTS (
SELECT 1
FROM calendar_group_members me
JOIN calendar_group_members other ON other.group_id = me.group_id
JOIN files f ON f.id = $2 AND f.owner_id = other.user_id
WHERE me.user_id = $1
AND (
EXISTS (
SELECT 1 FROM users u
WHERE u.id = other.user_id AND u.photo_file_id = f.id
)
OR EXISTS (
SELECT 1 FROM pets p
WHERE p.owner_id = other.user_id AND p.photo_file_id = f.id
)
)
) AS allowed`;
// 일정 연동의 사진 내려받기 규칙 — UploadService 에 등록한다 (file-access-policy.ts)
@Injectable()
export class CalendarFileAccess implements FileAccessPolicy, OnModuleInit {
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly uploads: UploadService,
) {}
onModuleInit(): void {
this.uploads.registerAccessPolicy(this);
}
async canRead(userId: string, fileId: string): Promise<boolean> {
const rows = await this.dataSource.query<{ allowed: boolean }[]>(
GROUP_MEMBER_CAN_READ_FILE,
[userId, fileId],
);
return rows[0]?.allowed === true;
}
}
@@ -0,0 +1,122 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { InviteCodeLookup } from '@/common/decorators/invite-code-lookup.decorator';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { CalendarSharingService } from './calendar-sharing.service';
import {
CalendarInvitationDto,
CalendarSharingDto,
JoinCalendarDto,
} from './dto/calendar-sharing.dto';
// 일정 연동 — 내 연동 상태와 연동 해제. 라우팅과 입출력만 담당한다
@ApiTags('Schedule')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller()
export class CalendarSharingController {
constructor(private readonly sharing: CalendarSharingService) {}
@Get('calendar-sharing')
@ApiOperation({
summary: '내 일정 연동 상태 (초대 코드·함께 보는 사람)',
description: '초대 코드가 아직 없으면 이때 만든다',
})
@ApiStandardResponse(CalendarSharingDto, { description: '연동 상태' })
get(@CurrentUser() user: AuthUser): Promise<CalendarSharingDto> {
return this.sharing.getSharing(user.userId);
}
@Delete('calendar-membership')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '연동 해제 (나만 빠진다)',
description:
'내 일정은 그대로 남고, 다른 사람의 일정은 더 이상 보이지 않는다. 연동 중이 아니어도 204',
})
@ApiNoContentResponse({ description: '해제 완료' })
async leave(@CurrentUser() user: AuthUser): Promise<void> {
await this.sharing.leave(user.userId);
}
}
// 일정 연동 초대 — 코드를 받은 회원이 연동하기 전에 미리 본다
@ApiTags('Schedule')
@ApiBearerAuth()
@InviteCodeLookup()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('calendar-invitations')
export class CalendarInvitationsController {
constructor(private readonly sharing: CalendarSharingService) {}
@Get(':code')
@ApiOperation({
summary: '초대 코드로 상대 미리보기 (연동 전)',
description: '코드는 대소문자를 가리지 않는다',
})
@ApiParam({ name: 'code', example: 'RUP-7KQ2MX' })
@ApiStandardResponse(CalendarInvitationDto, { description: '코드 주인' })
@ApiErrorResponse('SCHED_001', '코드에 맞는 회원이 없음')
preview(
@CurrentUser() user: AuthUser,
@Param('code') code: string,
): Promise<CalendarInvitationDto> {
return this.sharing.previewInvitation(user.userId, code);
}
}
// 일정 연동하기
@ApiTags('Schedule')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('calendar-memberships')
export class CalendarMembershipsController {
constructor(private readonly sharing: CalendarSharingService) {}
@Post()
@InviteCodeLookup()
@ApiOperation({
summary: '초대 코드로 일정 연동',
description:
'확인 순서: SCHED_001(없는 코드) → SCHED_003(내 코드) → SCHED_005(이미 연동된 사람) → SCHED_004(다른 사람과 연동 중) → SCHED_002(상대 쪽이 가득 참). 상대가 아직 아무와도 연동하지 않았으면 둘이 새로 묶인다',
})
@ApiStandardResponse(CalendarSharingDto, {
status: 201,
description: '연동한 뒤의 내 연동 상태',
})
@ApiErrorResponse('SCHED_001', '코드에 맞는 회원이 없음')
@ApiErrorResponse('SCHED_003', '내 초대 코드')
@ApiErrorResponse('SCHED_005', '이미 연동된 사람')
@ApiErrorResponse('SCHED_004', '이미 다른 사람과 연동 중')
@ApiErrorResponse('SCHED_002', '이미 4명이 연동됨')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
join(
@CurrentUser() user: AuthUser,
@Body() dto: JoinCalendarDto,
): Promise<CalendarSharingDto> {
return this.sharing.join(user.userId, dto);
}
}
@@ -0,0 +1,228 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { CALENDAR_INVITE_CODE } from '@/modules/user/calendar-invite-code';
import { User } from '@/modules/user/entities/user.entity';
import { retryOnUniqueViolation } from '@/shared/utils/db-error';
import { LOCK_SCOPE, lockFor } from '@/shared/utils/db-lock';
import {
CalendarInvitationDto,
CalendarSharingDto,
JoinCalendarDto,
} from './dto/calendar-sharing.dto';
import {
CALENDAR_GROUP_MAX_MEMBERS,
CalendarGroup,
CalendarGroupMember,
} from './entities/calendar-group.entity';
/** 초대 코드가 겹쳤을 때 다시 뽑는 횟수 — 32^6 가지라 실제로 겹치는 일은 드물다 */
const CODE_RETRY = 5;
/** 연동 변경은 전체를 한 줄로 세운다 — 참여·해제는 드물어 잠금 하나로 충분하다 */
const SHARING_LOCK_KEY = 'all';
/**
* 일정 연동 — 최대 4명이 한 달력을 함께 본다. 사람마다 초대 코드가 하나 있고(가입할 때 만든다),
* 남의 코드를 입력하면 그 사람의 묶음에 들어간다(없으면 둘이 새 묶음). 해제하면 나만 빠진다.
*
* 초대 코드는 한 번 쓰면 바뀐다 — 연동하면 코드 주인의 코드를, 해제하면 나간 사람의 코드를 새로 뽑는다.
* 예전에 받은 코드로 언제든 다시 들어오지 못하게 한다 (코드만 알면 승인 없이 달력 전체를 보고 고친다).
*
* 회원 탈퇴는 FK(CASCADE)로 묶음에서 빠지므로 한 명만 남은 묶음이 생길 수 있다 —
* 읽을 때는 연동되지 않은 것으로 보고, 지우는 것은 참여·해제할 때 한다 (GET 은 아무것도 쓰지 않는다).
*/
@Injectable()
export class CalendarSharingService {
private readonly logger = new Logger(CalendarSharingService.name);
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/** 내 일정을 함께 보는 사람 (나 포함) — 연동되지 않았으면 나만. 일정을 읽고 고치는 범위다 */
async memberIds(userId: string): Promise<string[]> {
const group = await this.groupOf(this.dataSource.manager, userId);
return group.length >= 2 ? group.map((m) => m.userId) : [userId];
}
/** 내 연동 상태 — 읽기만 한다 */
async getSharing(userId: string): Promise<CalendarSharingDto> {
const me = await this.users.findOneBy({ id: userId });
if (!me) throw new BusinessException('RES_001', { context: { userId } });
const group = await this.groupOf(this.dataSource.manager, userId, true);
return CalendarSharingDto.from(userId, me, me.calendarInviteCode, group);
}
/** 초대 코드로 미리 보기 — 연동하기 전. 없는 코드는 SCHED_001 */
async previewInvitation(
userId: string,
rawCode: string,
): Promise<CalendarInvitationDto> {
const owner = await this.findCodeOwner(this.dataSource.manager, rawCode);
const isMine = owner.id === userId;
const theirs = await this.groupOf(this.dataSource.manager, owner.id);
const linked = theirs.length >= 2;
return {
ownerNickname: owner.nickname,
memberCount: linked ? theirs.length : 1,
maxMembers: CALENDAR_GROUP_MAX_MEMBERS,
isMine,
isMember: !isMine && linked && theirs.some((m) => m.userId === userId),
};
}
/**
* 초대 코드로 연동 — 이 순서로 확인한다:
* 없는 코드(SCHED_001) → 내 코드(SCHED_003) → 이미 같은 묶음(SCHED_005) →
* 내가 다른 사람과 연동 중(SCHED_004) → 상대 묶음이 가득 참(SCHED_002).
* 상대가 아직 아무와도 연동하지 않았으면 둘이 새 묶음을 만든다. 연동하면 코드 주인의 코드를 바꾼다.
*/
async join(
userId: string,
dto: JoinCalendarDto,
): Promise<CalendarSharingDto> {
const ownerId = await retryOnUniqueViolation(
() =>
this.dataSource.transaction(async (manager) => {
await lockFor(manager, LOCK_SCOPE.calendarSharing, SHARING_LOCK_KEY);
// 코드는 잠근 뒤에 찾는다 — 먼저 들어온 사람이 이 코드를 쓰고 바꿨다면 SCHED_001 이 된다
const owner = await this.findCodeOwner(manager, dto.code);
if (owner.id === userId) {
throw new BusinessException('SCHED_003', { context: { userId } });
}
const mine = await this.linkedGroupOf(manager, userId);
const theirs = await this.linkedGroupOf(manager, owner.id);
const myGroupId = mine.length > 0 ? mine[0].groupId : null;
const groupId = theirs.length > 0 ? theirs[0].groupId : null;
if (groupId !== null && myGroupId === groupId) {
throw new BusinessException('SCHED_005', { context: { groupId } });
}
if (myGroupId !== null) {
throw new BusinessException('SCHED_004', {
context: { groupId: myGroupId },
});
}
if (theirs.length >= CALENDAR_GROUP_MAX_MEMBERS) {
throw new BusinessException('SCHED_002', {
context: { groupId, members: theirs.length },
});
}
if (groupId !== null) {
await manager.insert(CalendarGroupMember, { groupId, userId });
} else {
const group = await manager.save(manager.create(CalendarGroup, {}));
await manager.insert(CalendarGroupMember, {
groupId: group.id,
userId: owner.id,
});
// 코드 주인이 먼저 든 것으로 둔다 — 기본값 now() 는 트랜잭션 시작 시각이라 둘이 같아진다
await manager.insert(CalendarGroupMember, {
groupId: group.id,
userId,
joinedAt: () => 'clock_timestamp()',
});
}
// 쓴 코드는 바꾼다 — 겹치면(유니크 위반) 처음부터 다시 한다
await this.rotateCode(manager, owner.id);
return owner.id;
}),
CODE_RETRY,
);
this.logger.log(`일정 연동: 회원 ${userId} → 회원 ${ownerId} 의 묶음`);
return this.getSharing(userId);
}
/**
* 연동 해제 — 나만 빠진다(내 일정은 그대로). 한 명만 남으면 묶음을 지운다.
* 빠졌으면 내 코드를 바꾼다 (예전 묶음 사람들이 갖고 있던 내 코드로 다시 묶이지 않게).
* 연동 중이 아니면 아무 일도 없다.
*/
async leave(userId: string): Promise<void> {
const left = await retryOnUniqueViolation(
() =>
this.dataSource.transaction(async (manager) => {
await lockFor(manager, LOCK_SCOPE.calendarSharing, SHARING_LOCK_KEY);
const mine = await manager.findOneBy(CalendarGroupMember, { userId });
if (!mine) return false;
await manager.delete(CalendarGroupMember, { userId });
const remaining = await manager.countBy(CalendarGroupMember, {
groupId: mine.groupId,
});
if (remaining < 2) {
await manager.delete(CalendarGroup, { id: mine.groupId });
}
await this.rotateCode(manager, userId);
return true;
}),
CODE_RETRY,
);
if (left) this.logger.log(`일정 연동 해제: 회원 ${userId}`);
}
// ── 도우미 ─────────────────────────────────────────────
/** [userId] 가 든 묶음의 사람들 (연동한 순서) — 묶음이 없으면 [] */
private async groupOf(
manager: EntityManager,
userId: string,
withProfiles = false,
): Promise<CalendarGroupMember[]> {
const mine = await manager.findOneBy(CalendarGroupMember, { userId });
if (!mine) return [];
return manager.find(CalendarGroupMember, {
where: { groupId: mine.groupId },
relations: { user: withProfiles },
order: { joinedAt: 'ASC', userId: 'ASC' },
});
}
/** 연동된 묶음 (2명 이상) — 혼자 남은 묶음은 지우고 [] 를 돌려준다. 잠금 안에서만 부른다 */
private async linkedGroupOf(
manager: EntityManager,
userId: string,
): Promise<CalendarGroupMember[]> {
const group = await this.groupOf(manager, userId);
if (group.length === 1) {
await manager.delete(CalendarGroup, { id: group[0].groupId });
return [];
}
return group;
}
/** 새 초대 코드로 바꾼다 — 겹치면 유니크 위반이 나고, 호출한 쪽이 트랜잭션을 다시 한다 */
private async rotateCode(
manager: EntityManager,
userId: string,
): Promise<void> {
await manager.update(
User,
{ id: userId },
{ calendarInviteCode: CALENDAR_INVITE_CODE.random() },
);
}
/** 초대 코드의 주인 — 형식이 틀리거나 없는 코드는 SCHED_001 */
private async findCodeOwner(
manager: EntityManager,
rawCode: string,
): Promise<User> {
const code = CALENDAR_INVITE_CODE.normalize(rawCode);
const owner =
code === null
? null
: await manager.findOneBy(User, { calendarInviteCode: code });
if (!owner) {
throw new BusinessException('SCHED_001', { context: { code: rawCode } });
}
return owner;
}
}
@@ -0,0 +1,141 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import type { ProfileSource } from '@/modules/pet/pet-targets';
import { CALENDAR_GROUP_MAX_MEMBERS } from '../entities/calendar-group.entity';
/** 초대 코드 입력 길이 상한 — 형식 검사는 서비스가 한다 (틀린 코드는 SCHED_001) */
const INVITE_CODE_INPUT_MAX_LENGTH = 20;
/** 초대 코드로 연동하기 */
export class JoinCalendarDto {
@ApiProperty({
description: '상대의 초대 코드 (RUP-XXXXXX, 대소문자 구분 없음)',
example: 'RUP-7KQ2MX',
})
@IsString({ message: '초대 코드를 입력해 주세요.' })
@IsNotEmpty({ message: '초대 코드를 입력해 주세요.' })
@MaxLength(INVITE_CODE_INPUT_MAX_LENGTH, {
message: '초대 코드가 올바르지 않습니다.',
})
code!: string;
}
/** 일정을 함께 보는 사람 */
export class CalendarMemberDto {
@ApiProperty({ description: '회원 ID' }) userId!: string;
@ApiProperty({
description: '닉네임 — 모르면 null',
nullable: true,
type: String,
})
nickname!: string | null;
@ApiProperty({
description: '프로필 사진 파일 ID — GET /api/files/{id}',
nullable: true,
type: String,
})
photoFileId!: string | null;
@ApiProperty({ description: '나인지' }) isMe!: boolean;
}
/** 묶음에 든 사람 한 줄 (calendar_group_members + users) */
export interface CalendarMemberSource {
readonly userId: string;
readonly joinedAt: Date;
readonly user?: ProfileSource | null;
}
/** 내 일정 연동 상태 */
export class CalendarSharingDto {
@ApiProperty({
description: '내 초대 코드 — 상대가 이 코드를 입력하면 연동된다',
example: 'RUP-7KQ2MX',
})
inviteCode!: string;
@ApiProperty({
description: '다른 사람과 연동되어 있는지 (나 포함 2명 이상)',
})
linked!: boolean;
@ApiProperty({
description: '함께 볼 수 있는 최대 인원 (나 포함)',
example: CALENDAR_GROUP_MAX_MEMBERS,
})
maxMembers!: number;
@ApiProperty({
description:
'함께 보는 사람 — 내가 맨 앞, 나머지는 연동한 순서. 연동되지 않았으면 나만',
type: [CalendarMemberDto],
})
members!: CalendarMemberDto[];
/**
* [group] 은 내 묶음의 사람들 (회원 정보 포함). 두 명 미만이면 연동되지 않은 것으로 본다.
*/
static from(
meId: string,
me: ProfileSource,
inviteCode: string,
group: readonly CalendarMemberSource[],
): CalendarSharingDto {
const linked = group.length >= 2;
const others = linked
? group
.filter((m) => m.userId !== meId)
.sort(
(a, b) =>
a.joinedAt.getTime() - b.joinedAt.getTime() ||
a.userId.localeCompare(b.userId),
)
: [];
return {
inviteCode,
linked,
maxMembers: CALENDAR_GROUP_MAX_MEMBERS,
members: [
{
userId: meId,
nickname: me.nickname,
photoFileId: me.photoFileId,
isMe: true,
},
...others.map((m) => ({
userId: m.userId,
nickname: m.user?.nickname ?? null,
photoFileId: m.user?.photoFileId ?? null,
isMe: false,
})),
],
};
}
}
/** 초대 코드로 본 상대 — 연동하기 전에 보여 준다 */
export class CalendarInvitationDto {
@ApiProperty({
description: '코드 주인의 닉네임 — 모르면 null',
nullable: true,
type: String,
})
ownerNickname!: string | null;
@ApiProperty({
description:
'코드 주인과 지금 함께 보는 사람 수 (주인 포함, 연동 전이면 1)',
})
memberCount!: number;
@ApiProperty({ description: '함께 볼 수 있는 최대 인원 (나 포함)' })
maxMembers!: number;
@ApiProperty({ description: '내 코드인지' }) isMine!: boolean;
@ApiProperty({ description: '코드 주인과 이미 연동되어 있는지' })
isMember!: boolean;
}
@@ -39,6 +39,8 @@ import type {
RepeatUnit,
} from '../entities/repeat-rule.entity';
import type { ScheduleEvent } from '../entities/schedule-event.entity';
import { toOwnership } from '../schedule-view';
import type { ScheduleViewContext } from '../schedule-view';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
@@ -211,6 +213,51 @@ export class EventDetailsResponseDto {
}
}
/** 일정 주인 — 연동된 사람의 일정도 보이므로 누구의 일정인지 함께 보낸다 */
export class ScheduleOwnerDto {
@ApiProperty({ description: '회원 ID' }) userId!: string;
@ApiProperty({
description: '닉네임 — 모르면 null',
nullable: true,
type: String,
})
nickname!: string | null;
@ApiProperty({
description: '프로필 사진 파일 ID — GET /api/files/{id}',
nullable: true,
type: String,
})
photoFileId!: string | null;
}
/** 일정 대상 — 일정 주인의 아이 또는 주인 자신(self) */
export class ScheduleTargetDto {
@ApiProperty({ description: '아이 ID 또는 "self"' }) id!: string;
@ApiProperty({
description: '아이 이름 · self 면 일정 주인의 닉네임',
nullable: true,
type: String,
})
name!: string | null;
@ApiProperty({
description: '아이 사진 · self 면 일정 주인의 프로필 사진 (파일 ID)',
nullable: true,
type: String,
})
photoFileId!: string | null;
@ApiProperty({
description: '아이의 고유색 (#RRGGBB) · self 면 null',
nullable: true,
type: String,
})
tint!: string | null;
}
/** 날짜 일정 응답 */
export class ScheduleEventResponseDto extends EventDetailsResponseDto {
@ApiProperty() id!: string;
@@ -221,12 +268,28 @@ export class ScheduleEventResponseDto extends EventDetailsResponseDto {
@ApiProperty({ description: '완료 표시' })
isDone!: boolean;
static fromEvent(event: ScheduleEvent): ScheduleEventResponseDto {
@ApiProperty({ description: '일정 주인 (회원 ID)' })
ownerId!: string;
@ApiProperty({ description: '일정 주인', type: ScheduleOwnerDto })
owner!: ScheduleOwnerDto;
@ApiProperty({
description: '대상 — targetIds 순서. 더 이상 없는 대상은 빠진다',
type: [ScheduleTargetDto],
})
targets!: ScheduleTargetDto[];
static fromEvent(
event: ScheduleEvent,
context: ScheduleViewContext,
): ScheduleEventResponseDto {
return {
id: event.id,
date: event.date,
isDone: event.isDone,
...EventDetailsResponseDto.from(event.details),
...toOwnership(event.ownerId, event.details.targetIds, context),
};
}
}
@@ -244,12 +307,28 @@ export class RepeatRuleResponseDto {
@ApiProperty({ type: EventDetailsResponseDto })
details!: EventDetailsResponseDto;
static from(rule: RepeatRule): RepeatRuleResponseDto {
@ApiProperty({ description: '반복 일정 주인 (회원 ID)' })
ownerId!: string;
@ApiProperty({ description: '반복 일정 주인', type: ScheduleOwnerDto })
owner!: ScheduleOwnerDto;
@ApiProperty({
description: '대상 — details.targetIds 순서. 더 이상 없는 대상은 빠진다',
type: [ScheduleTargetDto],
})
targets!: ScheduleTargetDto[];
static from(
rule: RepeatRule,
context: ScheduleViewContext,
): RepeatRuleResponseDto {
return {
id: rule.id,
from: rule.fromDate,
spec: { mode: rule.mode, unit: rule.unit, n: rule.n },
details: EventDetailsResponseDto.from(rule.details),
...toOwnership(rule.ownerId, rule.details.targetIds, context),
};
}
}
@@ -0,0 +1,55 @@
import {
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryColumn,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { User } from '@/modules/user/entities/user.entity';
/** 일정을 함께 보는 사람 수 (나 포함) */
export const CALENDAR_GROUP_MAX_MEMBERS = 4;
/**
* 일정 연동 묶음 — 묶인 사람들은 서로의 일정·반복 일정·완료 표시를 보고 고친다.
* 두 명 이상일 때만 "연동됨"이다. 한 명만 남으면 묶음을 지운다.
*/
@Entity('calendar_groups')
export class CalendarGroup {
@PrimaryGeneratedColumn('uuid')
id!: string;
@OneToMany(() => CalendarGroupMember, (member) => member.group)
members?: CalendarGroupMember[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
/** 묶음에 든 사람 — 한 사람은 한 묶음에만 든다 (UNIQUE user_id) */
@Entity('calendar_group_members')
@Unique(['userId'])
export class CalendarGroupMember {
@PrimaryColumn({ type: 'uuid' })
groupId!: string;
@PrimaryColumn({ type: 'uuid' })
userId!: string;
@ManyToOne(() => CalendarGroup, (group) => group.members, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'group_id' })
group?: CalendarGroup;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: User;
@CreateDateColumn({ type: 'timestamptz' })
joinedAt!: Date;
}
@@ -60,7 +60,11 @@ export class RepeatRulesController {
constructor(private readonly schedule: ScheduleService) {}
@Get()
@ApiOperation({ summary: '반복 일정 목록 (만든 순, 커서 페이지네이션)' })
@ApiOperation({
summary: '반복 일정 목록 (만든 순, 커서 페이지네이션)',
description:
'일정을 연동했으면 함께 보는 사람들의 반복 일정도 온다 (ownerId·owner 로 구분)',
})
@ApiPaginatedResponse(RepeatRuleResponseDto, '반복 일정 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@@ -85,10 +89,17 @@ export class RepeatRulesController {
}
@Put(':id')
@ApiOperation({ summary: '반복 주기·내용 바꾸기 (시작한 날은 그대로)' })
@ApiOperation({
summary: '반복 주기·내용 바꾸기 (시작한 날은 그대로)',
description:
'연동한 사람의 반복 일정도 바꿀 수 있다 — 대상은 그 일정 주인의 아이만',
})
@ApiStandardResponse(RepeatRuleResponseDto, { description: '바꾼 반복 일정' })
@ApiErrorResponse('RES_001', '반복 일정을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
@ApiErrorResponse(
'VAL_001',
'입력값 검증 실패 · 일정 주인의 아이가 아닌 대상',
)
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@@ -162,7 +173,11 @@ export class RepeatRuleCompletionsController {
constructor(private readonly schedule: ScheduleService) {}
@Get()
@ApiOperation({ summary: '반복 일정 완료 표시 목록 (커서 페이지네이션)' })
@ApiOperation({
summary: '반복 일정 완료 표시 목록 (커서 페이지네이션)',
description:
'일정을 연동했으면 함께 보는 사람들의 반복 일정 완료 표시도 온다',
})
@ApiPaginatedResponse(RuleCompletionResponseDto, '완료 표시 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@@ -46,7 +46,11 @@ export class ScheduleEventsController {
constructor(private readonly schedule: ScheduleService) {}
@Get()
@ApiOperation({ summary: '날짜 일정 목록 (날짜순, 커서 페이지네이션)' })
@ApiOperation({
summary: '날짜 일정 목록 (날짜순, 커서 페이지네이션)',
description:
'일정을 연동했으면 함께 보는 사람들의 일정도 온다 (ownerId·owner 로 구분)',
})
@ApiPaginatedResponse(ScheduleEventResponseDto, '날짜 일정 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@@ -76,10 +80,17 @@ export class ScheduleEventsController {
}
@Put(':id')
@ApiOperation({ summary: '일정 내용 바꾸기 (날짜·완료 표시는 그대로)' })
@ApiOperation({
summary: '일정 내용 바꾸기 (날짜·완료 표시는 그대로)',
description:
'연동한 사람의 일정도 바꿀 수 있다 — 대상은 그 일정 주인의 아이만',
})
@ApiStandardResponse(ScheduleEventResponseDto, { description: '바꾼 일정' })
@ApiErrorResponse('RES_001', '일정을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
@ApiErrorResponse(
'VAL_001',
'입력값 검증 실패 · 일정 주인의 아이가 아닌 대상',
)
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@@ -91,7 +102,7 @@ export class ScheduleEventsController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '일정 삭제' })
@ApiOperation({ summary: '일정 삭제 (연동한 사람의 일정 포함)' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '일정을 찾을 수 없음')
async remove(
@@ -104,7 +115,7 @@ export class ScheduleEventsController {
@Put(':id/completion')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '완료 표시' })
@ApiOperation({ summary: '완료 표시 (연동한 사람의 일정 포함)' })
@ApiNoContentResponse({ description: '완료로 표시함' })
@ApiErrorResponse('RES_001', '일정을 찾을 수 없음')
async complete(
@@ -0,0 +1,96 @@
import { CalendarSharingDto } from './dto/calendar-sharing.dto';
import { toOwnership } from './schedule-view';
describe('일정의 주인·대상', () => {
const context = {
owners: new Map([
['partner', { nickname: '짝꿍', photoFileId: 'partner-photo' }],
]),
pets: new Map([
[
'pet-1',
{
id: 'pet-1',
ownerId: 'partner',
name: '콩이',
photoFileId: 'pet-photo',
tint: '#FF9B6A',
archivedAt: null,
},
],
]),
};
it('대상은 일정 주인의 아이와 주인 자신(self)으로 푼다 (보관 여부는 싣지 않는다)', () => {
expect(toOwnership('partner', ['pet-1', 'self'], context)).toEqual({
ownerId: 'partner',
owner: {
userId: 'partner',
nickname: '짝꿍',
photoFileId: 'partner-photo',
},
targets: [
{
id: 'pet-1',
name: '콩이',
photoFileId: 'pet-photo',
tint: '#FF9B6A',
},
{
id: 'self',
name: '짝꿍',
photoFileId: 'partner-photo',
tint: null,
},
],
});
});
it('다른 사람의 아이는 대상으로 풀지 않는다', () => {
expect(toOwnership('me', ['pet-1'], context).targets).toEqual([]);
});
it('주인 정보를 모르면 닉네임·사진은 null 이고 self 는 빠진다', () => {
const view = toOwnership('gone', ['self'], context);
expect(view.owner).toEqual({
userId: 'gone',
nickname: null,
photoFileId: null,
});
expect(view.targets).toEqual([]);
});
});
describe('일정 연동 상태', () => {
const me = { nickname: '나', photoFileId: null };
const member = (userId: string, joinedAt: string) => ({
userId,
joinedAt: new Date(joinedAt),
user: { nickname: userId, photoFileId: null },
});
it('내가 맨 앞, 나머지는 연동한 순서 (같은 때면 회원 id 순)', () => {
const dto = CalendarSharingDto.from('me', me, 'RUP-7KQ2MX', [
member('c', '2026-10-03T00:00:00Z'),
member('me', '2026-10-01T00:00:00Z'),
member('b', '2026-10-02T00:00:00Z'),
member('a', '2026-10-02T00:00:00Z'),
]);
expect(dto.linked).toBe(true);
expect(dto.maxMembers).toBe(4);
expect(dto.members.map((m) => m.userId)).toEqual(['me', 'a', 'b', 'c']);
expect(dto.members.map((m) => m.isMe)).toEqual([true, false, false, false]);
});
it('혼자 남은 묶음은 연동되지 않은 것으로 보고 나만 보여 준다', () => {
const dto = CalendarSharingDto.from('me', me, 'RUP-7KQ2MX', [
member('me', '2026-10-01T00:00:00Z'),
]);
expect(dto.linked).toBe(false);
expect(dto.members).toEqual([
{ userId: 'me', nickname: '나', photoFileId: null, isMe: true },
]);
});
});
@@ -0,0 +1,55 @@
// 일정 응답에 붙이는 주인·대상 — 부수효과 없는 순수 함수.
// 연동된 사람에게는 남의 일정도 보이므로 누구의 일정인지, 대상이 누구인지를 이름·사진과 함께 보낸다.
import { resolveTargets } from '@/modules/pet/pet-targets';
import type { ProfileSource, TargetPetSource } from '@/modules/pet/pet-targets';
/** 응답을 만들 때 찾아 볼 주인·대상 아이 (한 번에 읽어 둔다) */
export interface ScheduleViewContext {
/** 일정 주인 (회원 id → 보이는 정보) */
readonly owners: ReadonlyMap<string, ProfileSource>;
/** 대상 아이 (id → 아이) */
readonly pets: ReadonlyMap<string, TargetPetSource>;
}
export interface ScheduleOwnerView {
readonly userId: string;
readonly nickname: string | null;
readonly photoFileId: string | null;
}
export interface ScheduleTargetView {
readonly id: string;
readonly name: string | null;
readonly photoFileId: string | null;
readonly tint: string | null;
}
export interface ScheduleOwnership {
readonly ownerId: string;
readonly owner: ScheduleOwnerView;
readonly targets: ScheduleTargetView[];
}
/**
* 일정의 주인과 대상 — 대상 아이는 일정 주인의 아이로, "self" 는 일정 주인으로 푼다.
* 더 이상 찾을 수 없는 대상은 뺀다.
*/
export function toOwnership(
ownerId: string,
targetIds: readonly string[],
context: ScheduleViewContext,
): ScheduleOwnership {
const profile = context.owners.get(ownerId) ?? null;
return {
ownerId,
owner: {
userId: ownerId,
nickname: profile?.nickname ?? null,
photoFileId: profile?.photoFileId ?? null,
},
targets: resolveTargets(targetIds, ownerId, context.pets, profile).map(
({ id, name, photoFileId, tint }) => ({ id, name, photoFileId, tint }),
),
};
}
@@ -2,6 +2,19 @@ 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 { CalendarFileAccess } from './calendar-file-access';
import {
CalendarInvitationsController,
CalendarMembershipsController,
CalendarSharingController,
} from './calendar-sharing.controller';
import { CalendarSharingService } from './calendar-sharing.service';
import {
CalendarGroup,
CalendarGroupMember,
} from './entities/calendar-group.entity';
import {
RepeatRule,
RepeatRuleCompletion,
@@ -16,14 +29,25 @@ import { ScheduleService } from './schedule.service';
@Module({
imports: [
TypeOrmModule.forFeature([ScheduleEvent, RepeatRule, RepeatRuleCompletion]),
TypeOrmModule.forFeature([
ScheduleEvent,
RepeatRule,
RepeatRuleCompletion,
CalendarGroup,
CalendarGroupMember,
User,
]),
PetModule,
UploadModule,
],
controllers: [
ScheduleEventsController,
RepeatRulesController,
RepeatRuleCompletionsController,
CalendarSharingController,
CalendarInvitationsController,
CalendarMembershipsController,
],
providers: [ScheduleService],
providers: [ScheduleService, CalendarSharingService, CalendarFileAccess],
})
export class ScheduleModule {}
@@ -1,6 +1,8 @@
import { EntityManager, Repository } from 'typeorm';
import { EntityManager, In, Repository } from 'typeorm';
import { PetService } from '@/modules/pet/pet.service';
import { User } from '@/modules/user/entities/user.entity';
import { CalendarSharingService } from './calendar-sharing.service';
import { EventDetailsDto, toDetailsColumns } from './dto/schedule.dto';
import {
RepeatRule,
@@ -48,18 +50,35 @@ describe('toDetailsColumns', () => {
});
describe('ScheduleService', () => {
const events = { findOneBy: jest.fn(), delete: jest.fn(), update: jest.fn() };
const events = {
findOneBy: jest.fn(),
delete: jest.fn(),
update: jest.fn(),
save: jest.fn(),
};
const rules = { findOneBy: jest.fn(), delete: jest.fn() };
const completions = { delete: jest.fn() };
const pets = { registerListener: jest.fn(), assertOwnedTargets: jest.fn() };
const pets = {
registerListener: jest.fn(),
assertOwnedTargets: jest.fn(),
findTargetPets: jest.fn(),
};
const users = { find: jest.fn() };
const sharing = { memberIds: jest.fn() };
const service = new ScheduleService(
events as unknown as Repository<ScheduleEvent>,
rules as unknown as Repository<RepeatRule>,
completions as unknown as Repository<RepeatRuleCompletion>,
users as unknown as Repository<User>,
pets as unknown as PetService,
sharing as unknown as CalendarSharingService,
);
beforeEach(() => jest.clearAllMocks());
beforeEach(() => {
jest.clearAllMocks();
// 연동하지 않은 회원 — 자기 일정만 본다
sharing.memberIds.mockResolvedValue(['o']);
});
it('시작할 때 아이 삭제·보관 리스너로 등록한다', () => {
service.onModuleInit();
@@ -102,4 +121,57 @@ describe('ScheduleService', () => {
).rejects.toMatchObject({ code: 'RES_001' });
expect(completions.delete).not.toHaveBeenCalled();
});
describe('연동한 사람의 일정', () => {
beforeEach(() => {
sharing.memberIds.mockResolvedValue(['o', 'partner']);
});
it('지우기·완료 표시는 연동한 사람들의 일정 안에서 찾는다', async () => {
events.delete.mockResolvedValue({ affected: 1 });
events.update.mockResolvedValue({ affected: 1 });
await service.deleteEvent('o', 'e');
await service.setEventDone('o', 'e', true);
expect(events.delete).toHaveBeenCalledWith({
id: 'e',
ownerId: In(['o', 'partner']),
});
expect(events.update).toHaveBeenCalledWith(
{ id: 'e', ownerId: In(['o', 'partner']) },
{ isDone: true },
);
});
it('남의 일정을 고칠 때 대상은 그 일정 주인의 아이로 확인한다', async () => {
const event = {
id: 'e',
ownerId: 'partner',
date: '2026-10-11',
isDone: false,
details: toDetailsColumns(details()),
} as ScheduleEvent;
events.findOneBy.mockResolvedValue(event);
events.save.mockImplementation((e: ScheduleEvent) => Promise.resolve(e));
users.find.mockResolvedValue([
{ id: 'partner', nickname: '짝꿍', photoFileId: null },
]);
pets.findTargetPets.mockResolvedValue(new Map());
const updated = await service.updateEvent(
'o',
'e',
details({ title: '같이 산책' }),
);
expect(pets.assertOwnedTargets).toHaveBeenCalledWith('partner', ['self']);
expect(updated.title).toBe('같이 산책');
expect(updated.ownerId).toBe('partner');
// self 는 일정 주인이다
expect(updated.targets).toEqual([
{ id: 'self', name: '짝꿍', photoFileId: null, tint: null },
]);
});
});
});
+113 -43
View File
@@ -1,6 +1,6 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { EntityManager, In, Repository } from 'typeorm';
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
import {
@@ -13,6 +13,8 @@ import {
PetLifecycleListener,
} from '@/modules/pet/pet-lifecycle';
import { PetService } from '@/modules/pet/pet.service';
import { User } from '@/modules/user/entities/user.entity';
import { CalendarSharingService } from './calendar-sharing.service';
import {
CreateEventsDto,
CreateRuleDto,
@@ -27,12 +29,24 @@ import {
RepeatRule,
RepeatRuleCompletion,
} from './entities/repeat-rule.entity';
import type { ScheduleDetails } from './entities/schedule-details';
import { ScheduleEvent } from './entities/schedule-event.entity';
import type { ScheduleViewContext } from './schedule-view';
/** 대상(target_ids)을 가진 일정 테이블 */
const TARGET_TABLES = ['schedule_events', 'repeat_rules'] as const;
// 일정 — 날짜 일정·반복 규칙·완료 표시. 어느 날에 무엇이 놓이는지는 앱이 계산한다.
/** 주인과 대상을 가진 일정 (날짜 일정·반복 규칙) */
interface OwnedSchedule {
readonly ownerId: string;
readonly details: ScheduleDetails;
}
/**
* 일정 — 날짜 일정·반복 규칙·완료 표시. 어느 날에 무엇이 놓이는지는 앱이 계산한다.
* 일정을 연동한 사람끼리는 서로의 일정을 보고 고친다 (CalendarSharingService) —
* 그 밖의 사람의 일정은 없는 것과 똑같이 RES_001. 새 일정은 언제나 만든 사람의 것이다.
*/
@Injectable()
export class ScheduleService implements OnModuleInit, PetLifecycleListener {
constructor(
@@ -42,7 +56,10 @@ export class ScheduleService implements OnModuleInit, PetLifecycleListener {
private readonly rules: Repository<RepeatRule>,
@InjectRepository(RepeatRuleCompletion)
private readonly completions: Repository<RepeatRuleCompletion>,
@InjectRepository(User)
private readonly users: Repository<User>,
private readonly pets: PetService,
private readonly sharing: CalendarSharingService,
) {}
onModuleInit(): void {
@@ -50,13 +67,15 @@ export class ScheduleService implements OnModuleInit, PetLifecycleListener {
}
// ── 날짜 일정 ───────────────────────────────────────────
/** 나와 연동한 사람들의 날짜 일정 */
async listEvents(
ownerId: string,
userId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ScheduleEventResponseDto>> {
const ownerIds = await this.sharing.memberIds(userId);
const qb = this.events
.createQueryBuilder('event')
.where('event.owner_id = :ownerId', { ownerId });
.where('event.owner_id IN (:...ownerIds)', { ownerIds });
const page = await keysetPaginate(
qb,
[
@@ -65,57 +84,68 @@ export class ScheduleService implements OnModuleInit, PetLifecycleListener {
],
query,
);
return mapPage(page, (e) => ScheduleEventResponseDto.fromEvent(e));
const context = await this.viewContext(page.items);
return mapPage(page, (e) => ScheduleEventResponseDto.fromEvent(e, context));
}
/** 여러 건을 한 번에 만든다 (기간 설정) — 하나라도 틀리면 아무것도 만들지 않는다 */
/** 여러 건을 한 번에 만든다 (기간 설정) — 하나라도 틀리면 아무것도 만들지 않는다. 대상은 내 아이만 */
async createEvents(
ownerId: string,
userId: string,
dto: CreateEventsDto,
): Promise<ScheduleEventResponseDto[]> {
await this.pets.assertOwnedTargets(
ownerId,
userId,
dto.events.flatMap((e) => e.targetIds),
);
const saved = await this.events.save(
dto.events.map((e) =>
this.events.create({
ownerId,
ownerId: userId,
date: e.date,
details: toDetailsColumns(e),
isDone: false,
}),
),
);
return saved.map((e) => ScheduleEventResponseDto.fromEvent(e));
const context = await this.viewContext(saved);
return saved.map((e) => ScheduleEventResponseDto.fromEvent(e, context));
}
/** 일정 내용 바꾸기 — 날짜와 완료 표시는 그대로 둔다 */
/** 일정 내용 바꾸기 — 날짜와 완료 표시는 그대로 둔다. 대상은 그 일정 주인의 아이만 */
async updateEvent(
ownerId: string,
userId: string,
eventId: string,
dto: EventDetailsDto,
): Promise<ScheduleEventResponseDto> {
const event = await this.findOwnedEvent(ownerId, eventId);
await this.pets.assertOwnedTargets(ownerId, dto.targetIds);
const event = await this.findSharedEvent(userId, eventId);
await this.pets.assertOwnedTargets(event.ownerId, dto.targetIds);
event.details = toDetailsColumns(dto);
return ScheduleEventResponseDto.fromEvent(await this.events.save(event));
const saved = await this.events.save(event);
return ScheduleEventResponseDto.fromEvent(
saved,
await this.viewContext([saved]),
);
}
async deleteEvent(ownerId: string, eventId: string): Promise<void> {
const result = await this.events.delete({ id: eventId, ownerId });
async deleteEvent(userId: string, eventId: string): Promise<void> {
const ownerIds = await this.sharing.memberIds(userId);
const result = await this.events.delete({
id: eventId,
ownerId: In(ownerIds),
});
if (!result.affected) {
throw new BusinessException('RES_001', { context: { eventId } });
}
}
async setEventDone(
ownerId: string,
userId: string,
eventId: string,
done: boolean,
): Promise<void> {
const ownerIds = await this.sharing.memberIds(userId);
const result = await this.events.update(
{ id: eventId, ownerId },
{ id: eventId, ownerId: In(ownerIds) },
{ isDone: done },
);
if (!result.affected) {
@@ -124,13 +154,15 @@ export class ScheduleService implements OnModuleInit, PetLifecycleListener {
}
// ── 반복 규칙 ───────────────────────────────────────────
/** 나와 연동한 사람들의 반복 일정 */
async listRules(
ownerId: string,
userId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<RepeatRuleResponseDto>> {
const ownerIds = await this.sharing.memberIds(userId);
const qb = this.rules
.createQueryBuilder('rule')
.where('rule.owner_id = :ownerId', { ownerId });
.where('rule.owner_id IN (:...ownerIds)', { ownerIds });
const page = await keysetPaginate(
qb,
[
@@ -143,53 +175,62 @@ export class ScheduleService implements OnModuleInit, PetLifecycleListener {
],
query,
);
return mapPage(page, (r) => RepeatRuleResponseDto.from(r));
const context = await this.viewContext(page.items);
return mapPage(page, (r) => RepeatRuleResponseDto.from(r, context));
}
/** 반복 일정 만들기 — 대상은 내 아이만 */
async createRule(
ownerId: string,
userId: string,
dto: CreateRuleDto,
): Promise<RepeatRuleResponseDto> {
await this.pets.assertOwnedTargets(ownerId, dto.details.targetIds);
await this.pets.assertOwnedTargets(userId, dto.details.targetIds);
const rule = await this.rules.save(
this.rules.create({
ownerId,
ownerId: userId,
fromDate: dto.from,
...dto.spec,
details: toDetailsColumns(dto.details),
}),
);
return RepeatRuleResponseDto.from(rule);
return RepeatRuleResponseDto.from(rule, await this.viewContext([rule]));
}
/** 주기·내용 바꾸기 — 시작한 날과 날짜별 완료 표시는 그대로 둔다 */
/** 주기·내용 바꾸기 — 시작한 날과 날짜별 완료 표시는 그대로 둔다. 대상은 그 일정 주인의 아이만 */
async updateRule(
ownerId: string,
userId: string,
ruleId: string,
dto: UpdateRuleDto,
): Promise<RepeatRuleResponseDto> {
const rule = await this.findOwnedRule(ownerId, ruleId);
await this.pets.assertOwnedTargets(ownerId, dto.details.targetIds);
const rule = await this.findSharedRule(userId, ruleId);
await this.pets.assertOwnedTargets(rule.ownerId, dto.details.targetIds);
Object.assign(rule, dto.spec, { details: toDetailsColumns(dto.details) });
return RepeatRuleResponseDto.from(await this.rules.save(rule));
const saved = await this.rules.save(rule);
return RepeatRuleResponseDto.from(saved, await this.viewContext([saved]));
}
/** 반복 일정 삭제 — 규칙 전체와 날짜별 완료 표시가 함께 지워진다 */
async deleteRule(ownerId: string, ruleId: string): Promise<void> {
const result = await this.rules.delete({ id: ruleId, ownerId });
async deleteRule(userId: string, ruleId: string): Promise<void> {
const ownerIds = await this.sharing.memberIds(userId);
const result = await this.rules.delete({
id: ruleId,
ownerId: In(ownerIds),
});
if (!result.affected) {
throw new BusinessException('RES_001', { context: { ruleId } });
}
}
/** 나와 연동한 사람들의 반복 일정 완료 표시 */
async listCompletions(
ownerId: string,
userId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<RuleCompletionResponseDto>> {
const ownerIds = await this.sharing.memberIds(userId);
const qb = this.completions
.createQueryBuilder('done')
.innerJoin('done.rule', 'rule')
.where('rule.owner_id = :ownerId', { ownerId });
.where('rule.owner_id IN (:...ownerIds)', { ownerIds });
const page = await keysetPaginate(
qb,
[
@@ -203,12 +244,12 @@ export class ScheduleService implements OnModuleInit, PetLifecycleListener {
/** 반복 일정의 그날 완료 표시 — 이미 같은 값이면 그대로 둔다 */
async setRuleDone(
ownerId: string,
userId: string,
ruleId: string,
date: string,
done: boolean,
): Promise<void> {
await this.findOwnedRule(ownerId, ruleId);
await this.findSharedRule(userId, ruleId);
if (done) {
await this.completions
.createQueryBuilder()
@@ -260,21 +301,50 @@ export class ScheduleService implements OnModuleInit, PetLifecycleListener {
);
}
private async findOwnedEvent(
ownerId: string,
// ── 응답 조립 ─────────────────────────────────────────
/** 일정 주인들의 회원 정보와 대상 아이를 한 번에 읽는다 */
private async viewContext(
items: readonly OwnedSchedule[],
): Promise<ScheduleViewContext> {
const ownerIds = [...new Set(items.map((i) => i.ownerId))];
const [owners, pets] = await Promise.all([
ownerIds.length === 0
? []
: this.users.find({
where: { id: In(ownerIds) },
select: { id: true, nickname: true, photoFileId: true },
}),
this.pets.findTargetPets(items.flatMap((i) => i.details.targetIds)),
]);
return { owners: new Map(owners.map((u) => [u.id, u])), pets };
}
// ── 조회 도우미 ───────────────────────────────────────
/** 나와 연동한 사람의 날짜 일정 — 아니면 없는 것과 똑같이 RES_001 */
private async findSharedEvent(
userId: string,
eventId: string,
): Promise<ScheduleEvent> {
const event = await this.events.findOneBy({ id: eventId, ownerId });
const ownerIds = await this.sharing.memberIds(userId);
const event = await this.events.findOneBy({
id: eventId,
ownerId: In(ownerIds),
});
if (!event)
throw new BusinessException('RES_001', { context: { eventId } });
return event;
}
private async findOwnedRule(
ownerId: string,
/** 나와 연동한 사람의 반복 일정 — 아니면 없는 것과 똑같이 RES_001 */
private async findSharedRule(
userId: string,
ruleId: string,
): Promise<RepeatRule> {
const rule = await this.rules.findOneBy({ id: ruleId, ownerId });
const ownerIds = await this.sharing.memberIds(userId);
const rule = await this.rules.findOneBy({
id: ruleId,
ownerId: In(ownerIds),
});
if (!rule) throw new BusinessException('RES_001', { context: { ruleId } });
return rule;
}
@@ -0,0 +1,9 @@
/**
* 내 것이 아닌 파일을 읽어도 되는지 정하는 규칙 — 함께 쓰는 기능(공유 다이어리 등)이 구현한다.
* 각 모듈이 onModuleInit 에서 UploadService.registerAccessPolicy 로 등록한다 —
* 업로드 모듈이 다른 도메인을 알지 않아도 된다(의존 방향: 다른 도메인 → 업로드).
*/
export interface FileAccessPolicy {
/** [userId] 가 남의 파일 [fileId] 를 내려받아도 되면 true. 모르는 파일이면 false */
canRead(userId: string, fileId: string): Promise<boolean>;
}
@@ -0,0 +1,19 @@
import { StreamableFile } from '@nestjs/common';
import type { FileDownload } from './upload.service';
/** 파일은 id 별로 내용이 바뀌지 않으므로 오래 캐시해도 된다 (로그인한 사람만 받으므로 private) */
export const FILE_CACHE_CONTROL = 'private, max-age=31536000, immutable';
/**
* 파일 본문 응답 — 업로드 디렉터리를 정적으로 서빙하지 않는다.
* 형식은 서버 기록으로 정하고, 브라우저가 열어 실행하지 않도록 attachment 로 내려준다 (.claude/rules/security.md).
* 핸들러에는 @SkipTransform() 과 @Header('Cache-Control', ...) 를 함께 붙인다.
*/
export function toStreamableFile(file: FileDownload): StreamableFile {
return new StreamableFile(file.stream, {
type: file.mimeType,
length: file.size,
disposition: `attachment; filename="${file.downloadName}"`,
});
}
@@ -0,0 +1,64 @@
import { fileReferencesOf, orphanFilesSql } from './file-sweep';
import type { EntityShape } from './file-sweep';
const entity = (
tableName: string,
relations: Array<[target: string, column: string]>,
): EntityShape => ({
tableName,
manyToOneRelations: relations.map(([target, column]) => ({
inverseEntityMetadata: { tableName: target },
joinColumns: [{ databaseName: column }],
})),
});
describe('쓰이지 않는 파일 찾기', () => {
const entities = [
entity('users', [['files', 'photo_file_id']]),
entity('pets', [
['users', 'owner_id'],
['files', 'photo_file_id'],
]),
entity('received_cards', [
['users', 'owner_id'],
['files', 'sender_photo_file_id'],
['files', 'pet_photo_file_id'],
]),
// 파일 표 자신의 owner_id 는 사용자를 가리킨다 — 파일 참조가 아니다
entity('files', [['users', 'owner_id']]),
];
it('파일 표를 가리키는 FK 컬럼만 모은다', () => {
expect(fileReferencesOf(entities, 'files')).toEqual([
{ table: 'users', column: 'photo_file_id' },
{ table: 'pets', column: 'photo_file_id' },
{ table: 'received_cards', column: 'sender_photo_file_id' },
{ table: 'received_cards', column: 'pet_photo_file_id' },
]);
});
it('오래됐고 어느 컬럼도 가리키지 않는 파일만, 오래된 것부터 정해진 개수까지 고른다', () => {
const sql = orphanFilesSql('files', fileReferencesOf(entities, 'files'));
expect(sql).toContain('FROM "files" f');
expect(sql).toContain('WHERE f.created_at < $1');
expect(sql).toContain(
'AND NOT EXISTS (SELECT 1 FROM "users" r WHERE r."photo_file_id" = f.id)',
);
expect(sql).toContain(
'AND NOT EXISTS (SELECT 1 FROM "received_cards" r WHERE r."pet_photo_file_id" = f.id)',
);
expect(sql.match(/NOT EXISTS/g)).toHaveLength(4);
expect(sql).toMatch(/ORDER BY f\.created_at\s+LIMIT \$2$/);
});
it('참조가 없으면 오래된 파일을 모두 고른다', () => {
expect(orphanFilesSql('files', [])).not.toContain('NOT EXISTS');
});
it('이름 형식이 이상하면 SQL 을 만들지 않는다', () => {
expect(() =>
orphanFilesSql('files', [{ table: 'users"; DROP', column: 'x' }]),
).toThrow(/쓸 수 없는 이름/);
});
});
+68
View File
@@ -0,0 +1,68 @@
// 아무도 쓰지 않는 파일 찾기 — 부수효과 없는 순수 함수 (실제 정리는 FileSweeper 가 한다)
/** 파일을 가리키는 FK 컬럼 하나 */
export interface FileReference {
readonly table: string;
readonly column: string;
}
/** fileReferencesOf 가 읽는 엔티티 정의 (TypeORM EntityMetadata 의 일부) */
export interface EntityShape {
readonly tableName: string;
readonly manyToOneRelations: ReadonlyArray<{
readonly inverseEntityMetadata: { readonly tableName: string };
readonly joinColumns: ReadonlyArray<{ readonly databaseName: string }>;
}>;
}
/** SQL 에 그대로 넣는 이름 — 엔티티 정의에서 왔더라도 형식을 확인한다 */
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
/**
* 파일 표([filesTable])를 가리키는 FK 컬럼 — 엔티티 정의에서 찾는다.
* 새 엔티티가 사진을 가리켜도(ManyToOne → StoredFile) 여기에 따로 적지 않아도 정리 대상에서 빠진다.
*/
export function fileReferencesOf(
entities: readonly EntityShape[],
filesTable: string,
): FileReference[] {
const refs: FileReference[] = [];
for (const entity of entities) {
for (const relation of entity.manyToOneRelations) {
if (relation.inverseEntityMetadata.tableName !== filesTable) continue;
for (const column of relation.joinColumns) {
refs.push({ table: entity.tableName, column: column.databaseName });
}
}
}
return refs;
}
/**
* 오래됐고($1 보다 먼저 올린) 어느 FK 도 가리키지 않는 파일을 찾는 SQL — 한 번에 $2 개까지, 오래된 것부터.
* 고른 뒤 실제로 지울 때(UploadService.releaseIfUnused) FK 가 한 번 더 막으므로, 그사이 쓰이기 시작한 파일은 지워지지 않는다.
*/
export function orphanFilesSql(
filesTable: string,
refs: readonly FileReference[],
): string {
for (const name of [
filesTable,
...refs.flatMap((r) => [r.table, r.column]),
]) {
if (!IDENTIFIER.test(name)) {
throw new Error(`파일 정리 SQL 에 쓸 수 없는 이름입니다: ${name}`);
}
}
const unreferenced = refs.map(
(r) =>
`AND NOT EXISTS (SELECT 1 FROM "${r.table}" r WHERE r."${r.column}" = f.id)`,
);
return [
`SELECT f.id FROM "${filesTable}" f`,
'WHERE f.created_at < $1',
...unreferenced,
'ORDER BY f.created_at',
'LIMIT $2',
].join('\n');
}
@@ -0,0 +1,76 @@
import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { StoredFile } from './entities/stored-file.entity';
import { fileReferencesOf, orphanFilesSql } from './file-sweep';
import { UploadService } from './upload.service';
/** 정리 주기 — 한 시간마다 */
const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
/** 올린 뒤 이만큼 지나도 아무도 쓰지 않는 파일만 지운다 — 사진을 올리고 저장하기 전의 파일을 지우지 않게 */
const ORPHAN_AGE_MS = 24 * 60 * 60 * 1000;
/** 한 번에 지우는 최대 개수 — 남으면 다음 주기에 이어서 */
const SWEEP_BATCH = 500;
/**
* 쓰이지 않는 파일 정리 — 올렸지만 어디에도 붙이지 않은 사진, 다른 기록이 지워지며 남은 사진 등.
* 새 의존성 없이 setInterval 로 돈다. 종료할 때 타이머를 멈춘다 (graceful shutdown — shutdown.logger.ts 참고).
*/
@Injectable()
export class FileSweeper implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(FileSweeper.name);
private timer: NodeJS.Timeout | null = null;
private running = false;
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly uploads: UploadService,
) {}
onModuleInit(): void {
this.timer = setInterval(() => void this.sweep(), SWEEP_INTERVAL_MS);
// 타이머 때문에 프로세스가 끝나지 못하는 일이 없게 한다
this.timer.unref();
}
onApplicationShutdown(): void {
if (this.timer !== null) clearInterval(this.timer);
this.timer = null;
}
/** 한 번 정리한다 — 지운 파일 수. 앞의 정리가 아직 돌고 있으면 건너뛴다 */
async sweep(now: Date = new Date()): Promise<number> {
if (this.running) return 0;
this.running = true;
try {
const filesTable = this.dataSource.getMetadata(StoredFile).tableName;
const refs = fileReferencesOf(
this.dataSource.entityMetadatas,
filesTable,
);
const rows = await this.dataSource.query<{ id: string }[]>(
orphanFilesSql(filesTable, refs),
[new Date(now.getTime() - ORPHAN_AGE_MS), SWEEP_BATCH],
);
await this.uploads.releaseIfUnused(rows.map((r) => r.id));
if (rows.length > 0) {
this.logger.log(`쓰이지 않는 파일 정리: ${rows.length}개`);
}
return rows.length;
} catch (error) {
this.logger.error(
`파일 정리 실패: ${error instanceof Error ? error.message : String(error)}`,
);
return 0;
} finally {
this.running = false;
}
}
}
@@ -28,11 +28,9 @@ import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { UploadResponseDto } from './dto/upload-response.dto';
import { FILE_CACHE_CONTROL, toStreamableFile } from './file-response';
import { UploadService } from './upload.service';
/** 파일은 id 별로 내용이 바뀌지 않으므로 오래 캐시해도 된다 (로그인한 본인만 받으므로 private) */
const FILE_CACHE_CONTROL = 'private, max-age=31536000, immutable';
// 파일 업로드·내려받기 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이,
// 내용(시그니처) 검사와 소유자 기록은 UploadService 가 담당한다.
@@ -79,21 +77,17 @@ export class UploadController {
@Get(':id')
@SkipTransform() // 파일 본문 — { success, data } 로 감싸면 깨진다
@Header('Cache-Control', FILE_CACHE_CONTROL)
@ApiOperation({ summary: '내 파일 내려받기' })
@ApiOperation({
summary: '파일 내려받기',
description:
'내 파일, 그리고 함께 쓰는 다이어리에서 보이는 사진(표지·기록 사진·대상 아이·함께하는 사람의 프로필)',
})
@ApiOkResponse({ description: '파일 본문 (Content-Type 은 서버가 정한다)' })
@ApiErrorResponse('RES_001', '파일이 없거나 내 파일이 아님')
@ApiErrorResponse('RES_001', '파일이 없거나 볼 수 없는 파일')
async download(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<StreamableFile> {
const file = await this.uploadService.download(id, user.userId);
// 업로드 디렉터리를 정적으로 서빙하지 않는다 — 형식은 서버 기록으로 정하고,
// 브라우저가 열어 실행하지 않도록 attachment 로 내려준다 (.claude/rules/security.md)
return new StreamableFile(file.stream, {
type: file.mimeType,
length: file.size,
disposition: `attachment; filename="${file.downloadName}"`,
});
return toStreamableFile(await this.uploadService.download(id, user.userId));
}
}
+12 -3
View File
@@ -3,7 +3,7 @@ import { MulterModule } from '@nestjs/platform-express';
import { TypeOrmModule } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { diskStorage } from 'multer';
import { mkdirSync } from 'fs';
import { accessSync, constants as fsConstants, mkdirSync } from 'fs';
import { extname } from 'path';
import { BusinessException } from '@/common/errors/business.exception';
@@ -13,11 +13,20 @@ import {
isAllowedFile,
} from './upload.config';
import { StoredFile } from './entities/stored-file.entity';
import { FileSweeper } from './file-sweeper';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
// 저장 디렉터리는 기동 시 한 번 보장한다 (없으면 multer 가 매 요청 실패한다)
// 저장 디렉터리는 기동 시 한 번 보장한다 (없으면 multer 가 매 요청 실패한다).
// 쓸 수 없으면(운영 서버에서 root 소유로 만들어진 호스트 경로 등) 첫 업로드에서야 500 이 나므로 기동을 막는다 — fail-fast
mkdirSync(UPLOAD_DIR, { recursive: true });
try {
accessSync(UPLOAD_DIR, fsConstants.W_OK);
} catch {
throw new Error(
`업로드 디렉터리에 쓸 수 없습니다: ${UPLOAD_DIR} — 백엔드(uid 1000)가 쓸 수 있게 소유권을 맞춰 주세요 (.claude/skills/docker/SKILL.md "첫 배포 전")`,
);
}
@Module({
imports: [
@@ -50,7 +59,7 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
}),
],
controllers: [UploadController],
providers: [UploadService],
providers: [UploadService, FileSweeper],
exports: [UploadService],
})
export class UploadModule {}
@@ -0,0 +1,80 @@
import { Readable } from 'stream';
import { Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { StoredFile } from './entities/stored-file.entity';
import { UploadService } from './upload.service';
// 실제 디스크를 열지 않는다 — 내려받기 권한 판단만 본다
jest.mock('fs', () => ({
...jest.requireActual<typeof import('fs')>('fs'),
createReadStream: jest.fn(() => Readable.from([])),
}));
const OWNER = 'owner-1';
const OTHER = 'other-1';
const file = {
id: 'file-1',
ownerId: OWNER,
storedName: 'stored.png',
originalName: 'cover.png',
mimeType: 'image/png',
size: 10,
} as StoredFile;
describe('UploadService 내려받기', () => {
const files = { findOneBy: jest.fn() };
let service: UploadService;
beforeEach(() => {
jest.clearAllMocks();
service = new UploadService(files as unknown as Repository<StoredFile>);
});
const expectCode = async (promise: Promise<unknown>, code: string) => {
await expect(promise).rejects.toBeInstanceOf(BusinessException);
await expect(promise).rejects.toMatchObject({ code });
};
it('내 파일은 규칙 없이 내려받는다', async () => {
files.findOneBy.mockResolvedValue(file);
const canRead = jest.fn();
service.registerAccessPolicy({ canRead });
const download = await service.download(file.id, OWNER);
expect(download.mimeType).toBe('image/png');
expect(canRead).not.toHaveBeenCalled();
});
it('남의 파일은 등록된 규칙이 허락해야 내려받는다', async () => {
files.findOneBy.mockResolvedValue(file);
const deny = jest.fn().mockResolvedValue(false);
const allow = jest.fn().mockResolvedValue(true);
service.registerAccessPolicy({ canRead: deny });
service.registerAccessPolicy({ canRead: allow });
await expect(service.download(file.id, OTHER)).resolves.toBeDefined();
expect(deny).toHaveBeenCalledWith(OTHER, file.id);
expect(allow).toHaveBeenCalledWith(OTHER, file.id);
});
it('허락하는 규칙이 없으면 없는 파일과 똑같이 RES_001', async () => {
files.findOneBy.mockResolvedValue(file);
service.registerAccessPolicy({
canRead: jest.fn().mockResolvedValue(false),
});
await expectCode(service.download(file.id, OTHER), 'RES_001');
});
it('없는 파일은 RES_001 — 규칙을 묻지 않는다', async () => {
files.findOneBy.mockResolvedValue(null);
const canRead = jest.fn();
service.registerAccessPolicy({ canRead });
await expectCode(service.download('missing', OTHER), 'RES_001');
expect(canRead).not.toHaveBeenCalled();
});
});
+69 -5
View File
@@ -1,14 +1,16 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { createReadStream } from 'fs';
import { open, unlink } from 'fs/promises';
import { join } from 'path';
import { copyFile, open, unlink } from 'fs/promises';
import { extname, join } from 'path';
import { Readable } from 'stream';
import { Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { isForeignKeyViolation } from '@/shared/utils/db-error';
import { StoredFile } from './entities/stored-file.entity';
import type { FileAccessPolicy } from './file-access-policy';
import {
SIGNATURE_BYTES,
UPLOAD_DIR,
@@ -30,6 +32,7 @@ export interface FileDownload {
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
private readonly accessPolicies: FileAccessPolicy[] = [];
constructor(
@InjectRepository(StoredFile)
@@ -64,13 +67,74 @@ export class UploadService {
return UploadResponseDto.from(saved);
}
/** 내 파일을 내려받는다 — 남의 파일이나 없는 파일은 똑같이 RES_001 */
async download(fileId: string, ownerId: string): Promise<FileDownload> {
const file = await this.files.findOneBy({ id: fileId, ownerId });
/** 남의 파일을 읽어도 되는 경우를 등록한다 (file-access-policy.ts) */
registerAccessPolicy(policy: FileAccessPolicy): void {
this.accessPolicies.push(policy);
}
/**
* 파일을 내려받는다 — 내 파일이거나, 등록된 규칙이 허락한 남의 파일(예: 함께 쓰는 다이어리의 사진)만.
* 볼 수 없는 파일과 없는 파일은 똑같이 RES_001
*/
async download(fileId: string, userId: string): Promise<FileDownload> {
const file = await this.files.findOneBy({ id: fileId });
if (!file || !(await this.canRead(file, userId))) {
throw new BusinessException('RES_001', { context: { fileId } });
}
return this.toDownload(file);
}
private async canRead(file: StoredFile, userId: string): Promise<boolean> {
if (file.ownerId === userId) return true;
for (const policy of this.accessPolicies) {
if (await policy.canRead(userId, file.id)) return true;
}
return false;
}
/**
* 소유자를 따지지 않고 내려받는다 — 호출한 쪽이 볼 권한을 이미 확인한 경우만 쓴다
* (예: 명함 코드를 아는 사람에게 그 명함의 사진을 보여 줄 때).
*/
async downloadUnchecked(fileId: string): Promise<FileDownload> {
const file = await this.files.findOneBy({ id: fileId });
if (!file) {
throw new BusinessException('RES_001', { context: { fileId } });
}
return this.toDownload(file);
}
/**
* 파일을 [ownerId] 의 것으로 복사한다 — 명함을 받을 때처럼 원본이 지워져도 남아야 하는 경우.
* 없는 파일(null 포함)이면 null.
*/
async copyFor(
fileId: string | null,
ownerId: string,
): Promise<string | null> {
if (fileId === null) return null;
const source = await this.files.findOneBy({ id: fileId });
if (!source) return null;
// 저장 파일명은 새로 만든다 — 확장자는 원본(서버가 검증해 만든 이름)을 따른다
const storedName = `${randomUUID()}${extname(source.storedName)}`;
await copyFile(
join(UPLOAD_DIR, source.storedName),
join(UPLOAD_DIR, storedName),
);
const copy = await this.files.save(
this.files.create({
ownerId,
storedName,
originalName: source.originalName,
mimeType: source.mimeType,
size: source.size,
}),
);
return copy.id;
}
private toDownload(file: StoredFile): FileDownload {
return {
stream: createReadStream(join(UPLOAD_DIR, file.storedName)),
mimeType: file.mimeType,
@@ -0,0 +1,9 @@
import { inviteCodeFormat } from '@/shared/utils/invite-code';
/**
* 일정 연동 초대 코드 `RUP-XXXXXX` (뒷부분 6자) — users.calendar_invite_code 의 길이(10)와 맞춘다.
* 코드만 알면 승인 없이 달력 전체를 함께 보고 고치게 되므로 다이어리와 같은 길이로 짐작하기 어렵게 두고,
* 한 번 쓰이면(연동·해제) 새 코드로 바꾼다 (schedule/calendar-sharing.service.ts).
* 가입할 때 만든다 (UserService) — 연동 화면을 여는 GET 은 아무것도 쓰지 않는다.
*/
export const CALENDAR_INVITE_CODE = inviteCodeFormat(6);
@@ -2,6 +2,7 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
@@ -31,9 +32,17 @@ export class User {
@JoinColumn({ name: 'photo_file_id' })
photoFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
photoFileId!: string | null;
/**
* 일정 연동 초대 코드 `RUP-XXXXXX` — 사람마다 하나. 가입할 때 만들고(UserService),
* 연동·해제에 쓰이면 새로 바꾼다 (CalendarSharingService). 형식은 ../calendar-invite-code.ts
*/
@Column({ type: 'varchar', length: 10, unique: true })
calendarInviteCode!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@@ -0,0 +1,24 @@
import { EntityManager } from 'typeorm';
/** 회원 탈퇴 — 다른 도메인이 자기 기록을 맞춘다 */
export interface UserLifecycleEvent {
readonly userId: string;
/** 회원을 지우는 트랜잭션 — 지우기 전 상태를 이 안에서 읽는다 */
readonly manager: EntityManager;
}
/**
* 회원 탈퇴를 따라 정리해야 하는 도메인이 구현한다.
* 각 모듈이 onModuleInit 에서 UserService.registerListener 로 등록한다 —
* 회원 모듈이 다른 도메인을 알지 않아도 된다(의존 방향: 다른 도메인 → 회원). pet-lifecycle.ts 와 같은 방식이다.
*/
export interface UserLifecycleListener {
/**
* 회원을 지우기 직전. 회원의 기록이 CASCADE 로 지워지면서 **남의 파일**이 더는 쓰이지 않게 되면
* (예: 내 다이어리에 함께하는 사람이 올린 사진) 그 파일 id 를 돌려준다 — 커밋한 뒤 정리한다.
* 회원 자신의 파일은 files.owner_id CASCADE 로 함께 지워지므로 돌려주지 않아도 된다.
*/
onUserDeleting(
event: UserLifecycleEvent,
): Promise<ReadonlyArray<string | null>>;
}
+56 -32
View File
@@ -5,6 +5,7 @@ import { DataSource, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { UploadService } from '@/modules/upload/upload.service';
import { isUniqueViolation } from '@/shared/utils/db-error';
import { CALENDAR_INVITE_CODE } from './calendar-invite-code';
import { UpdateMeDto } from './dto/update-me.dto';
import { UserResponseDto } from './dto/user-response.dto';
import {
@@ -12,6 +13,10 @@ import {
VerifiedSocialAccount,
} from './entities/social-account.entity';
import { NICKNAME_MAX_LENGTH, User } from './entities/user.entity';
import { UserLifecycleListener } from './user-lifecycle';
/** 가입할 때 일정 연동 초대 코드가 겹치면 다시 뽑는 횟수 — 32^6 가지라 실제로 겹치는 일은 드물다 */
const CODE_RETRY = 5;
/** 가입·로그인 결과 */
export interface SignedInUser {
@@ -23,6 +28,7 @@ export interface SignedInUser {
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
private readonly listeners: UserLifecycleListener[] = [];
constructor(
@InjectRepository(User)
@@ -33,6 +39,11 @@ export class UserService {
private readonly uploads: UploadService,
) {}
/** 회원 탈퇴를 따라 정리할 도메인을 등록한다 (user-lifecycle.ts) */
registerListener(listener: UserLifecycleListener): void {
this.listeners.push(listener);
}
/** 액세스 토큰의 회원이 아직 있는지 — 탈퇴한 회원의 토큰은 만료 전이라도 거절한다 */
exists(userId: string): Promise<boolean> {
return this.users.existsBy({ id: userId });
@@ -45,35 +56,39 @@ export class UserService {
const found = await this.findBySocial(account);
if (found) return { user: found, isNewUser: false };
try {
const user = await this.dataSource.transaction(async (manager) => {
const created = await manager.save(
manager.create(User, {
nickname: account.nickname?.slice(0, NICKNAME_MAX_LENGTH) || null,
photoFileId: null,
}),
);
await manager.save(
manager.create(SocialAccount, {
userId: created.id,
provider: account.provider,
subject: account.subject,
}),
);
return created;
});
for (let attempt = 1; ; attempt += 1) {
try {
const user = await this.dataSource.transaction(async (manager) => {
const created = await manager.save(
manager.create(User, {
nickname: account.nickname?.slice(0, NICKNAME_MAX_LENGTH) || null,
photoFileId: null,
calendarInviteCode: CALENDAR_INVITE_CODE.random(),
}),
);
await manager.save(
manager.create(SocialAccount, {
userId: created.id,
provider: account.provider,
subject: account.subject,
}),
);
return created;
});
this.logger.log(`회원 가입: ${user.id} (${account.provider})`);
return {
user: UserResponseDto.from(user, account.provider),
isNewUser: true,
};
} catch (error) {
// 같은 계정으로 동시에 가입하면 유니크 제약에 걸린다 — 먼저 만들어진 회원으로 로그인한다
if (!isUniqueViolation(error)) throw error;
const raced = await this.findBySocial(account);
if (!raced) throw error;
return { user: raced, isNewUser: false };
this.logger.log(`회원 가입: ${user.id} (${account.provider})`);
return {
user: UserResponseDto.from(user, account.provider),
isNewUser: true,
};
} catch (error) {
if (!isUniqueViolation(error)) throw error;
// 같은 계정으로 동시에 가입하면 유니크 제약에 걸린다 — 먼저 만들어진 회원으로 로그인한다
const raced = await this.findBySocial(account);
if (raced) return { user: raced, isNewUser: false };
// 아니면 일정 연동 초대 코드가 겹친 것 — 새 코드로 다시 가입시킨다
if (attempt >= CODE_RETRY) throw error;
}
}
}
@@ -109,15 +124,24 @@ export class UserService {
/**
* 회원탈퇴 — 회원과 그 회원의 모든 기록(아이·일정·다이어리·명함·파일 기록)을 지운다.
* 기록은 FK(ON DELETE CASCADE)로 함께 지워지고, 실제 파일은 그 뒤에 디스크에서 정리한다.
* 함께 지워지는 기록이 남의 파일을 쓰고 있었다면(내 다이어리에 함께하는 사람이 올린 사진) 그 파일도 정리한다.
*/
async withdraw(userId: string): Promise<void> {
const storedNames = await this.uploads.storedNamesOf(userId);
const result = await this.users.delete({ id: userId });
if (!result.affected) {
throw new BusinessException('RES_001', { context: { userId } });
}
const othersFiles = await this.dataSource.transaction(async (manager) => {
const fileIds: Array<string | null> = [];
for (const listener of this.listeners) {
fileIds.push(...(await listener.onUserDeleting({ userId, manager })));
}
const result = await manager.delete(User, { id: userId });
if (!result.affected) {
throw new BusinessException('RES_001', { context: { userId } });
}
return fileIds;
});
for (const name of storedNames) await this.uploads.unlinkQuietly(name);
await this.uploads.releaseIfUnused(othersFiles);
this.logger.log(`회원 탈퇴: ${userId} (파일 ${storedNames.length}개 정리)`);
}
+2 -1
View File
@@ -80,7 +80,8 @@ export function buildDatabaseOptions(): DataSourceOptions {
database: process.env.DB_NAME ?? DEV_DB_DEFAULTS.database,
synchronize: shouldSynchronize(),
migrationsRun: shouldRunMigrations(),
logging: !isProduction,
// 운영은 쿼리를 남기지 않는다(양·민감정보). 기동 때 적용한 마이그레이션만 남겨 배포 기록으로 쓴다
logging: isProduction ? ['migration'] : true,
// 컬럼은 snake_case, 엔티티 속성은 camelCase
namingStrategy: new SnakeNamingStrategy(),
extra: { ...POOL_OPTIONS },
@@ -47,6 +47,44 @@ describe('validateEnv', () => {
});
});
describe('JWT_SECRET — development·test 밖에서는 필수 (allowlist)', () => {
it('development·test 에서는 비어 있어도 통과한다 (개발용 키를 쓴다)', () => {
expect(() => validateEnv({ NODE_ENV: 'development' })).not.toThrow();
expect(() => validateEnv({ NODE_ENV: 'test' })).not.toThrow();
});
it('staging·오타·미설정처럼 목록 밖의 환경은 JWT_SECRET 이 없으면 실패한다', () => {
for (const config of [
{ NODE_ENV: 'staging' },
{ NODE_ENV: 'prod' },
{ NODE_ENV: 'Development' },
{},
]) {
expect(() => validateEnv(config)).toThrow(
/JWT_SECRET 가 설정되지 않았습니다/,
);
}
});
it('목록 밖의 환경에서는 짧은 키·예시 값도 막는다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'staging', JWT_SECRET: 'short-secret' }),
).toThrow(/JWT_SECRET 는 32자 이상/);
expect(() =>
validateEnv({
NODE_ENV: 'staging',
JWT_SECRET: 'change-me-please-change-me-please-123',
}),
).toThrow(/JWT_SECRET 가 예시 값 그대로입니다/);
expect(() =>
validateEnv({
NODE_ENV: 'staging',
JWT_SECRET: 'a-staging-secret-that-is-long-enough-123',
}),
).not.toThrow();
});
});
describe('운영 환경', () => {
it('정상 설정이면 통과한다', () => {
expect(() => validateEnv(prod())).not.toThrow();
+24 -16
View File
@@ -7,6 +7,7 @@
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
*/
import { isDevNodeEnv } from '@/shared/config/node-env';
import { parseDurationMs } from '@/shared/utils/duration';
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */
@@ -18,11 +19,9 @@ const REQUIRED_IN_PRODUCTION = [
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
// 비워 두면 개발용 고정 키로 서명하게 된다 — 운영에서는 기동을 막는다 (modules/auth/auth.config.ts)
'JWT_SECRET',
] as const;
/** 운영 JWT 서명 키 최소 길이 — 짧은 키는 무차별 대입으로 풀린다 */
/** JWT 서명 키 최소 길이 (development·test 밖) — 짧은 키는 무차별 대입으로 풀린다 */
const JWT_SECRET_MIN_LENGTH = 32;
/** 값이 있다면 `15m`·`1h`·`14d` 형식이어야 하는 키 */
@@ -40,11 +39,7 @@ const POSITIVE_INT_KEYS = [
] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
const NO_PLACEHOLDER_IN_PRODUCTION = [
'DB_PASSWORD',
'REDIS_PASSWORD',
'JWT_SECRET',
] as const;
const NO_PLACEHOLDER_IN_PRODUCTION = ['DB_PASSWORD', 'REDIS_PASSWORD'] as const;
/**
* 운영에서 **공개 주소**여야 하는 키.
@@ -94,7 +89,8 @@ function looksNonPublic(value: string): boolean {
export function validateEnv(
config: Record<string, unknown>,
): Record<string, unknown> {
const isProduction = readString(config, 'NODE_ENV') === 'production';
const nodeEnv = readString(config, 'NODE_ENV');
const isProduction = nodeEnv === 'production';
const errors: string[] = [];
// 1. 숫자여야 하는 값 — 비어 있으면 코드의 기본값을 쓰므로 통과시킨다
@@ -118,6 +114,25 @@ export function validateEnv(
}
}
// JWT 서명 키 — 개발용 키·개발용 로그인은 development·test 에서만 열린다 (shared/config/node-env.ts).
// 그 밖의 환경(staging·production·미설정 …)에서 비워 두면 개발용 고정 키로 서명하게 되므로 기동을 막는다
if (!isDevNodeEnv(nodeEnv)) {
const jwtSecret = readString(config, 'JWT_SECRET');
if (jwtSecret === undefined) {
errors.push(
'JWT_SECRET 가 설정되지 않았습니다. (development·test 밖에서는 필수)',
);
} else if (looksLikePlaceholder(jwtSecret)) {
errors.push(
'JWT_SECRET 가 예시 값 그대로입니다. 운영용 값으로 교체해 주세요.',
);
} else if (jwtSecret.length < JWT_SECRET_MIN_LENGTH) {
errors.push(
`JWT_SECRET 는 ${JWT_SECRET_MIN_LENGTH}자 이상이어야 합니다. (development·test 밖)`,
);
}
}
if (isProduction) {
// 2. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
@@ -136,13 +151,6 @@ export function validateEnv(
}
}
const jwtSecret = readString(config, 'JWT_SECRET');
if (jwtSecret !== undefined && jwtSecret.length < JWT_SECRET_MIN_LENGTH) {
errors.push(
`JWT_SECRET 는 ${JWT_SECRET_MIN_LENGTH}자 이상이어야 합니다. (운영 환경)`,
);
}
// 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
const allowLocalhostUrl =
readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true';
+10
View File
@@ -0,0 +1,10 @@
/**
* 개발용 서명 키·개발용 로그인을 허용하는 실행 환경 (allowlist).
* 이 목록 밖의 값(production·staging·오타·미설정 등)은 모두 운영처럼 다룬다 — 실수로 열리지 않게 닫힌 쪽이 기본이다.
*/
export const DEV_NODE_ENVS: readonly string[] = ['development', 'test'];
/** 개발·테스트 환경인지 — 앞뒤 공백은 무시한다 */
export function isDevNodeEnv(nodeEnv: string | undefined): boolean {
return DEV_NODE_ENVS.includes(nodeEnv?.trim() ?? '');
}
+17
View File
@@ -27,3 +27,20 @@ export function isUniqueViolation(error: unknown): boolean {
export function isForeignKeyViolation(error: unknown): boolean {
return pgCodeOf(error) === PG_FOREIGN_KEY_VIOLATION;
}
/**
* 유니크 위반이면 처음부터 다시 한다 — 무작위로 뽑은 코드가 겹친 경우.
* [run] 은 매번 새 값을 뽑아야 한다. [attempts] 번 모두 겹치면 마지막 오류를 그대로 던진다.
*/
export async function retryOnUniqueViolation<T>(
run: () => Promise<T>,
attempts: number,
): Promise<T> {
for (let attempt = 1; ; attempt += 1) {
try {
return await run();
} catch (error) {
if (!isUniqueViolation(error) || attempt >= attempts) throw error;
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { EntityManager } from 'typeorm';
/**
* 트랜잭션 잠금(pg_advisory_xact_lock)의 용도 — 키가 서로 겹치지 않도록 한 곳에서 번호를 정한다.
* 잠금은 트랜잭션이 끝나면(커밋·롤백) 저절로 풀린다.
*/
export const LOCK_SCOPE = {
/** 다이어리 책장 한도 — 만들기·참여 (회원별) */
diaryShelf: 1,
/** 펫 명함 한도 (회원별) */
petCards: 2,
/** 받은 명함 보관함 한도·중복 (회원별) */
receivedCards: 3,
/** 일정 연동 참여·해제 (전체 하나) */
calendarSharing: 4,
} as const;
export type LockScope = (typeof LOCK_SCOPE)[keyof typeof LOCK_SCOPE];
/**
* [scope] 안에서 [key] 하나에 대한 일을 한 줄로 세운다 — 예: 한 회원의 "세고 → 만들기"가 동시에 두 번 돌아
* 한도를 넘지 않게. 행 잠금(FOR UPDATE)과 달리 셀 행이 하나도 없을 때도 잠긴다.
* [key] 는 hashtext 로 정수가 되므로 드물게 다른 키와 같은 잠금을 쓸 수 있다 — 그래도 순서만 늦어질 뿐 틀리지 않는다.
*/
export async function lockFor(
manager: EntityManager,
scope: LockScope,
key: string,
): Promise<void> {
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
scope,
key,
]);
}
+15 -1
View File
@@ -1,4 +1,18 @@
import { parsePositiveInt } from './env';
import { parseCommaList, parsePositiveInt } from './env';
describe('parseCommaList', () => {
it('콤마로 나누고 앞뒤 공백을 지운다', () => {
expect(
parseCommaList('https://a.com, https://b.com ,https://c.com'),
).toEqual(['https://a.com', 'https://b.com', 'https://c.com']);
});
it('값이 없거나 빈 칸뿐이면 빈 목록이다 (compose 미정의 변수 대응)', () => {
expect(parseCommaList(undefined)).toEqual([]);
expect(parseCommaList('')).toEqual([]);
expect(parseCommaList(' , ,')).toEqual([]);
});
});
describe('parsePositiveInt', () => {
it('정상적인 양의 정수 문자열을 숫자로 변환한다', () => {
+8
View File
@@ -5,6 +5,14 @@
* `Number('')` 는 0 이므로 `??` 만으로는 기본값이 적용되지 않는다 — 이 함수로 방어한다.
*/
/** 콤마로 나눈 목록 — 앞뒤 공백과 빈 칸은 버린다 (값이 없으면 빈 목록) */
export function parseCommaList(value: string | undefined): string[] {
return (value ?? '')
.split(',')
.map((v) => v.trim())
.filter((v) => v !== '');
}
/**
* 환경변수를 양의 정수로 파싱한다.
* 값이 없거나(undefined·빈 문자열) 숫자가 아니거나 0 이하면 기본값을 반환한다.
@@ -0,0 +1,40 @@
import { INVITE_CODE_ALPHABET, inviteCodeFormat } from './invite-code';
describe('초대 코드', () => {
const six = inviteCodeFormat(6);
const four = inviteCodeFormat(4);
it('RUP- 뒤에 정한 길이만큼 붙인다', () => {
expect(six.length).toBe(10);
expect(four.length).toBe(8);
for (let i = 0; i < 50; i += 1) {
expect(six.random()).toMatch(/^RUP-[A-Z2-9]{6}$/);
expect(four.random()).toMatch(/^RUP-[A-Z2-9]{4}$/);
}
});
it('헷갈리는 글자(0·O·1·I)는 쓰지 않는다', () => {
expect(INVITE_CODE_ALPHABET).not.toMatch(/[0O1I]/);
for (let i = 0; i < 50; i += 1) {
const body = six.random().slice('RUP-'.length);
for (const ch of body) expect(INVITE_CODE_ALPHABET).toContain(ch);
}
});
it('입력한 코드는 앞뒤 공백을 지우고 대문자로 맞춘다', () => {
expect(six.normalize(' rup-7kq2mx ')).toBe('RUP-7KQ2MX');
expect(four.normalize('rup-7kq2')).toBe('RUP-7KQ2');
});
it('형식이 아니면 null — 길이가 다르거나 쓰지 않는 글자가 있으면', () => {
expect(six.normalize('RUP-7KQ2M')).toBeNull();
expect(six.normalize('RUP-7KQ2MXX')).toBeNull();
expect(six.normalize('RUP-0KQ2MX')).toBeNull();
expect(six.normalize('ABC-7KQ2MX')).toBeNull();
expect(six.normalize('7KQ2MX')).toBeNull();
expect(six.normalize('')).toBeNull();
// 길이가 다른 종류의 코드는 서로 받지 않는다
expect(four.normalize('RUP-7KQ2MX')).toBeNull();
expect(six.normalize('RUP-7KQ2')).toBeNull();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { randomInt } from 'crypto';
// 초대 코드 `RUP-XXXX…` — 부수효과 없는 순수 함수 (무작위 값만 crypto 에서 얻는다).
// 종류마다 뒷부분 길이를 정해 쓴다 — 지금은 다이어리·일정 연동 모두 6자.
/** 코드 앞머리 — 앱이 보여 주는 형식 */
const INVITE_CODE_PREFIX = 'RUP-';
/** 헷갈리는 글자(0·O·1·I)를 뺀 대문자·숫자 */
export const INVITE_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
/** 한 종류의 초대 코드 — 뒷부분 길이가 정해진 형식 */
export interface InviteCodeFormat {
/** 저장 길이 — `RUP-` + 뒷부분 */
readonly length: number;
/** 새 코드 — 추측하기 어렵게 무작위로 뽑는다 (겹치면 호출한 쪽이 다시 뽑는다) */
random(): string;
/** 사람이 입력한 코드 → 저장 형식 (앞뒤 공백 제거·대문자). 형식이 아니면 null */
normalize(input: string): string | null;
}
/** 뒷부분이 [bodyLength] 자인 초대 코드 형식 */
export function inviteCodeFormat(bodyLength: number): InviteCodeFormat {
const pattern = new RegExp(
`^${INVITE_CODE_PREFIX}[${INVITE_CODE_ALPHABET}]{${bodyLength}}$`,
);
return {
length: INVITE_CODE_PREFIX.length + bodyLength,
random: () => {
let body = '';
for (let i = 0; i < bodyLength; i += 1) {
body += INVITE_CODE_ALPHABET[randomInt(0, INVITE_CODE_ALPHABET.length)];
}
return `${INVITE_CODE_PREFIX}${body}`;
},
normalize: (input) => {
const code = input.trim().toUpperCase();
return pattern.test(code) ? code : null;
},
};
}
+6 -35
View File
@@ -1,14 +1,9 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '@/app.module';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app';
/**
* 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다.
@@ -35,7 +30,7 @@ interface ContractBody {
const body = (res: Response): ContractBody => res.body as ContractBody;
/** 매 실행마다 새 회원으로 시작한다 */
const E2E_DEVICE_KEY = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const E2E_DEVICE_KEY = uniqueDeviceKey();
describe('응답 계약 (e2e)', () => {
let app: INestApplication<App>;
@@ -43,34 +38,10 @@ describe('응답 계약 (e2e)', () => {
const auth = () => ({ Authorization: `Bearer ${accessToken}` });
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
// main.ts 와 동일한 전역 설정을 적용해야 실제 응답과 같아진다
app.setGlobalPrefix('api');
app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware()));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
await app.init();
// main.ts 와 동일한 전역 설정을 적용한 앱 (support/e2e-app.ts)
app = await createE2eApp();
// 개발용 로그인으로 테스트 회원을 만든다 (NODE_ENV=test 에서는 열려 있다)
const login = await request(app.getHttpServer())
.post('/api/auth/dev-sessions')
.send({ provider: 'kakao', deviceKey: E2E_DEVICE_KEY })
.expect(201);
accessToken = (login.body as { data: { accessToken: string } }).data
.accessToken;
accessToken = await devLogin(app, E2E_DEVICE_KEY);
});
afterAll(async () => {
+444
View File
@@ -0,0 +1,444 @@
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { DataSource } from 'typeorm';
import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app';
/**
* 일정 연동 — 초대 코드로 묶이고, 서로의 일정을 보고 고치고, 해제하면 나만 빠진다.
* 회원 여럿을 개발용 로그인으로 만들고 끝나면 모두 탈퇴한다.
*/
/** 1×1 PNG — 업로드가 매직 바이트를 확인하므로 진짜 PNG 여야 한다 */
const TINY_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
interface SharingBody {
inviteCode: string;
linked: boolean;
maxMembers: number;
members: { userId: string; nickname: string | null; isMe: boolean }[];
}
interface Target {
id: string;
name: string | null;
photoFileId: string | null;
tint: string | null;
}
interface EventBody {
id: string;
title: string;
isDone: boolean;
ownerId: string;
owner: { userId: string; nickname: string | null };
targets: Target[];
}
interface RuleBody {
id: string;
ownerId: string;
targets: Target[];
}
/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */
const data = <T>(res: Response): T => (res.body as { data: T }).data;
const errorCode = (res: Response): string | undefined =>
(res.body as { error?: { code?: string } }).error?.code;
describe('일정 연동 (e2e)', () => {
let app: INestApplication<App>;
const tokens: Record<string, string> = {};
const ids: Record<string, string> = {};
const people = ['a', 'b', 'c', 'd', 'e'] as const;
type Person = (typeof people)[number];
const as = (who: Person) => ({ Authorization: `Bearer ${tokens[who]}` });
const http = () => request(app.getHttpServer());
let petId = '';
let petPhotoId = '';
let profilePhotoId = '';
let eventId = '';
let ruleId = '';
const uploadPng = async (who: Person): Promise<string> => {
const res = await http()
.post('/api/files')
.set(as(who))
.attach('file', TINY_PNG, {
filename: 'photo.png',
contentType: 'image/png',
})
.expect(201);
return data<{ id: string }>(res).id;
};
const sharing = async (who: Person): Promise<SharingBody> =>
data<SharingBody>(
await http().get('/api/calendar-sharing').set(as(who)).expect(200),
);
const join = (who: Person, code: string) =>
http().post('/api/calendar-memberships').set(as(who)).send({ code });
const leave = (who: Person) =>
http().delete('/api/calendar-membership').set(as(who)).expect(204);
const eventsOf = async (who: Person): Promise<EventBody[]> =>
data<{ items: EventBody[] }>(
await http()
.get('/api/schedule-events?limit=100')
.set(as(who))
.expect(200),
).items;
const memberIds = (body: SharingBody) => body.members.map((m) => m.userId);
/** 지금의 초대 코드 — 연동·해제에 쓰이면 바뀌므로 매번 읽는다 */
const codeOf = async (who: Person): Promise<string> =>
(await sharing(who)).inviteCode;
/** 쓴 코드는 한 번뿐 — 예전 코드로는 찾을 수 없고(SCHED_001) 새 코드가 생긴다 */
const expectRotated = async (who: Person, previous: string) => {
const stale = await http()
.get(`/api/calendar-invitations/${previous}`)
.set(as(who))
.expect(404);
expect(errorCode(stale)).toBe('SCHED_001');
const current = await codeOf(who);
expect(current).toMatch(/^RUP-[A-Z2-9]{6}$/);
expect(current).not.toBe(previous);
};
beforeAll(async () => {
app = await createE2eApp();
for (const who of people) {
tokens[who] = await devLogin(app, uniqueDeviceKey(`cal-${who}`));
const me = await http()
.patch('/api/users/me')
.set(as(who))
.send({ nickname: who })
.expect(200);
ids[who] = data<{ id: string }>(me).id;
}
});
afterAll(async () => {
for (const who of people) {
await http().delete('/api/users/me').set(as(who));
}
await app.close();
});
it('초대 코드는 가입할 때 생기고, 읽기만 해서는 바뀌지 않는다', async () => {
const first = await sharing('a');
const again = await sharing('a');
expect(first.inviteCode).toMatch(/^RUP-[A-Z2-9]{6}$/);
expect(again.inviteCode).toBe(first.inviteCode);
expect(first).toEqual({
inviteCode: first.inviteCode,
linked: false,
maxMembers: 4,
members: [
{ userId: ids.a, nickname: 'a', photoFileId: null, isMe: true },
],
});
});
it('A 가 자기 아이로 일정·반복 일정을 만든다', async () => {
petPhotoId = await uploadPng('a');
profilePhotoId = await uploadPng('a');
await http()
.patch('/api/users/me')
.set(as('a'))
.send({ photoFileId: profilePhotoId })
.expect(200);
const pet = await http()
.post('/api/pets')
.set(as('a'))
.send({
tint: '#FF9B6A',
name: '콩이',
species: '강아지',
breed: '',
gender: 'male',
neutered: true,
birthDate: '2021-09-14',
weight: 3.2,
weightUnit: 'kg',
allergies: [],
conditions: [],
watchConditions: [],
photoFileId: petPhotoId,
})
.expect(201);
petId = data<{ id: string }>(pet).id;
const events = await http()
.post('/api/schedule-events')
.set(as('a'))
.send({
events: [
{
date: '2026-10-20',
title: '콩이 산책',
kind: 'general',
category: 'walk',
targetIds: [petId],
},
],
})
.expect(201);
const [event] = data<EventBody[]>(events);
eventId = event.id;
expect(event.ownerId).toBe(ids.a);
const rule = await http()
.post('/api/repeat-rules')
.set(as('a'))
.send({
from: '2026-10-01',
spec: { mode: 'daily', unit: 'day', n: 1 },
details: {
title: '심장사상충 약',
kind: 'general',
category: 'med',
targetIds: [petId],
},
})
.expect(201);
ruleId = data<RuleBody>(rule).id;
});
it('B 는 코드를 미리 보고 연동한다 (대소문자 무관) — 쓴 코드는 바뀐다', async () => {
const aCode = await codeOf('a');
const preview = await http()
.get(`/api/calendar-invitations/${aCode.toLowerCase()}`)
.set(as('b'))
.expect(200);
expect(data(preview)).toEqual({
ownerNickname: 'a',
memberCount: 1,
maxMembers: 4,
isMine: false,
isMember: false,
});
const joined = await join('b', aCode).expect(201);
expect(data<SharingBody>(joined).linked).toBe(true);
expect(memberIds(data<SharingBody>(joined))).toEqual([ids.b, ids.a]);
expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.b]);
await expectRotated('a', aCode);
const after = await http()
.get(`/api/calendar-invitations/${await codeOf('a')}`)
.set(as('b'))
.expect(200);
expect(data(after)).toMatchObject({ memberCount: 2, isMember: true });
});
it('B 는 A 의 일정을 주인·대상과 함께 본다', async () => {
const event = (await eventsOf('b')).find((e) => e.id === eventId);
expect(event?.ownerId).toBe(ids.a);
expect(event?.owner.nickname).toBe('a');
expect(event?.targets).toEqual([
{ id: petId, name: '콩이', photoFileId: petPhotoId, tint: '#FF9B6A' },
]);
const rules = await http()
.get('/api/repeat-rules')
.set(as('b'))
.expect(200);
expect(data<{ items: RuleBody[] }>(rules).items.map((r) => r.id)).toContain(
ruleId,
);
});
it('B 가 A 의 일정을 고치고 완료하면 A 도 그대로 본다', async () => {
const updated = await http()
.put(`/api/schedule-events/${eventId}`)
.set(as('b'))
.send({
title: 'B 가 고친 산책',
kind: 'general',
category: 'walk',
targetIds: [petId, 'self'],
})
.expect(200);
// 대상은 일정 주인(A)의 아이와 A 자신으로 확인·표시한다
expect(data<EventBody>(updated).targets.map((t) => t.name)).toEqual([
'콩이',
'a',
]);
await http()
.put(`/api/schedule-events/${eventId}/completion`)
.set(as('b'))
.expect(204);
await http()
.put(`/api/repeat-rules/${ruleId}/completions/2026-10-11`)
.set(as('b'))
.expect(204);
const seen = (await eventsOf('a')).find((e) => e.id === eventId);
expect(seen?.title).toBe('B 가 고친 산책');
expect(seen?.isDone).toBe(true);
const completions = await http()
.get('/api/repeat-rule-completions')
.set(as('a'))
.expect(200);
expect(
data<{ items: { ruleId: string; date: string }[] }>(completions).items,
).toContainEqual({ ruleId, date: '2026-10-11' });
});
it('새 일정은 내 아이만 대상으로 한다 — B 가 A 의 아이로 만들면 VAL_001', async () => {
const res = await http()
.post('/api/schedule-events')
.set(as('b'))
.send({
events: [
{
date: '2026-10-21',
title: '남의 아이',
kind: 'general',
category: 'walk',
targetIds: [petId],
},
],
})
.expect(400);
expect(errorCode(res)).toBe('VAL_001');
});
it('연동한 사람의 프로필·아이 사진은 받고, 바깥 사람은 RES_001', async () => {
for (const fileId of [petPhotoId, profilePhotoId]) {
await http().get(`/api/files/${fileId}`).set(as('b')).expect(200);
const denied = await http()
.get(`/api/files/${fileId}`)
.set(as('e'))
.expect(404);
expect(errorCode(denied)).toBe('RES_001');
}
});
it('C 도 A 의 코드로 들어와 셋이 된다', async () => {
const joined = await join('c', await codeOf('a')).expect(201);
expect(memberIds(data<SharingBody>(joined))).toEqual([ids.c, ids.a, ids.b]);
});
it('이미 연동된 사람(SCHED_005)·내 코드(SCHED_003)는 거절한다', async () => {
for (const owner of ['a', 'c'] as const) {
const again = await join('b', await codeOf(owner)).expect(409);
expect(errorCode(again)).toBe('SCHED_005');
}
const own = await join('a', await codeOf('a')).expect(400);
expect(errorCode(own)).toBe('SCHED_003');
});
it('4명이 차면 SCHED_002, 다른 사람과 연동 중이면 SCHED_004', async () => {
await join('d', await codeOf('a')).expect(201);
const full = await join('e', await codeOf('b')).expect(409);
expect(errorCode(full)).toBe('SCHED_002');
const busy = await join('b', await codeOf('e')).expect(409);
expect(errorCode(busy)).toBe('SCHED_004');
// 해제하면 나간 사람의 코드가 바뀐다
const dCode = await codeOf('d');
await leave('d');
await expectRotated('d', dCode);
expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.b, ids.c]);
});
it('B 가 해제하면 서로의 일정이 사라지고 B 의 일정은 B 에게 남는다', async () => {
const mine = await http()
.post('/api/schedule-events')
.set(as('b'))
.send({
events: [
{
date: '2026-10-22',
title: 'B 의 일정',
kind: 'general',
category: 'etc',
targetIds: ['self'],
},
],
})
.expect(201);
const [bEvent] = data<EventBody[]>(mine);
expect((await eventsOf('a')).map((e) => e.id)).toContain(bEvent.id);
const bCode = await codeOf('b');
await leave('b');
await expectRotated('b', bCode);
const bSees = (await eventsOf('b')).map((e) => e.id);
expect(bSees).toContain(bEvent.id);
expect(bSees).not.toContain(eventId);
expect((await eventsOf('a')).map((e) => e.id)).not.toContain(bEvent.id);
const edit = await http()
.put(`/api/schedule-events/${eventId}/completion`)
.set(as('b'))
.expect(404);
expect(errorCode(edit)).toBe('RES_001');
expect((await sharing('b')).linked).toBe(false);
expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.c]);
});
it('둘만 남은 묶음에서 한 명이 나가면 묶음이 지워진다', async () => {
await leave('c');
const a = await sharing('a');
expect(a.linked).toBe(false);
expect(memberIds(a)).toEqual([ids.a]);
const rows = await app
.get(DataSource)
.query<
{ count: string }[]
>('SELECT count(*) FROM calendar_group_members WHERE user_id = ANY($1)', [[ids.a, ids.c]]);
expect(rows[0].count).toBe('0');
// 연동 중이 아니어도 해제는 204 — 빠진 게 없으니 코드도 그대로
const cCode = await codeOf('c');
await leave('c');
expect(await codeOf('c')).toBe(cCode);
});
it('함께하던 사람이 탈퇴해 혼자 남으면 연동되지 않은 것으로 보인다 — 정리는 해제할 때 (GET 은 쓰지 않는다)', async () => {
await join('e', await codeOf('d')).expect(201);
await http().delete('/api/users/me').set(as('d')).expect(204);
const countRows = async () =>
(
await app
.get(DataSource)
.query<
{ count: string }[]
>('SELECT count(*) FROM calendar_group_members WHERE user_id = $1', [ids.e])
)[0].count;
const e = await sharing('e');
expect(e.linked).toBe(false);
expect(memberIds(e)).toEqual([ids.e]);
expect(await countRows()).toBe('1');
await leave('e');
expect(await countRows()).toBe('0');
});
it('없는 코드나 형식이 틀린 코드는 SCHED_001', async () => {
const preview = await http()
.get('/api/calendar-invitations/RUP-000000')
.set(as('a'))
.expect(404);
expect(errorCode(preview)).toBe('SCHED_001');
const bad = await join('a', '엉뚱한 코드').expect(404);
expect(errorCode(bad)).toBe('SCHED_001');
});
});
+529
View File
@@ -0,0 +1,529 @@
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app';
/**
* 공유 다이어리 — 초대 코드로 참여하고, 함께 기록·댓글을 남기고, 내보내기·나가기·공유 끄기까지.
* 회원 여럿을 개발용 로그인으로 만들고 끝나면 모두 탈퇴한다.
*/
/** 1×1 PNG — 업로드가 매직 바이트를 확인하므로 진짜 PNG 여야 한다 */
const TINY_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
interface Member {
userId: string;
nickname: string | null;
isOwner: boolean;
}
interface DiaryBody {
id: string;
shared: boolean;
ownerId: string;
isOwner: boolean;
inviteCode: string | null;
mutedNotices: string[];
members: Member[];
targets: { id: string; name: string | null }[];
}
interface Author {
userId: string;
nickname: string | null;
}
interface Page<T> {
items: T[];
}
/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */
const data = <T>(res: Response): T => (res.body as { data: T }).data;
const errorCode = (res: Response): string | undefined =>
(res.body as { error?: { code?: string } }).error?.code;
describe('공유 다이어리 (e2e)', () => {
let app: INestApplication<App>;
const tokens: Record<string, string> = {};
const ids: Record<string, string> = {};
const people = ['owner', 'member', 'outsider', 'third', 'fifth'] as const;
type Person = (typeof people)[number];
const as = (who: Person) => ({ Authorization: `Bearer ${tokens[who]}` });
const http = () => request(app.getHttpServer());
let diaryId = '';
let inviteCode = '';
let coverFileId = '';
let ownerPhotoId = '';
let petId = '';
let petPhotoId = '';
let postId = '';
const uploadPng = async (who: Person): Promise<string> => {
const res = await http()
.post('/api/files')
.set(as(who))
.attach('file', TINY_PNG, {
filename: 'photo.png',
contentType: 'image/png',
})
.expect(201);
return data<{ id: string }>(res).id;
};
const fileStatus = async (who: Person, fileId: string): Promise<number> =>
(await http().get(`/api/files/${fileId}`).set(as(who))).status;
/**
* 누가 빠지면 초대 코드가 바뀐다 — 빠진 사람이 예전 코드로 다시 들어오면 DIARY_003, 주인은 새 코드를 본다.
* 뒤의 테스트를 위해 새 코드를 기억해 둔다
*/
const expectCodeRotated = async (leaver: Person): Promise<void> => {
const previous = inviteCode;
const stale = await http()
.post('/api/diary-memberships')
.set(as(leaver))
.send({ code: previous })
.expect(404);
expect(errorCode(stale)).toBe('DIARY_003');
const list = await http().get('/api/diaries').set(as('owner')).expect(200);
const current = data<Page<DiaryBody>>(list).items.find(
(d) => d.id === diaryId,
)?.inviteCode;
expect(current).toMatch(/^RUP-[A-Z2-9]{6}$/);
expect(current).not.toBe(previous);
inviteCode = current ?? '';
};
beforeAll(async () => {
app = await createE2eApp();
for (const who of people) {
tokens[who] = await devLogin(app, uniqueDeviceKey(who));
const me = await http()
.patch('/api/users/me')
.set(as(who))
.send({ nickname: who })
.expect(200);
ids[who] = data<{ id: string }>(me).id;
}
});
afterAll(async () => {
for (const who of people) {
await http().delete('/api/users/me').set(as(who));
}
await app.close();
});
it('주인이 공유 다이어리를 만들면 초대 코드가 생긴다', async () => {
coverFileId = await uploadPng('owner');
ownerPhotoId = await uploadPng('owner');
petPhotoId = await uploadPng('owner');
await http()
.patch('/api/users/me')
.set(as('owner'))
.send({ photoFileId: ownerPhotoId })
.expect(200);
const pet = await http()
.post('/api/pets')
.set(as('owner'))
.send({
tint: '#FF9B6A',
name: '콩이',
species: '강아지',
breed: '',
gender: 'male',
neutered: true,
birthDate: '2021-09-14',
weight: 3.2,
weightUnit: 'kg',
allergies: [],
conditions: [],
watchConditions: [],
photoFileId: petPhotoId,
})
.expect(201);
petId = data<{ id: string }>(pet).id;
const res = await http()
.post('/api/diaries')
.set(as('owner'))
.send({
name: '함께 쓰는 다이어리',
targetIds: ['self', petId],
ribbon: '#FF7500',
tint: '#FF8A5B',
coverFileId,
shared: true,
})
.expect(201);
const diary = data<DiaryBody>(res);
diaryId = diary.id;
inviteCode = diary.inviteCode ?? '';
expect(inviteCode).toMatch(/^RUP-[A-Z2-9]{6}$/);
expect(diary.shared).toBe(true);
expect(diary.isOwner).toBe(true);
expect(diary.ownerId).toBe(ids.owner);
expect(diary.members).toEqual([
expect.objectContaining({ userId: ids.owner, isOwner: true }),
]);
expect(diary.targets).toEqual([
{
id: 'self',
name: 'owner',
photoFileId: ownerPhotoId,
tint: null,
archived: false,
},
{
id: petId,
name: '콩이',
photoFileId: petPhotoId,
tint: '#FF9B6A',
archived: false,
},
]);
});
it('코드를 받은 사람은 참여 전에 미리 본다 (대소문자 무관)', async () => {
const res = await http()
.get(`/api/diary-invitations/${inviteCode.toLowerCase()}`)
.set(as('member'))
.expect(200);
expect(data(res)).toEqual({
diaryId,
name: '함께 쓰는 다이어리',
ownerNickname: 'owner',
memberCount: 1,
maxMembers: 4,
isMember: false,
});
});
it('참여하면 목록에 보이고, 주인이 아니므로 초대 코드는 없다', async () => {
const joined = await http()
.post('/api/diary-memberships')
.set(as('member'))
.send({ code: inviteCode })
.expect(201);
expect(data<DiaryBody>(joined).isOwner).toBe(false);
const list = await http().get('/api/diaries').set(as('member')).expect(200);
const diary = data<Page<DiaryBody>>(list).items.find(
(d) => d.id === diaryId,
);
expect(diary?.isOwner).toBe(false);
expect(diary?.inviteCode).toBeNull();
expect(diary?.members.map((m) => m.userId)).toEqual([
ids.owner,
ids.member,
]);
});
it('이미 함께하고 있으면 DIARY_005 (주인 포함)', async () => {
for (const who of ['member', 'owner'] as const) {
const res = await http()
.post('/api/diary-memberships')
.set(as(who))
.send({ code: inviteCode })
.expect(409);
expect(errorCode(res)).toBe('DIARY_005');
}
});
it('주인 포함 4명이 차면 DIARY_004', async () => {
for (const who of ['outsider', 'third'] as const) {
await http()
.post('/api/diary-memberships')
.set(as(who))
.send({ code: inviteCode })
.expect(201);
}
const full = await http()
.post('/api/diary-memberships')
.set(as('fifth'))
.send({ code: inviteCode })
.expect(409);
expect(errorCode(full)).toBe('DIARY_004');
// 뒤의 테스트를 위해 둘은 다시 나간다 — outsider 는 바깥 사람으로 쓴다
for (const who of ['outsider', 'third'] as const) {
await http()
.delete(`/api/diaries/${diaryId}/membership`)
.set(as(who))
.expect(204);
await expectCodeRotated(who);
}
});
it('함께하는 사람이 남긴 기록·댓글을 주인이 쓴 사람과 함께 본다', async () => {
const post = await http()
.post(`/api/diaries/${diaryId}/posts`)
.set(as('member'))
.send({ date: '2026-10-11', text: '오늘 산책했어요' })
.expect(201);
postId = data<{ id: string }>(post).id;
expect(data<{ author: Author }>(post).author).toEqual({
userId: ids.member,
nickname: 'member',
photoFileId: null,
});
await http()
.post(`/api/diary-posts/${postId}/comments`)
.set(as('member'))
.send({ text: '귀여워' })
.expect(201);
const posts = await http()
.get('/api/diary-posts')
.set(as('owner'))
.expect(200);
const seen = data<Page<{ id: string; author: Author }>>(posts).items.find(
(p) => p.id === postId,
);
expect(seen?.author.nickname).toBe('member');
const comments = await http()
.get('/api/diary-comments')
.set(as('owner'))
.expect(200);
expect(
data<Page<{ postId: string; author: Author }>>(comments).items,
).toContainEqual(
expect.objectContaining({
postId,
author: expect.objectContaining({ nickname: 'member' }) as Author,
}),
);
});
it('함께하는 사람은 표지 사진을 받고, 바깥 사람은 RES_001', async () => {
const ok = await http()
.get(`/api/files/${coverFileId}`)
.set(as('member'))
.expect(200);
expect(ok.headers['content-type']).toContain('image/png');
const denied = await http()
.get(`/api/files/${coverFileId}`)
.set(as('outsider'))
.expect(404);
expect(errorCode(denied)).toBe('RES_001');
});
it('다이어리에서 보이는 사진(프로필·대상 아이·기록 사진)은 함께하는 사람끼리만 받는다', async () => {
const memberPhotoId = await uploadPng('member');
const postPhotoId = await uploadPng('member');
const unrelatedId = await uploadPng('owner');
await http()
.patch('/api/users/me')
.set(as('member'))
.send({ photoFileId: memberPhotoId })
.expect(200);
await http()
.post(`/api/diaries/${diaryId}/posts`)
.set(as('member'))
.send({ date: '2026-10-10', text: '사진도', photoFileId: postPhotoId })
.expect(201);
// 함께하는 사람: 주인 프로필·대상 아이 사진을 받는다
expect(await fileStatus('member', ownerPhotoId)).toBe(200);
expect(await fileStatus('member', petPhotoId)).toBe(200);
// 주인: 함께하는 사람의 프로필·기록 사진을 받는다
expect(await fileStatus('owner', memberPhotoId)).toBe(200);
expect(await fileStatus('owner', postPhotoId)).toBe(200);
// 다이어리에 보이지 않는 주인의 다른 파일은 받지 못한다
expect(await fileStatus('member', unrelatedId)).toBe(404);
// 바깥 사람은 어느 것도 받지 못한다
for (const id of [ownerPhotoId, petPhotoId, memberPhotoId, postPhotoId]) {
expect(await fileStatus('outsider', id)).toBe(404);
}
});
it('바깥 사람에게는 다이어리가 없는 것과 같다 (RES_001)', async () => {
const res = await http()
.post(`/api/diaries/${diaryId}/posts`)
.set(as('outsider'))
.send({ date: '2026-10-11', text: '몰래' })
.expect(404);
expect(errorCode(res)).toBe('RES_001');
});
it('주인만 할 수 있는 일은 함께하는 사람에게 AUTH_003', async () => {
const patch = await http()
.patch(`/api/diaries/${diaryId}`)
.set(as('member'))
.send({ name: '바꿔 볼까' })
.expect(403);
expect(errorCode(patch)).toBe('AUTH_003');
const sharing = await http()
.put(`/api/diaries/${diaryId}/sharing`)
.set(as('member'))
.send({ shared: false })
.expect(403);
expect(errorCode(sharing)).toBe('AUTH_003');
});
it('알림 설정은 사람마다 따로다', async () => {
await http()
.put(`/api/diaries/${diaryId}/muted-notices`)
.set(as('member'))
.send({ mutedNotices: ['reminder'] })
.expect(204);
const mine = await http().get('/api/diaries').set(as('member')).expect(200);
const owners = await http()
.get('/api/diaries')
.set(as('owner'))
.expect(200);
const pick = (res: Response) =>
data<Page<DiaryBody>>(res).items.find((d) => d.id === diaryId);
expect(pick(mine)?.mutedNotices).toEqual(['reminder']);
expect(pick(owners)?.mutedNotices).toEqual([]);
});
it('주인은 나갈 수도, 자신을 내보낼 수도 없다 (BIZ_001)', async () => {
const leave = await http()
.delete(`/api/diaries/${diaryId}/membership`)
.set(as('owner'))
.expect(400);
expect(errorCode(leave)).toBe('BIZ_001');
const kickSelf = await http()
.delete(`/api/diaries/${diaryId}/members/${ids.owner}`)
.set(as('owner'))
.expect(400);
expect(errorCode(kickSelf)).toBe('BIZ_001');
});
it('내보내진 사람은 다이어리에 닿지 못하고 예전 코드로 돌아오지 못한다 (기록은 남는다)', async () => {
await http()
.delete(`/api/diaries/${diaryId}/members/${ids.member}`)
.set(as('owner'))
.expect(204);
await expectCodeRotated('member');
const write = await http()
.post(`/api/diaries/${diaryId}/posts`)
.set(as('member'))
.send({ date: '2026-10-12', text: '다시' })
.expect(404);
expect(errorCode(write)).toBe('RES_001');
const edit = await http()
.patch(`/api/diary-posts/${postId}`)
.set(as('member'))
.send({ text: '고칠래' })
.expect(404);
expect(errorCode(edit)).toBe('RES_001');
const cover = await http()
.get(`/api/files/${coverFileId}`)
.set(as('member'))
.expect(404);
expect(errorCode(cover)).toBe('RES_001');
const posts = await http()
.get('/api/diary-posts')
.set(as('owner'))
.expect(200);
expect(data<Page<{ id: string }>>(posts).items.map((p) => p.id)).toContain(
postId,
);
// 이미 빠진 사람을 다시 내보내면 RES_001 — 실패했으므로 코드도 그대로다
const again = await http()
.delete(`/api/diaries/${diaryId}/members/${ids.member}`)
.set(as('owner'))
.expect(404);
expect(errorCode(again)).toBe('RES_001');
await http()
.get(`/api/diary-invitations/${inviteCode}`)
.set(as('member'))
.expect(200);
});
it('공유를 끄면 주인 말고는 모두 빠지고 예전 코드는 쓸 수 없다', async () => {
await http()
.post('/api/diary-memberships')
.set(as('member'))
.send({ code: inviteCode })
.expect(201);
const off = await http()
.put(`/api/diaries/${diaryId}/sharing`)
.set(as('owner'))
.send({ shared: false })
.expect(200);
const diary = data<DiaryBody>(off);
expect(diary.shared).toBe(false);
expect(diary.inviteCode).toBeNull();
expect(diary.members.map((m) => m.userId)).toEqual([ids.owner]);
const list = await http().get('/api/diaries').set(as('member')).expect(200);
expect(data<Page<DiaryBody>>(list).items.map((d) => d.id)).not.toContain(
diaryId,
);
const preview = await http()
.get(`/api/diary-invitations/${inviteCode}`)
.set(as('member'))
.expect(404);
expect(errorCode(preview)).toBe('DIARY_003');
});
it('다시 켜면 새 코드가 생기고, 참여한 사람은 스스로 나갈 수 있다', async () => {
const on = await http()
.put(`/api/diaries/${diaryId}/sharing`)
.set(as('owner'))
.send({ shared: true })
.expect(200);
const fresh = data<DiaryBody>(on).inviteCode ?? '';
expect(fresh).toMatch(/^RUP-[A-Z2-9]{6}$/);
expect(fresh).not.toBe(inviteCode);
inviteCode = fresh;
await http()
.post('/api/diary-memberships')
.set(as('member'))
.send({ code: fresh })
.expect(201);
await http()
.delete(`/api/diaries/${diaryId}/membership`)
.set(as('member'))
.expect(204);
await expectCodeRotated('member');
const list = await http().get('/api/diaries').set(as('member')).expect(200);
expect(data<Page<DiaryBody>>(list).items.map((d) => d.id)).not.toContain(
diaryId,
);
});
it('없는 코드는 DIARY_003', async () => {
const preview = await http()
.get('/api/diary-invitations/RUP-ZZZZZZ')
.set(as('member'))
.expect(404);
expect(errorCode(preview)).toBe('DIARY_003');
const join = await http()
.post('/api/diary-memberships')
.set(as('member'))
.send({ code: '엉뚱한 코드' })
.expect(404);
expect(errorCode(join)).toBe('DIARY_003');
});
});
+2 -1
View File
@@ -12,5 +12,6 @@
"testRegex": "test/.*\\.e2e-spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
},
"testTimeout": 30000
}
+160
View File
@@ -0,0 +1,160 @@
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { DataSource } from 'typeorm';
import { FileSweeper } from '@/modules/upload/file-sweeper';
import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app';
/**
* 남용·찌꺼기 막기 — 코드 조회 한도(회원별 한 통), 탈퇴할 때 남의 사진 정리, 쓰이지 않는 파일 정리.
* 회원 여럿을 개발용 로그인으로 만들고 끝나면 모두 탈퇴한다.
*/
/** 1×1 PNG — 업로드가 매직 바이트를 확인하므로 진짜 PNG 여야 한다 */
const TINY_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
/** 코드 조회 한도 (common/decorators/invite-code-lookup.decorator.ts) */
const INVITE_LIMIT = 30;
const data = <T>(res: Response): T => (res.body as { data: T }).data;
const errorCode = (res: Response): string | undefined =>
(res.body as { error?: { code?: string } }).error?.code;
describe('한도·정리 (e2e)', () => {
let app: INestApplication<App>;
const tokens: Record<string, string> = {};
const people = ['guesser', 'bystander', 'owner', 'member', 'uploader'];
const as = (who: string) => ({ Authorization: `Bearer ${tokens[who]}` });
const http = () => request(app.getHttpServer());
const uploadPng = async (who: string): Promise<string> => {
const res = await http()
.post('/api/files')
.set(as(who))
.attach('file', TINY_PNG, {
filename: 'photo.png',
contentType: 'image/png',
})
.expect(201);
return data<{ id: string }>(res).id;
};
beforeAll(async () => {
app = await createE2eApp();
for (const who of people) {
tokens[who] = await devLogin(app, uniqueDeviceKey(`lim-${who}`));
}
});
afterAll(async () => {
for (const who of people) {
await http().delete('/api/users/me').set(as(who));
}
await app.close();
});
it('코드로 찾는 엔드포인트는 회원별로 한 통에서 센다 — 넘으면 어느 엔드포인트든 REQ_001', async () => {
// 형식이 틀린 코드라 DB 의 실제 코드와 겹치지 않는다 — 실패해도 한도에는 센다
const lookups = [
() => http().get('/api/diary-invitations/RUP-000000'),
() => http().get('/api/calendar-invitations/RUP-000000'),
() => http().get('/api/shared-pet-cards/not-a-code'),
() => http().get('/api/shared-pet-cards/not-a-code/images/pet'),
() => http().post('/api/diary-memberships').send({ code: 'nope' }),
() => http().post('/api/calendar-memberships').send({ code: 'nope' }),
];
for (let i = 0; i < INVITE_LIMIT; i += 1) {
const res = await lookups[i % lookups.length]().set(as('guesser'));
expect(res.status).not.toBe(429);
}
for (const lookup of [
() => http().post('/api/received-cards').send({ code: '0000-000' }),
() => http().get('/api/calendar-invitations/RUP-000000'),
]) {
const blocked = await lookup().set(as('guesser')).expect(429);
expect(errorCode(blocked)).toBe('REQ_001');
}
// 다른 회원은 자기 통을 쓴다 (같은 IP 라도)
const other = await http()
.get('/api/diary-invitations/RUP-000000')
.set(as('bystander'))
.expect(404);
expect(errorCode(other)).toBe('DIARY_003');
});
it('다이어리 주인이 탈퇴하면 함께하는 사람이 그 다이어리에 올린 사진도 정리된다', async () => {
const created = await http()
.post('/api/diaries')
.set(as('owner'))
.send({
name: '탈퇴 정리',
targetIds: ['self'],
ribbon: '#FF7500',
tint: '#FF8A5B',
shared: true,
})
.expect(201);
const diary = data<{ id: string; inviteCode: string }>(created);
await http()
.post('/api/diary-memberships')
.set(as('member'))
.send({ code: diary.inviteCode })
.expect(201);
const postPhotoId = await uploadPng('member');
const profilePhotoId = await uploadPng('member');
await http()
.patch('/api/users/me')
.set(as('member'))
.send({ photoFileId: profilePhotoId })
.expect(200);
await http()
.post(`/api/diaries/${diary.id}/posts`)
.set(as('member'))
.send({ date: '2026-10-11', text: '사진', photoFileId: postPhotoId })
.expect(201);
await http().delete('/api/users/me').set(as('owner')).expect(204);
const gone = await http()
.get(`/api/files/${postPhotoId}`)
.set(as('member'))
.expect(404);
expect(errorCode(gone)).toBe('RES_001');
// 다이어리와 상관없는 내 사진은 그대로
await http()
.get(`/api/files/${profilePhotoId}`)
.set(as('member'))
.expect(200);
});
it('하루가 지나도 아무도 쓰지 않는 파일은 정리하고, 쓰는 파일은 남긴다', async () => {
const orphanId = await uploadPng('uploader');
const usedId = await uploadPng('uploader');
await http()
.patch('/api/users/me')
.set(as('uploader'))
.send({ photoFileId: usedId })
.expect(200);
// 올린 지 이틀이 지난 것으로 만든다
await app
.get(DataSource)
.query(
`UPDATE files SET created_at = now() - interval '2 days' WHERE id = ANY($1)`,
[[orphanId, usedId]],
);
const released = await app.get(FileSweeper).sweep();
expect(released).toBeGreaterThanOrEqual(1);
await http().get(`/api/files/${orphanId}`).set(as('uploader')).expect(404);
await http().get(`/api/files/${usedId}`).set(as('uploader')).expect(200);
});
});

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