generated from ttipo/project-base
Compare commits
10 Commits
688e72bf04
...
5458c449f8
| Author | SHA1 | Date | |
|---|---|---|---|
| 5458c449f8 | |||
| aba3d801fc | |||
| 02c7ec0079 | |||
| 043e14a3fa | |||
| 3f84d7b747 | |||
| b10e90fe5c | |||
| 14e2bddb96 | |||
| 61cfc4461f | |||
| b6c5496a05 | |||
| f50ab85a75 |
@@ -40,7 +40,8 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
- **설정이 잘못되면 서버가 뜨지 않아야 한다(fail-fast).** 조용히 기본값으로 굴러가다 운영에서 터지는 것이 더 위험하다.
|
||||
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
|
||||
- 숫자 항목은 형식 검사 (모든 환경)
|
||||
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME`/`JWT_SECRET`(32자 이상) 필수
|
||||
- 운영 환경: `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` 등)나
|
||||
|
||||
@@ -45,7 +45,11 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
|
||||
| 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 (클라이언트 전용)
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
- **모바일 앱은 예외** — 앱에는 쿠키 저장소가 없어 리프레시 토큰을 응답 본문으로 받아 `flutter_secure_storage`(키: `refresh_token`)에 둔다.
|
||||
대신 서버는 리프레시 토큰을 **해시로만 저장**하고, **한 번 쓰면 폐기(회전)** 하며, 폐기된 토큰이 다시 오면 그 회원의 토큰을 모두 폐기한다 (`modules/auth`)
|
||||
- 소셜 로그인은 앱이 보낸 사용자 id 를 믿지 않는다 — 서버가 토큰을 **제공자에게 직접 확인**한다 (카카오는 앱 ID 까지 대조)
|
||||
- 개발용 로그인(`/auth/dev-sessions`)은 운영에서 **코드로 닫힌다** — env 값으로 열 수 없다
|
||||
- 개발용 로그인(`/auth/dev-sessions`)과 개발용 서명 키는 `NODE_ENV` 가 **development·test 일 때만** 열린다 (allowlist, `shared/config/node-env.ts`).
|
||||
staging·production·오타·미설정은 모두 닫히고 `JWT_SECRET` 이 없으면 기동하지 않는다 — env 값으로 열 수 없다
|
||||
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
|
||||
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_001)
|
||||
|
||||
@@ -31,7 +32,15 @@
|
||||
|
||||
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
|
||||
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다. 저장한 뒤 **파일 앞부분(매직 바이트)도 확인**해 형식과 다르면 지운다.
|
||||
- 파일은 **올린 회원의 것**으로 기록하고, 내려받기(`GET /files/:id`)·다른 기록에서의 참조 모두 본인 파일만 허용한다.
|
||||
- 파일은 **올린 회원의 것**으로 기록하고, 다른 기록에서의 참조는 본인 파일만 허용한다.
|
||||
내려받기(`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 + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
|
||||
|
||||
@@ -26,7 +26,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| 세션 만료 처리 | `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>`. 기기 사진은 저장할 때 내용으로 형식을 확인해 올린다. 서버 사진은 Dio 로 받아 그린다 |
|
||||
| 사진 | `server_file.dart` · `file_uploader.dart` · `shared/widgets/photo_image.dart` | 사진 경로 자리에 `server-file:<id>` (내 것이 아닌 서버 경로는 `server-url:<경로>` — 받기 전 명함 미리보기). 기기 사진은 저장할 때 내용으로 형식을 확인해 올린다. 서버 사진은 Dio 로 받아 그린다 |
|
||||
| QR 스캐너 | `shared/widgets/qr_scanner_page.dart` | `QrScannerPage<T>` — 문구·코드 꺼내기·불러오기만 넘긴다(명함 받기·다이어리 참여·일정 연동). 결과는 `ScanFound` / `ScanTypeCode`. 테스트는 `scannerCameraProvider` 로 카메라를 바꿔 끼운다 |
|
||||
| 함께 쓰는 기록 | `features/diary` · `features/schedule` | 서버가 준 작성자·멤버의 회원 id 가 나면 `kMeAuthorId`/`isMe` 로 바꿔 담는다. 남의 아이는 내 목록에 없으므로 서버가 준 이름·사진 요약(`targets`)으로 그린다. 기기 저장 모드는 `canShare`/`available` 이 false 라 안내만 띄운다 |
|
||||
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | 세션(토큰)을 첫 프레임 전에 읽어 override 한다 |
|
||||
| 도메인 feature(패턴 참고) | `lib/features/pet/` | 저장소 **계약 + `Local*` + `Api*`**, 프로바이더가 `DATA_MODE` 로 고른다. 알림기 build 는 `sessionEpochProvider` 를 watch |
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| 전역 ValidationPipe | `main.ts` | `whitelist/forbidNonWhitelisted/transform` |
|
||||
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터 |
|
||||
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트 |
|
||||
| Rate Limiting | `app.module.ts` 의 `ThrottlerGuard`(APP_GUARD) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정 |
|
||||
| Rate Limiting | `common/guards/throttler.guards.ts` (APP_GUARD) | IP 단위 기본 한도(`THROTTLE_*`) → 인증 → 코드 조회 한도(`@InviteCodeLookup()`, 회원 단위 한 바구니). 초과 시 429 → `REQ_001` |
|
||||
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
|
||||
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
|
||||
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off |
|
||||
@@ -38,7 +38,12 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| 공통 검증 데코레이터 | `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` | 소유자 기록·매직 바이트 검사·`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/` | 순수 함수 분리 위치 |
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -98,6 +98,32 @@ const DOMAIN_ERROR_CODES = {
|
||||
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,
|
||||
@@ -113,6 +139,16 @@ const DOMAIN_ERROR_CODES = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('소셜 설정은 비어 있으면 끈 것으로 본다', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDevNodeEnv } from '@/shared/config/node-env';
|
||||
import { parseDurationMs } from '@/shared/utils/duration';
|
||||
|
||||
/**
|
||||
@@ -15,8 +16,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 +28,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,10 +47,11 @@ 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';
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import { InviteCodeLookup } from '@/common/decorators/invite-code-lookup.decorator';
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
@@ -20,16 +20,10 @@ import {
|
||||
JoinDiaryDto,
|
||||
} from './dto/diary.dto';
|
||||
|
||||
/**
|
||||
* 초대 코드로 찾는 엔드포인트는 코드 추측의 표적이 된다 — 전역보다 엄격하게 막는다.
|
||||
* 분당 30회 — 코드를 몇 번 잘못 입력해도 충분하다.
|
||||
*/
|
||||
const INVITE_LOOKUP_THROTTLE = { default: { ttl: 60_000, limit: 30 } };
|
||||
|
||||
// 공유 다이어리 초대 — 코드를 받은 회원이 참여하기 전에 미리 본다
|
||||
@ApiTags('Diary')
|
||||
@ApiBearerAuth()
|
||||
@Throttle(INVITE_LOOKUP_THROTTLE)
|
||||
@InviteCodeLookup()
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@Controller('diary-invitations')
|
||||
export class DiaryInvitationsController {
|
||||
@@ -60,7 +54,7 @@ export class DiaryMembershipsController {
|
||||
constructor(private readonly diary: DiaryService) {}
|
||||
|
||||
@Post()
|
||||
@Throttle(INVITE_LOOKUP_THROTTLE)
|
||||
@InviteCodeLookup()
|
||||
@ApiOperation({
|
||||
summary: '초대 코드로 다이어리 참여',
|
||||
description:
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { toMemberViews, toTargetViews } from './diary-view';
|
||||
import type { TargetPetSource } from './diary-view';
|
||||
import { toMemberViews } from './diary-view';
|
||||
|
||||
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('함께하는 사람', () => {
|
||||
it('주인이 맨 앞, 나머지는 들어온 순서', () => {
|
||||
const views = toMemberViews(
|
||||
@@ -40,50 +27,3 @@ describe('함께하는 사람', () => {
|
||||
expect(view.photoFileId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
toTargetViews(['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(toTargetViews(['gone', 'pet-x'], OWNER, pets, owner)).toEqual([]);
|
||||
});
|
||||
|
||||
it('주인 정보를 모르면 self 도 뺀다', () => {
|
||||
expect(toTargetViews(['self'], OWNER, new Map(), null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
// 다이어리 응답 조립 — 부수효과 없는 순수 함수 (함께하는 사람·대상 표시)
|
||||
// 다이어리 응답 조립 — 부수효과 없는 순수 함수 (함께하는 사람 표시)
|
||||
// 대상 표시는 일정과 함께 쓰는 @/modules/pet/pet-targets 의 resolveTargets 를 쓴다.
|
||||
|
||||
import { SELF_TARGET_ID } from '@/common/validators';
|
||||
import type { DiaryMemberDto, DiaryTargetDto } from './dto/diary.dto';
|
||||
|
||||
/** 회원의 보이는 정보 — 닉네임·프로필 사진 */
|
||||
export interface ProfileSource {
|
||||
readonly nickname: string | null;
|
||||
readonly photoFileId: string | null;
|
||||
}
|
||||
import type { ProfileSource } from '@/modules/pet/pet-targets';
|
||||
import type { DiaryMemberDto } from './dto/diary.dto';
|
||||
|
||||
/** 함께하는 사람 한 줄 (diary_members + users) */
|
||||
export interface MemberSource {
|
||||
@@ -16,16 +11,6 @@ export interface MemberSource {
|
||||
readonly user?: ProfileSource | null;
|
||||
}
|
||||
|
||||
/** 다이어리 대상이 될 수 있는 아이 */
|
||||
export interface TargetPetSource {
|
||||
readonly id: string;
|
||||
readonly ownerId: string;
|
||||
readonly name: string;
|
||||
readonly photoFileId: string | null;
|
||||
readonly tint: string;
|
||||
readonly archivedAt: Date | null;
|
||||
}
|
||||
|
||||
/** 함께하는 사람 — 주인이 맨 앞, 나머지는 들어온 순서 */
|
||||
export function toMemberViews(
|
||||
members: readonly MemberSource[],
|
||||
@@ -45,39 +30,3 @@ export function toMemberViews(
|
||||
joinedAt: m.joinedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 다이어리 대상 — targetIds 순서대로. 아이는 다이어리 주인의 아이만, "self" 는 주인 자신이다.
|
||||
* 더 이상 찾을 수 없는 대상은 뺀다.
|
||||
*/
|
||||
export function toTargetViews(
|
||||
targetIds: readonly string[],
|
||||
ownerId: string,
|
||||
pets: ReadonlyMap<string, TargetPetSource>,
|
||||
owner: ProfileSource | null,
|
||||
): DiaryTargetDto[] {
|
||||
const views: DiaryTargetDto[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
]),
|
||||
PetModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
],
|
||||
controllers: [
|
||||
DiariesController,
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
PaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { SELF_TARGET_ID } from '@/common/validators';
|
||||
import { Pet } from '@/modules/pet/entities/pet.entity';
|
||||
import {
|
||||
PetLifecycleEvent,
|
||||
@@ -26,7 +25,16 @@ import {
|
||||
import { PetService } from '@/modules/pet/pet.service';
|
||||
import { UploadService } from '@/modules/upload/upload.service';
|
||||
import { User } from '@/modules/user/entities/user.entity';
|
||||
import { isUniqueViolation } from '@/shared/utils/db-error';
|
||||
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,
|
||||
@@ -49,7 +57,7 @@ import {
|
||||
DiaryPost,
|
||||
} from './entities/diary.entity';
|
||||
import { countShelfDiaries } from './diary-shelf';
|
||||
import { normalizeInviteCode, randomInviteCode } from './invite-code';
|
||||
import { DIARY_INVITE_CODE } from './invite-code';
|
||||
|
||||
/** 초대 코드가 겹쳤을 때 다시 뽑는 횟수 — 32^6 가지라 실제로 겹치는 일은 드물다 */
|
||||
const CODE_RETRY = 5;
|
||||
@@ -80,22 +88,17 @@ function onlyMemberOf<T extends ObjectLiteral>(
|
||||
}
|
||||
|
||||
/** 초대 코드가 겹치면(유니크 위반) 새 코드로 처음부터 다시 한다 */
|
||||
async function withFreshInviteCode<T>(run: () => Promise<T>): Promise<T> {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error) || attempt >= CODE_RETRY) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
@@ -113,10 +116,12 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 다이어리 ───────────────────────────────────────────
|
||||
@@ -159,7 +164,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
|
||||
tint: dto.tint,
|
||||
coverFileId: dto.coverFileId ?? null,
|
||||
shared,
|
||||
inviteCode: shared ? randomInviteCode() : null,
|
||||
inviteCode: shared ? DIARY_INVITE_CODE.random() : null,
|
||||
}),
|
||||
);
|
||||
await manager.insert(DiaryMember, {
|
||||
@@ -234,7 +239,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
|
||||
if (dto.shared) {
|
||||
if (!diary.shared || diary.inviteCode === null) {
|
||||
await withFreshInviteCode(async () => {
|
||||
const inviteCode = diary.inviteCode ?? randomInviteCode();
|
||||
const inviteCode = diary.inviteCode ?? DIARY_INVITE_CODE.random();
|
||||
await this.diaries.update(
|
||||
{ id: diaryId },
|
||||
{ shared: true, inviteCode },
|
||||
@@ -319,7 +324,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
|
||||
await manager.update(
|
||||
Diary,
|
||||
{ id: diaryId, shared: true },
|
||||
{ inviteCode: randomInviteCode() },
|
||||
{ inviteCode: DIARY_INVITE_CODE.random() },
|
||||
);
|
||||
const result = await manager.delete(DiaryMember, { diaryId, userId });
|
||||
if (!result.affected) {
|
||||
@@ -336,7 +341,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
|
||||
userId: string,
|
||||
rawCode: string,
|
||||
): Promise<DiaryInvitationDto> {
|
||||
const code = normalizeInviteCode(rawCode);
|
||||
const code = DIARY_INVITE_CODE.normalize(rawCode);
|
||||
const diary =
|
||||
code === null
|
||||
? null
|
||||
@@ -370,7 +375,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
|
||||
userId: string,
|
||||
dto: JoinDiaryDto,
|
||||
): Promise<DiaryResponseDto> {
|
||||
const code = normalizeInviteCode(dto.code);
|
||||
const code = DIARY_INVITE_CODE.normalize(dto.code);
|
||||
if (code === null) {
|
||||
throw new BusinessException('DIARY_003', { context: { code: dto.code } });
|
||||
}
|
||||
@@ -531,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({
|
||||
@@ -548,16 +573,16 @@ 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 },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
const diaryIds = memberships.map((m) => m.diaryId);
|
||||
const diaries =
|
||||
@@ -601,24 +626,9 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener {
|
||||
where: { diaryId: In(diaries.map((d) => d.id)) },
|
||||
relations: { user: true },
|
||||
});
|
||||
const petIds = [...new Set(diaries.flatMap((d) => d.targetIds))].filter(
|
||||
(id) => id !== SELF_TARGET_ID,
|
||||
const petById = await this.pets.findTargetPets(
|
||||
diaries.flatMap((d) => d.targetIds),
|
||||
);
|
||||
const pets =
|
||||
petIds.length === 0
|
||||
? []
|
||||
: await this.dataSource.manager.find(Pet, {
|
||||
where: { id: In(petIds) },
|
||||
select: {
|
||||
id: true,
|
||||
ownerId: true,
|
||||
name: true,
|
||||
photoFileId: true,
|
||||
tint: true,
|
||||
archivedAt: true,
|
||||
},
|
||||
});
|
||||
const petById = new Map(pets.map((p) => [p.id, p]));
|
||||
|
||||
return diaries.map((diary) =>
|
||||
DiaryResponseDto.from(diary, {
|
||||
|
||||
@@ -19,8 +19,9 @@ import {
|
||||
|
||||
import { IsHexColor, IsIsoDate, IsTargetIdList } from '@/common/validators';
|
||||
import type { User } from '@/modules/user/entities/user.entity';
|
||||
import { toMemberViews, toTargetViews } from '../diary-view';
|
||||
import type { TargetPetSource } from '../diary-view';
|
||||
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,
|
||||
@@ -350,7 +351,7 @@ export class DiaryResponseDto {
|
||||
isOwner,
|
||||
inviteCode: isOwner && diary.shared ? diary.inviteCode : null,
|
||||
members: toMemberViews(context.members, diary.ownerId),
|
||||
targets: toTargetViews(
|
||||
targets: resolveTargets(
|
||||
diary.targetIds,
|
||||
diary.ownerId,
|
||||
context.pets,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
|
||||
import { User } from '@/modules/user/entities/user.entity';
|
||||
import { INVITE_CODE_LENGTH } from '../invite-code';
|
||||
import { DIARY_INVITE_CODE } from '../invite-code';
|
||||
|
||||
/** 다이어리 규칙·입력 한도 — 앱의 diary.dart 상수와 맞춘다 */
|
||||
export const DIARY_LIMITS = {
|
||||
@@ -77,6 +77,7 @@ export class Diary {
|
||||
@JoinColumn({ name: 'cover_file_id' })
|
||||
coverFile?: StoredFile | null;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
coverFileId!: string | null;
|
||||
|
||||
@@ -87,7 +88,7 @@ export class Diary {
|
||||
/** 초대 코드 `RUP-XXXXXX` — 공유 중일 때만 있다. 공유를 끄면 지워 예전 코드로는 들어올 수 없다 */
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: INVITE_CODE_LENGTH,
|
||||
length: DIARY_INVITE_CODE.length,
|
||||
nullable: true,
|
||||
unique: true,
|
||||
})
|
||||
@@ -153,6 +154,7 @@ export class DiaryPost {
|
||||
@JoinColumn({ name: 'author_id' })
|
||||
author?: User;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'uuid' })
|
||||
authorId!: string;
|
||||
|
||||
@@ -170,6 +172,7 @@ export class DiaryPost {
|
||||
@JoinColumn({ name: 'photo_file_id' })
|
||||
photoFile?: StoredFile | null;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
photoFileId!: string | null;
|
||||
|
||||
@@ -199,6 +202,7 @@ export class DiaryComment {
|
||||
@JoinColumn({ name: 'author_id' })
|
||||
author?: User;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'uuid' })
|
||||
authorId!: string;
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import {
|
||||
INVITE_CODE_ALPHABET,
|
||||
INVITE_CODE_LENGTH,
|
||||
normalizeInviteCode,
|
||||
randomInviteCode,
|
||||
} from './invite-code';
|
||||
|
||||
describe('초대 코드', () => {
|
||||
it('RUP- 뒤에 6자를 붙여 10자로 만든다', () => {
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const code = randomInviteCode();
|
||||
expect(code).toHaveLength(INVITE_CODE_LENGTH);
|
||||
expect(code).toMatch(/^RUP-[A-Z2-9]{6}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('헷갈리는 글자(0·O·1·I)는 쓰지 않는다', () => {
|
||||
expect(INVITE_CODE_ALPHABET).not.toMatch(/[0O1I]/);
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const body = randomInviteCode().slice('RUP-'.length);
|
||||
for (const ch of body) expect(INVITE_CODE_ALPHABET).toContain(ch);
|
||||
}
|
||||
});
|
||||
|
||||
it('입력한 코드는 앞뒤 공백을 지우고 대문자로 맞춘다', () => {
|
||||
expect(normalizeInviteCode(' rup-7kq2mx ')).toBe('RUP-7KQ2MX');
|
||||
expect(normalizeInviteCode('RUP-7KQ2MX')).toBe('RUP-7KQ2MX');
|
||||
});
|
||||
|
||||
it('형식이 아니면 null — 길이가 다르거나 쓰지 않는 글자가 있으면', () => {
|
||||
expect(normalizeInviteCode('RUP-7KQ2M')).toBeNull();
|
||||
expect(normalizeInviteCode('RUP-7KQ2MXX')).toBeNull();
|
||||
expect(normalizeInviteCode('RUP-0KQ2MX')).toBeNull();
|
||||
expect(normalizeInviteCode('ABC-7KQ2MX')).toBeNull();
|
||||
expect(normalizeInviteCode('7KQ2MX')).toBeNull();
|
||||
expect(normalizeInviteCode('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,4 @@
|
||||
import { randomInt } from 'crypto';
|
||||
import { inviteCodeFormat } from '@/shared/utils/invite-code';
|
||||
|
||||
// 공유 다이어리 초대 코드 — 부수효과 없는 순수 함수 (무작위 값만 crypto 에서 얻는다)
|
||||
|
||||
/** 코드 앞머리 — 앱이 보여 주는 형식 `RUP-XXXXXX` */
|
||||
const INVITE_CODE_PREFIX = 'RUP-';
|
||||
const INVITE_CODE_BODY_LENGTH = 6;
|
||||
|
||||
/** 헷갈리는 글자(0·O·1·I)를 뺀 대문자·숫자 */
|
||||
export const INVITE_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
/** 저장 길이 — `RUP-` + 6자 */
|
||||
export const INVITE_CODE_LENGTH =
|
||||
INVITE_CODE_PREFIX.length + INVITE_CODE_BODY_LENGTH;
|
||||
|
||||
export const INVITE_CODE_PATTERN = new RegExp(
|
||||
`^${INVITE_CODE_PREFIX}[${INVITE_CODE_ALPHABET}]{${INVITE_CODE_BODY_LENGTH}}$`,
|
||||
);
|
||||
|
||||
/** 새 초대 코드 — 추측하기 어렵게 무작위로 뽑는다 (겹치면 호출한 쪽이 다시 뽑는다) */
|
||||
export function randomInviteCode(): string {
|
||||
let body = '';
|
||||
for (let i = 0; i < INVITE_CODE_BODY_LENGTH; i += 1) {
|
||||
body += INVITE_CODE_ALPHABET[randomInt(0, INVITE_CODE_ALPHABET.length)];
|
||||
}
|
||||
return `${INVITE_CODE_PREFIX}${body}`;
|
||||
}
|
||||
|
||||
/** 사람이 입력한 코드 → 저장 형식 (앞뒤 공백 제거·대문자). 형식이 아니면 null */
|
||||
export function normalizeInviteCode(input: string): string | null {
|
||||
const code = input.trim().toUpperCase();
|
||||
return INVITE_CODE_PATTERN.test(code) ? code : null;
|
||||
}
|
||||
/** 공유 다이어리 초대 코드 `RUP-XXXXXX` (뒷부분 6자) */
|
||||
export const DIARY_INVITE_CODE = inviteCodeFormat(6);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Pet } from '@/modules/pet/entities/pet.entity';
|
||||
import { petSnapshot } from './card-snapshot';
|
||||
import {
|
||||
DETAIL_SHARE_TTL_MS,
|
||||
effectiveShareMode,
|
||||
petSnapshot,
|
||||
} from './card-snapshot';
|
||||
|
||||
const pet = {
|
||||
name: '콩이',
|
||||
@@ -49,3 +53,35 @@ describe('petSnapshot', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,26 @@ import type {
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 공유한 모양에 맞춰 아이 정보를 잘라 낸다.
|
||||
* 간략히 명함에 보이지 않는 정보(건강·병원·메모)는 상세히로 공유했을 때만 넘긴다 — 받는 사람에게 더 보여 주지 않는다.
|
||||
|
||||
@@ -79,6 +79,7 @@ export class PetCard {
|
||||
@JoinColumn({ name: 'name_bg_file_id' })
|
||||
nameBgFile?: StoredFile | null;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
nameBgFileId!: string | null;
|
||||
|
||||
@@ -104,6 +105,13 @@ export class PetCard {
|
||||
@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;
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ 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;
|
||||
|
||||
/**
|
||||
* 받은 명함에 담는 아이 정보 — 받을 때 공유한 모양에 맞춰 잘라 낸 사본.
|
||||
* 간략히는 이름·종·품종·성별·생일·몸무게·등록번호만, 상세히는 건강 정보·병원·메모까지.
|
||||
@@ -54,8 +57,13 @@ export interface SharedCardStyle {
|
||||
/**
|
||||
* 받은 명함(보관함) — 받는 순간의 사본이다. 보낸 사람이 아이 정보를 바꾸거나 명함을 지워도 그대로 남는다.
|
||||
* 사진도 받는 사람 소유로 복사해 둔다 (원본이 지워져도 보이도록).
|
||||
* 같은 명함은 보관함에 한 번만 둔다 — 원래 명함이 지워져 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;
|
||||
@@ -74,6 +82,7 @@ export class ReceivedCard {
|
||||
@JoinColumn({ name: 'source_card_id' })
|
||||
sourceCard?: PetCard | null;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
sourceCardId!: string | null;
|
||||
|
||||
@@ -93,6 +102,7 @@ export class ReceivedCard {
|
||||
@JoinColumn({ name: 'sender_photo_file_id' })
|
||||
senderPhotoFile?: StoredFile | null;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
senderPhotoFileId!: string | null;
|
||||
|
||||
@@ -103,6 +113,7 @@ export class ReceivedCard {
|
||||
@JoinColumn({ name: 'pet_photo_file_id' })
|
||||
petPhotoFile?: StoredFile | null;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
petPhotoFileId!: string | null;
|
||||
|
||||
@@ -113,6 +124,7 @@ export class ReceivedCard {
|
||||
@JoinColumn({ name: 'background_file_id' })
|
||||
backgroundFile?: StoredFile | null;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
backgroundFileId!: string | null;
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -133,7 +134,10 @@ export class PetCardService implements OnModuleInit, PetLifecycleListener {
|
||||
});
|
||||
}
|
||||
|
||||
/** 공유 모양 정하기 — 공유 시트를 열 때 앱이 보낸다. 받는 사람에게는 이 모양의 정보만 간다 */
|
||||
/**
|
||||
* 공유 모양 정하기 — 공유 시트를 열 때 앱이 보낸다. 받는 사람에게는 이 모양의 정보만 간다.
|
||||
* 정한 때도 남긴다 — 상세히는 그때부터 7일만 상세히로 내보낸다 (card-snapshot.ts 의 effectiveShareMode)
|
||||
*/
|
||||
async setShareMode(
|
||||
ownerId: string,
|
||||
cardId: string,
|
||||
@@ -141,7 +145,7 @@ export class PetCardService implements OnModuleInit, PetLifecycleListener {
|
||||
): Promise<void> {
|
||||
const result = await this.cards.update(
|
||||
{ id: cardId, ownerId },
|
||||
{ shareMode: mode },
|
||||
{ shareMode: mode, shareModeSetAt: new Date() },
|
||||
);
|
||||
if (!result.affected) {
|
||||
throw new BusinessException('RES_001', { context: { cardId } });
|
||||
@@ -174,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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, QueryFailedError, Repository } from 'typeorm';
|
||||
|
||||
import { UploadService } from '@/modules/upload/upload.service';
|
||||
import { PetCard } from './entities/pet-card.entity';
|
||||
@@ -14,6 +14,7 @@ function card(overrides: Partial<PetCard> = {}): PetCard {
|
||||
ownerId: SENDER,
|
||||
code: '0428-117',
|
||||
shareMode: 'simple',
|
||||
shareModeSetAt: null,
|
||||
tint: '#FF9B6A',
|
||||
tint2: '#FFC59B',
|
||||
nameBg: null,
|
||||
@@ -52,13 +53,32 @@ describe('ReceivedCardService', () => {
|
||||
),
|
||||
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());
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
manager.existsBy.mockResolvedValue(false);
|
||||
manager.countBy.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it('형식이 틀린 코드는 DB 를 보지 않고 CARD_002', async () => {
|
||||
await expect(service.preview(VIEWER, 'abc')).rejects.toMatchObject({
|
||||
@@ -93,7 +113,7 @@ describe('ReceivedCardService', () => {
|
||||
|
||||
it('받으면 사진을 내 소유로 복사해 사본을 저장한다', async () => {
|
||||
cards.findOne.mockResolvedValue(card());
|
||||
received.save.mockImplementation((v: Partial<ReceivedCard>) =>
|
||||
manager.save.mockImplementation((v: Partial<ReceivedCard>) =>
|
||||
Promise.resolve({ ...v, id: 'r1', createdAt: new Date() }),
|
||||
);
|
||||
|
||||
@@ -115,7 +135,7 @@ describe('ReceivedCardService', () => {
|
||||
|
||||
it('저장하지 못하면 복사한 사진을 지운다', async () => {
|
||||
cards.findOne.mockResolvedValue(card());
|
||||
received.save.mockRejectedValue(new Error('db down'));
|
||||
manager.save.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(service.receive(VIEWER, { code: '0428-117' })).rejects.toThrow(
|
||||
'db down',
|
||||
@@ -126,4 +146,85 @@ describe('ReceivedCardService', () => {
|
||||
'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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
|
||||
import {
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { FileDownload, UploadService } from '@/modules/upload/upload.service';
|
||||
import { cardStyleSnapshot, petSnapshot } from './card-snapshot';
|
||||
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,
|
||||
@@ -17,7 +23,10 @@ import {
|
||||
SharedCardPreviewDto,
|
||||
} from './dto/received-card.dto';
|
||||
import { PetCard } from './entities/pet-card.entity';
|
||||
import { ReceivedCard } from './entities/received-card.entity';
|
||||
import {
|
||||
MAX_RECEIVED_CARDS,
|
||||
ReceivedCard,
|
||||
} from './entities/received-card.entity';
|
||||
|
||||
/** 공유된 명함에서 내려받을 수 있는 사진 */
|
||||
export const SHARED_IMAGE_KINDS = ['pet', 'sender', 'background'] as const;
|
||||
@@ -33,19 +42,22 @@ export class ReceivedCardService {
|
||||
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: card.shareMode,
|
||||
mode,
|
||||
senderNickname: card.owner?.nickname ?? null,
|
||||
hasSenderPhoto: card.owner?.photoFileId != null,
|
||||
pet: petSnapshot(pet, card.shareMode),
|
||||
pet: petSnapshot(pet, mode),
|
||||
hasPetPhoto: pet.photoFileId !== null,
|
||||
style: cardStyleSnapshot(card),
|
||||
hasBackgroundPhoto: card.nameBgFileId !== null,
|
||||
@@ -66,37 +78,53 @@ export class ReceivedCardService {
|
||||
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);
|
||||
|
||||
const copies = await Promise.all([
|
||||
this.uploads.copyFor(card.owner?.photoFileId ?? null, viewerId),
|
||||
this.uploads.copyFor(pet.photoFileId, viewerId),
|
||||
this.uploads.copyFor(card.nameBgFileId, viewerId),
|
||||
]);
|
||||
const [senderPhotoFileId, petPhotoFileId, backgroundFileId] = copies;
|
||||
|
||||
let copies: Array<string | null> = [];
|
||||
try {
|
||||
const saved = await this.received.save(
|
||||
this.received.create({
|
||||
ownerId: viewerId,
|
||||
sourceCardId: card.id,
|
||||
code: card.code,
|
||||
mode: card.shareMode,
|
||||
senderNickname: card.owner?.nickname ?? null,
|
||||
senderPhotoFileId,
|
||||
pet: petSnapshot(pet, card.shareMode),
|
||||
petPhotoFileId,
|
||||
style: cardStyleSnapshot(card),
|
||||
backgroundFileId,
|
||||
note: dto.note ?? null,
|
||||
}),
|
||||
);
|
||||
// 하나가 실패해도 나머지 복사가 끝나기를 기다려 만들어진 사본을 모두 지울 수 있게 한다
|
||||
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})`,
|
||||
);
|
||||
@@ -104,10 +132,32 @@ export class ReceivedCardService {
|
||||
} 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,
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import { InviteCodeLookup } from '@/common/decorators/invite-code-lookup.decorator';
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiPaginatedResponse,
|
||||
@@ -50,16 +50,10 @@ import {
|
||||
} from './received-card.service';
|
||||
import type { SharedImageKind } from './received-card.service';
|
||||
|
||||
/**
|
||||
* 명함 코드로 찾는 엔드포인트는 코드 추측의 표적이 된다 — 전역보다 엄격하게 막는다.
|
||||
* 분당 30회 — 사진 세 장을 포함해 명함 몇 장을 받는 데는 충분하다.
|
||||
*/
|
||||
const CODE_LOOKUP_THROTTLE = { default: { ttl: 60_000, limit: 30 } };
|
||||
|
||||
// 공유된 명함 — 코드를 아는 회원이 받기 전에 미리 본다
|
||||
@ApiTags('PetCard')
|
||||
@ApiBearerAuth()
|
||||
@Throttle(CODE_LOOKUP_THROTTLE)
|
||||
@InviteCodeLookup()
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요')
|
||||
@Controller('shared-pet-cards')
|
||||
export class SharedPetCardsController {
|
||||
@@ -123,7 +117,7 @@ export class ReceivedCardsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Throttle(CODE_LOOKUP_THROTTLE)
|
||||
@InviteCodeLookup()
|
||||
@ApiOperation({
|
||||
summary: '명함 받기 (보관함에 저장)',
|
||||
description:
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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(/쓸 수 없는 이름/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
|
||||
@@ -50,7 +51,7 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}),
|
||||
],
|
||||
controllers: [UploadController],
|
||||
providers: [UploadService],
|
||||
providers: [UploadService, FileSweeper],
|
||||
exports: [UploadService],
|
||||
})
|
||||
export class UploadModule {}
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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}개 정리)`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() ?? '');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -12,5 +12,6 @@
|
||||
"testRegex": "test/.*\\.e2e-spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
},
|
||||
"testTimeout": 30000
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,14 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- 앱 링크 — QR 의 rup://app/… 을 기기 카메라로 찍으면 앱이 열린다 (lib/core/config/app_links.dart) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="rup" android:host="app"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="flutter_deeplinking_enabled" android:value="true"/>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
||||
@@ -8,8 +8,21 @@
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Tma App</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>app.rup.links</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>rup</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>FlutterDeepLinkingEnabled</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>다른 반려인의 펫 명함 QR 코드를 읽기 위해 카메라를 사용합니다.</string>
|
||||
<string>펫 명함·공유 다이어리·일정 연동 QR 코드를 읽기 위해 카메라를 사용합니다.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>펫 명함 이미지를 사진 보관함에 저장합니다.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:tma_app/core/api/dio_client.dart';
|
||||
|
||||
/// 목록 API 한 번에 읽는 최대 개수 (backend PAGINATION_MAX_LIMIT)
|
||||
const int kApiPageLimit = 100;
|
||||
@@ -8,17 +9,20 @@ const int _kMaxPages = 1000;
|
||||
|
||||
/// 커서 페이지네이션 목록({items, pageInfo})을 마지막 페이지까지 읽어 이어 붙인다.
|
||||
/// 앱은 지금 전체 기록을 들고 화면에서 거른다 — 기록이 아주 많아지면 기간 조회로 바꾼다.
|
||||
/// [quiet] 면 실패해도 알림을 띄우지 않는다 (뒤에서 다시 읽을 때 — 실패는 그대로 던진다)
|
||||
Future<List<T>> fetchAllPages<T>(
|
||||
Dio dio,
|
||||
String path,
|
||||
T Function(Map<String, Object?> json) parse,
|
||||
) async {
|
||||
T Function(Map<String, Object?> json) parse, {
|
||||
bool quiet = false,
|
||||
}) async {
|
||||
final items = <T>[];
|
||||
String? cursor;
|
||||
for (var page = 0; page < _kMaxPages; page++) {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
path,
|
||||
queryParameters: {'limit': kApiPageLimit, 'cursor': ?cursor},
|
||||
options: quiet ? Options(extra: {kSilentExtra: true}) : null,
|
||||
);
|
||||
final data = response.data!;
|
||||
for (final item in data['items']! as List<Object?>) {
|
||||
|
||||
@@ -24,7 +24,8 @@ const String _kRetriedExtra = 'authRetried';
|
||||
const String _kRefreshUnreachableExtra = 'refreshUnreachable';
|
||||
|
||||
/// 토큰 갱신 결과
|
||||
enum _RefreshResult { refreshed, rejected, unreachable }
|
||||
/// 토큰 갱신 결과 — 거절(401·400)만 세션을 끊는다. 서버에 닿지 못했거나 너무 잦아 막혔으면(429) 세션은 그대로 둔다
|
||||
enum _RefreshResult { refreshed, rejected, unreachable, throttled }
|
||||
|
||||
/// 토큰 갱신 경로 (backend AuthController `POST /auth/tokens`)
|
||||
const String kRefreshPath = '/auth/tokens';
|
||||
@@ -134,12 +135,17 @@ class DioClient {
|
||||
if (sent != 'Bearer ${tokens.accessToken}') return true;
|
||||
|
||||
final result = await _refreshOnce();
|
||||
if (result == _RefreshResult.unreachable) options.extra[_kRefreshUnreachableExtra] = true;
|
||||
// 갱신하지 못했지만 다시 로그인할 일은 아닐 때 — 이번 요청이 어떤 코드로 실패할지 남겨 둔다
|
||||
if (result == _RefreshResult.unreachable) options.extra[_kRefreshUnreachableExtra] = 'SYS_001';
|
||||
if (result == _RefreshResult.throttled) options.extra[_kRefreshUnreachableExtra] = 'REQ_001';
|
||||
return result == _RefreshResult.refreshed;
|
||||
}
|
||||
|
||||
RequestOptions _withFreshToken(RequestOptions options) {
|
||||
options.extra[_kRetriedExtra] = true;
|
||||
// 사진 올리기(FormData)는 한 번 보내면 다시 쓸 수 없다 — 다시 보낼 때는 복사본으로
|
||||
final data = options.data;
|
||||
if (data is FormData) options.data = data.clone();
|
||||
final token = _session.tokens?.accessToken;
|
||||
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
||||
return options;
|
||||
@@ -162,9 +168,12 @@ class DioClient {
|
||||
await _session.save(AuthTokens.fromJson(data));
|
||||
return _RefreshResult.refreshed;
|
||||
} on DioException catch (e) {
|
||||
final response = e.response;
|
||||
if (response == null || (response.statusCode ?? 0) >= 500) return _RefreshResult.unreachable;
|
||||
return _RefreshResult.rejected;
|
||||
final status = e.response?.statusCode;
|
||||
// 서버가 리프레시 토큰을 받아들이지 않았을 때(401·형식 오류 400)만 세션을 끊는다.
|
||||
// 429(한 IP 를 여럿이 쓰는 통신사 망에서도 생긴다)·5xx·응답 없음은 잠깐의 문제라 세션을 둔다
|
||||
if (status == 401 || status == 400) return _RefreshResult.rejected;
|
||||
if (status == 429) return _RefreshResult.throttled;
|
||||
return _RefreshResult.unreachable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,8 +203,9 @@ class DioClient {
|
||||
// 갱신하려다 서버에 닿지 못했으면 다시 로그인할 일이 아니므로 일시적 오류(SYS_001)로 둔다.
|
||||
// AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
|
||||
if (code == 'AUTH_001' && e.requestOptions.headers['Authorization'] != null) {
|
||||
if (e.requestOptions.extra[_kRefreshUnreachableExtra] == true) {
|
||||
code = 'SYS_001';
|
||||
final kept = e.requestOptions.extra[_kRefreshUnreachableExtra];
|
||||
if (kept is String) {
|
||||
code = kept;
|
||||
} else {
|
||||
if (_session.hasSession) await _session.clear(expired: true);
|
||||
code = 'AUTH_002';
|
||||
|
||||
@@ -16,9 +16,16 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'DIARY_003': '초대 코드를 찾을 수 없어요. 코드를 다시 확인해 주세요.',
|
||||
'DIARY_004': '참여 인원이 가득 찼어요. (최대 4명)',
|
||||
'DIARY_005': '이미 참여하고 있는 다이어리예요.',
|
||||
'SCHED_001': '초대 코드를 찾을 수 없어요. 코드를 다시 확인해 주세요.',
|
||||
'SCHED_002': '이미 4명이 연동되어 있어요.',
|
||||
'SCHED_003': '내 초대 코드로는 연동할 수 없어요.',
|
||||
'SCHED_004': '이미 다른 사람과 연동되어 있어요. 연동을 해제한 뒤 다시 시도해 주세요.',
|
||||
'SCHED_005': '이미 연동된 사람이에요.',
|
||||
'CARD_001': '펫 명함은 최대 10장까지 만들 수 있어요.',
|
||||
'CARD_002': '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.',
|
||||
'CARD_003': '내 명함은 받을 수 없어요.',
|
||||
'CARD_004': '이미 보관함에 있는 명함이에요.',
|
||||
'CARD_005': '보관함이 가득 찼어요. (최대 100장)',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// 앱 링크 — QR 에 담아 기기 카메라로 찍어도 앱이 열리게 한다 (Android intent-filter · iOS URL scheme 이 `rup` 을 받는다).
|
||||
//
|
||||
// 앱에는 링크의 **경로**만 들어온다 (Android 는 호스트를 버린다). 그래서 호스트는 `app` 하나로 두고
|
||||
// 무엇을 열지는 경로로 정한다: rup://app/pet-card/0428-117 → 라우터 경로 /pet-card/0428-117
|
||||
|
||||
/// 링크 앞부분 — 뒤에 라우터 경로를 붙인다
|
||||
const String kAppLinkBase = 'rup://app';
|
||||
|
||||
/// 라우터 경로 → 앱 링크
|
||||
String appLink(String path) => '$kAppLinkBase$path';
|
||||
|
||||
/// 링크에서 [segment] 다음 값을 꺼낸다 — `rup://app/<segment>/값` 과 예전 형식 `rup://<segment>/값` 모두.
|
||||
/// 링크가 아니면 받은 글자 그대로 (직접 입력한 코드)
|
||||
String stripAppLink(String text, String segment) {
|
||||
for (final prefix in ['$kAppLinkBase/$segment/', 'rup://$segment/']) {
|
||||
if (text.startsWith(prefix)) return text.substring(prefix.length);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -14,9 +14,11 @@ import 'package:tma_app/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:tma_app/features/diary/presentation/pages/diary_compose_page.dart';
|
||||
import 'package:tma_app/features/diary/presentation/pages/diary_page.dart';
|
||||
import 'package:tma_app/features/diary/presentation/pages/diary_settings_page.dart';
|
||||
import 'package:tma_app/features/diary/presentation/widgets/diary_join.dart';
|
||||
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
|
||||
import 'package:tma_app/features/home/presentation/pages/home_page.dart';
|
||||
import 'package:tma_app/features/intro/presentation/pages/intro_page.dart';
|
||||
import 'package:tma_app/features/links/presentation/pages/link_landing_page.dart';
|
||||
import 'package:tma_app/features/mypage/domain/policy_doc.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/my_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/my_pets_page.dart';
|
||||
@@ -36,6 +38,8 @@ import 'package:tma_app/features/pet_card/presentation/pages/receive_card_page.d
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/received_card_detail_page.dart';
|
||||
import 'package:tma_app/features/records/presentation/pages/records_page.dart';
|
||||
import 'package:tma_app/features/schedule/presentation/pages/event_editor_page.dart';
|
||||
import 'package:tma_app/features/schedule/presentation/pages/share_calendar_page.dart';
|
||||
import 'package:tma_app/features/schedule/presentation/widgets/calendar_link_flow.dart';
|
||||
import 'package:tma_app/features/shell/presentation/pages/main_shell.dart';
|
||||
import 'package:tma_app/features/splash/presentation/pages/splash_page.dart';
|
||||
|
||||
@@ -130,6 +134,28 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
],
|
||||
),
|
||||
// 앱 링크 — 기기 카메라로 QR 을 찍어 열렸을 때 (rup://app/pet-card/… 등, core/config/app_links.dart)
|
||||
for (final kind in AppLinkKind.values)
|
||||
GoRoute(
|
||||
path: appLinkPattern(kind),
|
||||
parentNavigatorKey: rootKey,
|
||||
builder: (context, state) => LinkLandingPage(kind: kind, code: state.pathParameters['code']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.shareCalendar,
|
||||
parentNavigatorKey: rootKey,
|
||||
builder: (context, state) => const ShareCalendarPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.calendarLinkScan,
|
||||
parentNavigatorKey: rootKey,
|
||||
pageBuilder: (context, state) => _fadePage(state, const CalendarLinkScannerPage()),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.diaryJoinScan,
|
||||
parentNavigatorKey: rootKey,
|
||||
pageBuilder: (context, state) => _fadePage(state, const DiaryJoinScannerPage()),
|
||||
),
|
||||
// 펫 명함 — 경로가 겹치므로 고정 경로(manage·new·scan…)를 :cardId 보다 먼저 둔다
|
||||
GoRoute(
|
||||
path: AppRoutes.petCardManage,
|
||||
|
||||
@@ -44,6 +44,10 @@ abstract final class AppRoutes {
|
||||
static String eventCreate(DateTime date) => '$eventCreatePath?date=${toIsoDate(date)}';
|
||||
static String eventEdit(String eventId) => '$eventEditPath?id=${Uri.encodeQueryComponent(eventId)}';
|
||||
|
||||
// 함께 관리 — 달력 연동 (탭바 없이 전체 화면)
|
||||
static const String shareCalendar = '/schedule/sharing';
|
||||
static const String calendarLinkScan = '/schedule/sharing/scan';
|
||||
|
||||
// 알림 — 홈 종에서 연다 (탭바 없이 전체 화면)
|
||||
static const String notifications = '/notifications';
|
||||
|
||||
@@ -53,6 +57,9 @@ abstract final class AppRoutes {
|
||||
static String diaryWrite(String id) => '/diaries/$id/write';
|
||||
static String diarySettings(String id) => '/diaries/$id/settings';
|
||||
|
||||
/// 공유 다이어리 참여 스캐너 — /diaries/:diaryId 와 겹치지 않게 다른 경로에 둔다
|
||||
static const String diaryJoinScan = '/diary-invitations/scan';
|
||||
|
||||
static const String petCardManage = '/pet-cards/manage';
|
||||
static const String petCardCreatePattern = '/pet-cards/new/:petId';
|
||||
static const String petCardDetailPattern = '/pet-cards/:cardId';
|
||||
|
||||
@@ -12,31 +12,89 @@ import 'package:tma_app/core/utils/date_format.dart';
|
||||
import 'package:tma_app/features/diary/data/diary_repository.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary_book.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary_invitation.dart';
|
||||
import 'package:tma_app/features/diary/domain/mood.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet_target.dart';
|
||||
|
||||
/// 다이어리 경로 (backend DiariesController · DiaryPostsController · DiaryCommentsController)
|
||||
/// 다이어리 경로 (backend DiariesController · DiaryPostsController · DiaryCommentsController ·
|
||||
/// DiaryInvitationsController · DiaryMembershipsController)
|
||||
const String _kDiariesPath = '/diaries';
|
||||
const String _kPostsPath = '/diary-posts';
|
||||
const String _kCommentsPath = '/diary-comments';
|
||||
const String _kInvitationsPath = '/diary-invitations';
|
||||
const String _kMembershipsPath = '/diary-memberships';
|
||||
|
||||
/// 책장이 가득 찼을 때 서버가 주는 코드 — 앱은 null 로 받아 화면이 직접 안내한다
|
||||
const String kDiaryFullCode = 'DIARY_001';
|
||||
|
||||
Diary diaryFromApi(Map<String, Object?> json) => Diary(
|
||||
id: json['id']! as String,
|
||||
/// 서버의 회원 id → 앱의 사람 id (나이면 [kMeAuthorId]).
|
||||
/// 내 id 를 모르면(이전 버전에서 로그인) 모두 나로 본다 — 그때는 공유 다이어리가 없었다
|
||||
String _personId(Object? id, String? me) => me == null || id == me ? kMeAuthorId : id! as String;
|
||||
|
||||
/// 남이면 그 사람, 나면 null — 내 글·댓글은 내 프로필로 그린다
|
||||
DiaryPerson? _author(Map<String, Object?> json, String? me) {
|
||||
final id = _personId(json['authorId'], me);
|
||||
if (id == kMeAuthorId) return null;
|
||||
final author = json['author'] as Map<String, Object?>?;
|
||||
return DiaryPerson(
|
||||
id: id,
|
||||
nickname: author?['nickname'] as String?,
|
||||
photoPath: serverFileRefOrNull(author?['photoFileId'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
Diary diaryFromApi(Map<String, Object?> json, {String? myUserId}) {
|
||||
final isOwner = json['isOwner'] as bool? ?? true;
|
||||
return Diary(
|
||||
id: json['id']! as String,
|
||||
name: json['name']! as String,
|
||||
targetIds: (json['targetIds'] as List?)?.whereType<String>().toList() ?? const [],
|
||||
ribbon: colorFromHex(json['ribbon'] as String?) ?? kRibbonColors.first,
|
||||
tint: colorFromHex(json['tint'] as String?) ?? kDiaryTints.first,
|
||||
coverPath: serverFileRefOrNull(json['coverFileId'] as String?),
|
||||
mutedNotices: {
|
||||
for (final n in (json['mutedNotices'] as List?)?.whereType<String>() ?? const <String>[])
|
||||
?DiaryNotice.values.where((v) => v.name == n).firstOrNull,
|
||||
},
|
||||
shared: json['shared'] as bool? ?? false,
|
||||
isOwner: isOwner,
|
||||
inviteCode: json['inviteCode'] as String?,
|
||||
members: [
|
||||
for (final m in (json['members'] as List?)?.whereType<Map<String, Object?>>() ?? const <Map<String, Object?>>[])
|
||||
DiaryMember(
|
||||
id: _personId(m['userId'], myUserId),
|
||||
nickname: m['nickname'] as String?,
|
||||
photoPath: serverFileRefOrNull(m['photoFileId'] as String?),
|
||||
isOwner: m['isOwner'] as bool? ?? false,
|
||||
),
|
||||
],
|
||||
// 내 다이어리는 내 반려동물 목록으로 그리므로 다른 사람의 다이어리일 때만 담는다
|
||||
targets: isOwner
|
||||
? const []
|
||||
: [
|
||||
for (final t in (json['targets'] as List?)?.whereType<Map<String, Object?>>() ?? const <Map<String, Object?>>[])
|
||||
DiaryTarget(
|
||||
id: t['id']! as String,
|
||||
name: t['name'] as String?,
|
||||
photoPath: serverFileRefOrNull(t['photoFileId'] as String?),
|
||||
isSelf: t['id'] == kSelfTargetId,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
DiaryInvitation diaryInvitationFromApi(String code, Map<String, Object?> json) => DiaryInvitation(
|
||||
code: code,
|
||||
diaryId: json['diaryId']! as String,
|
||||
name: json['name']! as String,
|
||||
targetIds: (json['targetIds'] as List?)?.whereType<String>().toList() ?? const [],
|
||||
ribbon: colorFromHex(json['ribbon'] as String?) ?? kRibbonColors.first,
|
||||
tint: colorFromHex(json['tint'] as String?) ?? kDiaryTints.first,
|
||||
coverPath: serverFileRefOrNull(json['coverFileId'] as String?),
|
||||
mutedNotices: {
|
||||
for (final n in (json['mutedNotices'] as List?)?.whereType<String>() ?? const <String>[])
|
||||
?DiaryNotice.values.where((v) => v.name == n).firstOrNull,
|
||||
},
|
||||
ownerNickname: json['ownerNickname'] as String?,
|
||||
memberCount: (json['memberCount'] as num?)?.toInt() ?? 1,
|
||||
maxMembers: (json['maxMembers'] as num?)?.toInt() ?? kMaxDiaryMembers,
|
||||
isMember: json['isMember'] as bool? ?? false,
|
||||
);
|
||||
|
||||
/// 서버 저장소 — 사진은 저장할 때 올린다.
|
||||
/// 기록·댓글의 작성자가 로그인한 나이면 앱의 "나"(kMeAuthorId)로 바꿔 담는다 (하루 한 번 쓰기를 가린다).
|
||||
/// 작성자·멤버가 로그인한 나이면 앱의 "나"(kMeAuthorId)로 바꿔 담는다 (하루 한 번 쓰기·내 글 가리기).
|
||||
class ApiDiaryRepository implements DiaryRepository {
|
||||
ApiDiaryRepository({
|
||||
required Dio dio,
|
||||
@@ -53,38 +111,45 @@ class ApiDiaryRepository implements DiaryRepository {
|
||||
final FileUploader _uploader;
|
||||
final String? Function() _myUserId;
|
||||
|
||||
/// 지금은 다이어리 주인만 쓰므로, 내 id 를 모르면(이전 버전에서 로그인) 모두 나로 본다
|
||||
String _author(Object? id) {
|
||||
@override
|
||||
bool get canShare => true;
|
||||
|
||||
Diary _diary(Map<String, Object?> json) => diaryFromApi(json, myUserId: _myUserId());
|
||||
|
||||
DiaryPost _post(Map<String, Object?> json) {
|
||||
final me = _myUserId();
|
||||
return me == null || id == me ? kMeAuthorId : id! as String;
|
||||
return DiaryPost(
|
||||
id: json['id']! as String,
|
||||
diaryId: json['diaryId']! as String,
|
||||
date: parseIsoDate(json['date']! as String),
|
||||
createdAt: DateTime.parse(json['createdAt']! as String).toLocal(),
|
||||
text: json['text'] as String? ?? '',
|
||||
mood: Mood.byName(json['mood'] as String?),
|
||||
photoPath: serverFileRefOrNull(json['photoFileId'] as String?),
|
||||
authorId: _personId(json['authorId'], me),
|
||||
author: _author(json, me),
|
||||
);
|
||||
}
|
||||
|
||||
DiaryPost _post(Map<String, Object?> json) => DiaryPost(
|
||||
id: json['id']! as String,
|
||||
diaryId: json['diaryId']! as String,
|
||||
date: parseIsoDate(json['date']! as String),
|
||||
createdAt: DateTime.parse(json['createdAt']! as String).toLocal(),
|
||||
text: json['text'] as String? ?? '',
|
||||
mood: Mood.byName(json['mood'] as String?),
|
||||
photoPath: serverFileRefOrNull(json['photoFileId'] as String?),
|
||||
authorId: _author(json['authorId']),
|
||||
);
|
||||
|
||||
DiaryComment _comment(Map<String, Object?> json) => DiaryComment(
|
||||
id: json['id']! as String,
|
||||
postId: json['postId']! as String,
|
||||
text: json['text'] as String? ?? '',
|
||||
createdAt: DateTime.parse(json['createdAt']! as String).toLocal(),
|
||||
authorId: _author(json['authorId']),
|
||||
);
|
||||
DiaryComment _comment(Map<String, Object?> json) {
|
||||
final me = _myUserId();
|
||||
return DiaryComment(
|
||||
id: json['id']! as String,
|
||||
postId: json['postId']! as String,
|
||||
text: json['text'] as String? ?? '',
|
||||
createdAt: DateTime.parse(json['createdAt']! as String).toLocal(),
|
||||
authorId: _personId(json['authorId'], me),
|
||||
author: _author(json, me),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DiaryBook> fetch() async {
|
||||
Future<DiaryBook> fetch({bool quiet = false}) async {
|
||||
if (!_session.hasSession) return DiaryBook.empty;
|
||||
return DiaryBook(
|
||||
diaries: await fetchAllPages(_dio, _kDiariesPath, diaryFromApi),
|
||||
posts: await fetchAllPages(_dio, _kPostsPath, _post),
|
||||
comments: await fetchAllPages(_dio, _kCommentsPath, _comment),
|
||||
diaries: await fetchAllPages(_dio, _kDiariesPath, _diary, quiet: quiet),
|
||||
posts: await fetchAllPages(_dio, _kPostsPath, _post, quiet: quiet),
|
||||
comments: await fetchAllPages(_dio, _kCommentsPath, _comment, quiet: quiet),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,6 +160,7 @@ class ApiDiaryRepository implements DiaryRepository {
|
||||
required Color ribbon,
|
||||
required Color tint,
|
||||
String? coverPath,
|
||||
bool shared = false,
|
||||
}) async {
|
||||
final coverFileId = await _uploader.ensureUploaded(coverPath);
|
||||
try {
|
||||
@@ -106,13 +172,14 @@ class ApiDiaryRepository implements DiaryRepository {
|
||||
'ribbon': hexOf(ribbon),
|
||||
'tint': hexOf(tint),
|
||||
'coverFileId': coverFileId,
|
||||
'shared': shared,
|
||||
},
|
||||
// 가득 찬 경우는 화면이 "최대 6개" 로 직접 안내한다
|
||||
options: Options(extra: {
|
||||
kSilentCodesExtra: [kDiaryFullCode],
|
||||
}),
|
||||
);
|
||||
return diaryFromApi(response.data!);
|
||||
return _diary(response.data!);
|
||||
} on DioException catch (e) {
|
||||
final error = e.error;
|
||||
if (error is ApiException && error.code == kDiaryFullCode) return null;
|
||||
@@ -129,12 +196,43 @@ class ApiDiaryRepository implements DiaryRepository {
|
||||
'targetIds': diary.targetIds,
|
||||
'ribbon': hexOf(diary.ribbon),
|
||||
'coverFileId': await _uploader.ensureUploaded(diary.coverPath),
|
||||
'mutedNotices': [for (final n in diary.mutedNotices) n.name],
|
||||
},
|
||||
);
|
||||
return diaryFromApi(response.data!);
|
||||
return _diary(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setMutedNotices(String diaryId, Set<DiaryNotice> muted) => _dio.put<void>(
|
||||
'$_kDiariesPath/$diaryId/muted-notices',
|
||||
data: {'mutedNotices': [for (final n in muted) n.name]},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Diary> setShared(String diaryId, bool shared) async {
|
||||
final response = await _dio.put<Map<String, dynamic>>('$_kDiariesPath/$diaryId/sharing', data: {'shared': shared});
|
||||
return _diary(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DiaryInvitation> previewInvitation(String code) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('$_kInvitationsPath/${Uri.encodeComponent(code)}');
|
||||
return diaryInvitationFromApi(code, response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DiaryBook> join(String code) async {
|
||||
await _dio.post<Map<String, dynamic>>(_kMembershipsPath, data: {'code': code});
|
||||
// 참여한 다이어리의 지난 기록·댓글까지 함께 보여야 하므로 모두 다시 읽는다
|
||||
return fetch();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> leave(String diaryId) => _dio.delete<void>('$_kDiariesPath/$diaryId/membership');
|
||||
|
||||
@override
|
||||
Future<void> removeMember(String diaryId, String memberId) =>
|
||||
_dio.delete<void>('$_kDiariesPath/$diaryId/members/$memberId');
|
||||
|
||||
@override
|
||||
Future<void> deleteDiary(String diaryId) => _dio.delete<void>('$_kDiariesPath/$diaryId');
|
||||
|
||||
|
||||
@@ -12,25 +12,49 @@ import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart
|
||||
import 'package:tma_app/features/diary/data/api_diary_repository.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary_book.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary_invitation.dart';
|
||||
import 'package:tma_app/features/diary/domain/mood.dart';
|
||||
|
||||
/// 다이어리 저장소 계약 — 서버(ApiDiaryRepository) / 기기(LocalDiaryRepository).
|
||||
/// 바꾼 결과(id 가 붙은 다이어리·기록·댓글)를 돌려주면 알림기가 [DiaryBook] 에 반영한다.
|
||||
abstract interface class DiaryRepository {
|
||||
Future<DiaryBook> fetch();
|
||||
/// [quiet] 면 실패해도 알림을 띄우지 않는다 (뒤에서 다시 읽을 때)
|
||||
Future<DiaryBook> fetch({bool quiet = false});
|
||||
|
||||
/// 새 다이어리 — 책장이 가득 찼으면 null (서버 DIARY_001)
|
||||
/// 공유 다이어리(초대·참여·멤버)를 쓸 수 있는지 — 다른 회원과 함께 쓰므로 서버가 있어야 한다
|
||||
bool get canShare;
|
||||
|
||||
/// 새 다이어리 — 책장이 가득 찼으면 null (서버 DIARY_001). [shared] 면 처음부터 공유 다이어리
|
||||
Future<Diary?> createDiary({
|
||||
required String name,
|
||||
required List<String> targetIds,
|
||||
required Color ribbon,
|
||||
required Color tint,
|
||||
String? coverPath,
|
||||
bool shared = false,
|
||||
});
|
||||
|
||||
/// 이름·대상·리본·표지·알림을 바꾼다 (바탕색은 만들 때 정한 값을 유지한다)
|
||||
/// 이름·대상·리본·표지를 바꾼다 (주인만 · 바탕색은 만들 때 정한 값을 유지한다)
|
||||
Future<Diary> updateDiary(Diary diary);
|
||||
|
||||
/// 내 알림 설정 — 멤버마다 따로 둔다
|
||||
Future<void> setMutedNotices(String diaryId, Set<DiaryNotice> muted);
|
||||
|
||||
/// 개인 ↔ 공유 전환 (주인만). 개인으로 바꾸면 다른 멤버는 모두 나간다
|
||||
Future<Diary> setShared(String diaryId, bool shared);
|
||||
|
||||
/// 초대 코드로 참여할 다이어리를 미리 본다
|
||||
Future<DiaryInvitation> previewInvitation(String code);
|
||||
|
||||
/// 초대 코드로 참여한다 — 참여한 다이어리의 기록·댓글까지 모두 다시 읽어 돌려준다
|
||||
Future<DiaryBook> join(String code);
|
||||
|
||||
/// 참여한 다이어리에서 나간다 (주인이 아닐 때)
|
||||
Future<void> leave(String diaryId);
|
||||
|
||||
/// 멤버를 내보낸다 (주인만)
|
||||
Future<void> removeMember(String diaryId, String memberId);
|
||||
|
||||
/// 다이어리와 그 안의 기록·댓글을 모두 지운다
|
||||
Future<void> deleteDiary(String diaryId);
|
||||
|
||||
@@ -59,6 +83,8 @@ abstract interface class DiaryRepository {
|
||||
Future<void> dropTarget(String targetId);
|
||||
}
|
||||
|
||||
const String _kLocalNoSharing = '공유 다이어리는 서버 모드에서만 쓸 수 있다';
|
||||
|
||||
const String _kDiariesKey = 'diary.diaries.v1';
|
||||
const String _kPostsKey = 'diary.posts.v1';
|
||||
const String _kCommentsKey = 'diary.comments.v1';
|
||||
@@ -89,7 +115,10 @@ class LocalDiaryRepository implements DiaryRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DiaryBook> fetch() async => _read();
|
||||
Future<DiaryBook> fetch({bool quiet = false}) async => _read();
|
||||
|
||||
@override
|
||||
bool get canShare => false;
|
||||
|
||||
/// 가득 찼는지는 알림기가 아이의 보관 여부까지 보고 먼저 확인한다
|
||||
@override
|
||||
@@ -99,6 +128,7 @@ class LocalDiaryRepository implements DiaryRepository {
|
||||
required Color ribbon,
|
||||
required Color tint,
|
||||
String? coverPath,
|
||||
bool shared = false,
|
||||
}) async {
|
||||
final diary = Diary(
|
||||
id: _newId('d'),
|
||||
@@ -118,6 +148,29 @@ class LocalDiaryRepository implements DiaryRepository {
|
||||
return diary;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setMutedNotices(String diaryId, Set<DiaryNotice> muted) async {
|
||||
final book = _read();
|
||||
final diary = book.diary(diaryId);
|
||||
if (diary != null) await _write(book.updateDiary(diary.copyWith(mutedNotices: muted)));
|
||||
}
|
||||
|
||||
// 기기 저장 모드에는 함께 쓸 다른 회원이 없다 — 화면은 [canShare] 로 막는다
|
||||
@override
|
||||
Future<Diary> setShared(String diaryId, bool shared) => throw StateError(_kLocalNoSharing);
|
||||
|
||||
@override
|
||||
Future<DiaryInvitation> previewInvitation(String code) => throw StateError(_kLocalNoSharing);
|
||||
|
||||
@override
|
||||
Future<DiaryBook> join(String code) => throw StateError(_kLocalNoSharing);
|
||||
|
||||
@override
|
||||
Future<void> leave(String diaryId) => throw StateError(_kLocalNoSharing);
|
||||
|
||||
@override
|
||||
Future<void> removeMember(String diaryId, String memberId) => throw StateError(_kLocalNoSharing);
|
||||
|
||||
@override
|
||||
Future<void> deleteDiary(String diaryId) async => _write(_read().deleteDiary(diaryId));
|
||||
|
||||
|
||||
@@ -12,9 +12,48 @@ const int kCommentMaxLength = 50;
|
||||
/// 다이어리 이름을 비워 두면 쓰는 이름
|
||||
const String kDefaultDiaryName = '새 다이어리';
|
||||
|
||||
/// 기록한 사람 — 지금은 나 하나 (공유 다이어리가 생기면 회원 id)
|
||||
/// 기록한 사람이 나일 때의 id — 서버의 내 회원 id 도 이 값으로 바꿔 담는다 (하루 한 번 쓰기·내 글 가리기)
|
||||
const String kMeAuthorId = 'me';
|
||||
|
||||
/// 공유 다이어리 한 권에 함께할 수 있는 사람 수 — 주인 포함 (시안 MAX_MEMBERS)
|
||||
const int kMaxDiaryMembers = 4;
|
||||
|
||||
/// 닉네임을 모르는 사람
|
||||
const String kUnknownPersonName = '집사';
|
||||
|
||||
/// 다이어리에 글·댓글을 남긴 사람 · 멤버
|
||||
class DiaryPerson {
|
||||
const DiaryPerson({required this.id, this.nickname, this.photoPath});
|
||||
|
||||
/// 회원 id — 나이면 [kMeAuthorId]
|
||||
final String id;
|
||||
final String? nickname;
|
||||
final String? photoPath;
|
||||
|
||||
bool get isMe => id == kMeAuthorId;
|
||||
|
||||
String get name => nickname ?? kUnknownPersonName;
|
||||
}
|
||||
|
||||
/// 공유 다이어리의 멤버
|
||||
class DiaryMember extends DiaryPerson {
|
||||
const DiaryMember({required super.id, super.nickname, super.photoPath, this.isOwner = false});
|
||||
|
||||
final bool isOwner;
|
||||
}
|
||||
|
||||
/// 다른 사람의 다이어리에 담긴 아이 — 내 반려동물 목록에 없으므로 서버가 이름·사진을 함께 준다
|
||||
class DiaryTarget {
|
||||
const DiaryTarget({required this.id, this.name, this.photoPath, this.isSelf = false});
|
||||
|
||||
final String id;
|
||||
final String? name;
|
||||
final String? photoPath;
|
||||
|
||||
/// 다이어리 주인 본인 (대상 "나")
|
||||
final bool isSelf;
|
||||
}
|
||||
|
||||
/// 책갈피 리본 색 선택지 (시안 RIBBON_SWATCHES)
|
||||
const List<Color> kRibbonColors = [
|
||||
Color(0xFFFFFFFF), Color(0xFF2B2622), Color(0xFFFF7500),
|
||||
@@ -44,6 +83,11 @@ class Diary {
|
||||
required this.tint,
|
||||
this.coverPath,
|
||||
this.mutedNotices = const {},
|
||||
this.shared = false,
|
||||
this.isOwner = true,
|
||||
this.inviteCode,
|
||||
this.members = const [],
|
||||
this.targets = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -52,9 +96,10 @@ class Diary {
|
||||
/// 누구의 다이어리인지 — 반려동물 id 또는 나(self)
|
||||
final List<String> targetIds;
|
||||
|
||||
/// 대상이 모두 추억 보관함의 아이인지 — 그러면 책장에서 빠지고 보관함에서 본다
|
||||
/// 대상이 모두 추억 보관함의 아이인지 — 그러면 책장에서 빠지고 보관함에서 본다.
|
||||
/// 참여한 다이어리는 주인의 아이들이라 내 보관함과 상관없이 책장에 둔다
|
||||
bool isArchivedWith(Set<String> archivedPetIds) =>
|
||||
targetIds.isNotEmpty && targetIds.every(archivedPetIds.contains);
|
||||
isOwner && targetIds.isNotEmpty && targetIds.every(archivedPetIds.contains);
|
||||
final Color ribbon;
|
||||
final Color tint;
|
||||
final String? coverPath;
|
||||
@@ -64,12 +109,41 @@ class Diary {
|
||||
|
||||
bool notifies(DiaryNotice n) => !mutedNotices.contains(n);
|
||||
|
||||
/// 함께 쓰는 다이어리인지 (개인 다이어리는 false)
|
||||
final bool shared;
|
||||
|
||||
/// 내가 만든 다이어리인지 — 아니면 초대받아 참여한 다이어리
|
||||
final bool isOwner;
|
||||
|
||||
/// 멤버 초대 코드 — 주인에게만 온다
|
||||
final String? inviteCode;
|
||||
|
||||
/// 함께 쓰는 사람들 (주인이 먼저). 개인 다이어리·기기 저장 모드는 비어 있다
|
||||
final List<DiaryMember> members;
|
||||
|
||||
/// 참여한 다이어리의 아이들 (내 다이어리는 비어 있다 — 내 반려동물 목록으로 그린다)
|
||||
final List<DiaryTarget> targets;
|
||||
|
||||
/// 멤버 수 — 나만 쓰면 1
|
||||
int get memberCount => members.isEmpty ? 1 : members.length;
|
||||
|
||||
bool get membersFull => memberCount >= kMaxDiaryMembers;
|
||||
|
||||
/// 다이어리를 만든 사람
|
||||
DiaryMember? get owner => members.where((m) => m.isOwner).firstOrNull;
|
||||
|
||||
/// 첫 아이 이름 — 내 다이어리는 [myTargetNames](내 반려동물·나)에서, 참여한 다이어리는 서버가 준 이름으로
|
||||
String? firstTargetName(Map<String, String> myTargetNames) => isOwner
|
||||
? targetIds.map((id) => myTargetNames[id]).nonNulls.firstOrNull
|
||||
: targets.map((t) => t.name).nonNulls.firstOrNull;
|
||||
|
||||
Diary copyWith({
|
||||
String? name,
|
||||
List<String>? targetIds,
|
||||
Color? ribbon,
|
||||
String? coverPath,
|
||||
Set<DiaryNotice>? mutedNotices,
|
||||
List<DiaryMember>? members,
|
||||
}) =>
|
||||
Diary(
|
||||
id: id,
|
||||
@@ -79,6 +153,11 @@ class Diary {
|
||||
tint: tint,
|
||||
coverPath: coverPath ?? this.coverPath,
|
||||
mutedNotices: mutedNotices ?? this.mutedNotices,
|
||||
shared: shared,
|
||||
isOwner: isOwner,
|
||||
inviteCode: inviteCode,
|
||||
members: members ?? this.members,
|
||||
targets: targets,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -116,6 +195,7 @@ class DiaryPost {
|
||||
this.mood,
|
||||
this.photoPath,
|
||||
this.authorId = kMeAuthorId,
|
||||
this.author,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -131,6 +211,11 @@ class DiaryPost {
|
||||
final String? photoPath;
|
||||
final String authorId;
|
||||
|
||||
/// 남이 쓴 기록이면 그 사람 (내 기록은 null — 내 프로필로 그린다)
|
||||
final DiaryPerson? author;
|
||||
|
||||
bool get isMine => authorId == kMeAuthorId;
|
||||
|
||||
bool get hasPhoto => photoPath != null;
|
||||
|
||||
DiaryPost copyWith({String? text}) => DiaryPost(
|
||||
@@ -142,6 +227,7 @@ class DiaryPost {
|
||||
mood: mood,
|
||||
photoPath: photoPath,
|
||||
authorId: authorId,
|
||||
author: author,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -175,6 +261,7 @@ class DiaryComment {
|
||||
required this.text,
|
||||
required this.createdAt,
|
||||
this.authorId = kMeAuthorId,
|
||||
this.author,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -183,8 +270,19 @@ class DiaryComment {
|
||||
final DateTime createdAt;
|
||||
final String authorId;
|
||||
|
||||
DiaryComment copyWith({String? text}) =>
|
||||
DiaryComment(id: id, postId: postId, text: text ?? this.text, createdAt: createdAt, authorId: authorId);
|
||||
/// 남이 쓴 댓글이면 그 사람 (내 댓글은 null)
|
||||
final DiaryPerson? author;
|
||||
|
||||
bool get isMine => authorId == kMeAuthorId;
|
||||
|
||||
DiaryComment copyWith({String? text}) => DiaryComment(
|
||||
id: id,
|
||||
postId: postId,
|
||||
text: text ?? this.text,
|
||||
createdAt: createdAt,
|
||||
authorId: authorId,
|
||||
author: author,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:tma_app/core/config/app_links.dart';
|
||||
|
||||
/// 공유 다이어리 초대 — 참여하기 전에 확인하는 다이어리 정보
|
||||
class DiaryInvitation {
|
||||
const DiaryInvitation({
|
||||
required this.code,
|
||||
required this.diaryId,
|
||||
required this.name,
|
||||
required this.ownerNickname,
|
||||
required this.memberCount,
|
||||
required this.maxMembers,
|
||||
required this.isMember,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String diaryId;
|
||||
final String name;
|
||||
final String? ownerNickname;
|
||||
final int memberCount;
|
||||
final int maxMembers;
|
||||
|
||||
/// 이미 함께 쓰고 있는 다이어리인지 (내가 만든 다이어리 포함)
|
||||
final bool isMember;
|
||||
|
||||
bool get isFull => memberCount >= maxMembers;
|
||||
}
|
||||
|
||||
/// 초대 코드 앞머리 (서버가 `RUP-` + 6자로 만든다)
|
||||
const String kInviteCodePrefix = 'RUP-';
|
||||
|
||||
/// 다이어리 초대 링크의 경로 앞부분 — rup://app/diary-invite/RUP-8F3K2Q (라우터 경로 /diary-invite/:code)
|
||||
const String kDiaryInviteLinkSegment = 'diary-invite';
|
||||
|
||||
/// 초대 QR 에 담는 주소 — 앱의 스캐너가 읽고, 기기 카메라로 찍으면 앱이 열려 바로 참여 확인으로 간다
|
||||
String diaryInviteLink(String code) => appLink('/$kDiaryInviteLinkSegment/$code');
|
||||
|
||||
final RegExp _kInviteBody = RegExp(r'^[A-Z0-9]{6}$');
|
||||
|
||||
/// QR 또는 직접 입력한 값에서 초대 코드를 꺼낸다 — 주소(rup://app/diary-invite/RUP-8F3K2Q, 예전 rup://diary-invite/…)·
|
||||
/// 코드(RUP-8F3K2Q, 소문자도)·앞머리 없이 쓴 6자(8F3K2Q) 모두 받는다. 초대 코드가 아니면 null
|
||||
String? parseInviteCode(String raw) {
|
||||
var text = stripAppLink(raw.trim(), kDiaryInviteLinkSegment);
|
||||
text = text.split('?').first.trim().toUpperCase().replaceAll(' ', '');
|
||||
if (text.startsWith(kInviteCodePrefix)) text = text.substring(kInviteCodePrefix.length);
|
||||
return _kInviteBody.hasMatch(text) ? '$kInviteCodePrefix$text' : null;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/router/app_routes.dart';
|
||||
@@ -55,6 +56,9 @@ const double _kTabBottomGap = 30;
|
||||
/// 다이어리 머리가 위아래로 2씩 좁다 (시안 embedded margin 2px 0 16px — 기록 탭·보관함 모두)
|
||||
const double _kEmbeddedTrim = 2;
|
||||
|
||||
/// 멤버 수 칩 바탕 (시안 #F6F4EF)
|
||||
const Color _kChipBg = Color(0xFFF6F4EF);
|
||||
|
||||
/// 다이어리 안 — 기록 피드(모두·사진·글) / 달력 보기, 검색, 오늘 쓰기
|
||||
class DiaryPage extends ConsumerStatefulWidget {
|
||||
const DiaryPage({super.key, required this.diaryId, this.archived = false});
|
||||
@@ -79,6 +83,10 @@ class _DiaryPageState extends ConsumerState<DiaryPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_query.addListener(() => setState(() {}));
|
||||
// 함께 쓰는 다이어리면 들어올 때 다른 멤버의 새 기록을 다시 읽는다
|
||||
if (ref.read(diaryBookProvider).diary(widget.diaryId)?.shared ?? false) {
|
||||
unawaited(succeeded(ref.read(diaryProvider.notifier).refresh()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -139,10 +147,16 @@ class _DiaryPageState extends ConsumerState<DiaryPage> {
|
||||
onBack: () => context.pop(),
|
||||
trailing: archived
|
||||
? null
|
||||
: _HeaderIconButton(
|
||||
label: '다이어리 설정',
|
||||
onTap: () => unawaited(context.push(AppRoutes.diarySettings(diary.id))),
|
||||
child: const RupGlyphIcon(RupGlyph.gear, size: 21, color: AppColors.ink2),
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (diary.shared) ...[_MembersChip(count: diary.memberCount), const SizedBox(width: AppSpacing.sm)],
|
||||
_HeaderIconButton(
|
||||
label: '다이어리 설정',
|
||||
onTap: () => unawaited(context.push(AppRoutes.diarySettings(diary.id))),
|
||||
child: const RupGlyphIcon(RupGlyph.gear, size: 21, color: AppColors.ink2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
@@ -311,6 +325,42 @@ class _NoPosts extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 공유 다이어리 멤버 수 (시안 .rup-memseg__chip.is-solo)
|
||||
class _MembersChip extends StatelessWidget {
|
||||
const _MembersChip({required this.count});
|
||||
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
label: '멤버 $count명 (최대 $kMaxDiaryMembers명)',
|
||||
excludeSemantics: true,
|
||||
child: Container(
|
||||
height: 34,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13),
|
||||
decoration: BoxDecoration(color: _kChipBg, borderRadius: BorderRadius.circular(AppRadius.pill)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const RupIcon(RupIconName.users, size: 14, stroke: 2, color: AppColors.ink2),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'$count/$kMaxDiaryMembers',
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.ink2,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderIconButton extends StatelessWidget {
|
||||
const _HeaderIconButton({required this.label, required this.onTap, required this.child});
|
||||
|
||||
|
||||
@@ -10,11 +10,14 @@ import 'package:tma_app/core/router/app_routes.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
|
||||
import 'package:tma_app/features/diary/presentation/widgets/diary_join.dart';
|
||||
import 'package:tma_app/features/diary/presentation/widgets/diary_sharing_sections.dart';
|
||||
import 'package:tma_app/features/diary/presentation/widgets/diary_sheets.dart';
|
||||
import 'package:tma_app/features/records/presentation/providers/records_tab_provider.dart';
|
||||
import 'package:tma_app/shared/widgets/heart_toggle.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/pressable.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_button.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_card.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_dialog.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
@@ -24,6 +27,9 @@ import 'package:tma_app/shared/widgets/sub_page_header.dart';
|
||||
|
||||
const double _kNormal = AppTypography.normalHeight;
|
||||
|
||||
/// 나가기·전환 확인창의 경고 색 (시안 #E05A4A)
|
||||
const Color _kWarn = Color(0xFFE05A4A);
|
||||
|
||||
/// 알림 줄 — 아이콘·색·제목·설명 (시안 NOTIF_ROWS)
|
||||
typedef _NoticeRow = ({DiaryNotice notice, RupIconName icon, Color tint, String title, String desc});
|
||||
|
||||
@@ -33,155 +39,261 @@ const List<_NoticeRow> _kNoticeRows = [
|
||||
(notice: DiaryNotice.reminder, icon: RupIconName.paw, tint: AppColors.accent, title: '우리 아이 기록 알림', desc: '오늘 일기를 쓰지 않았을 때 잊지 않도록 알려드려요.'),
|
||||
];
|
||||
|
||||
/// 다이어리 설정 — 정보 수정 · 개인/공유 · 알림 · 제거
|
||||
class DiarySettingsPage extends ConsumerWidget {
|
||||
/// 다이어리 설정 — 정보 수정 · 개인/공유 · 멤버 · 알림 · 초대 · 나가기/제거
|
||||
class DiarySettingsPage extends ConsumerStatefulWidget {
|
||||
const DiarySettingsPage({super.key, required this.diaryId});
|
||||
|
||||
final String diaryId;
|
||||
|
||||
Future<void> _delete(BuildContext context, WidgetRef ref, Diary diary) async {
|
||||
@override
|
||||
ConsumerState<DiarySettingsPage> createState() => _DiarySettingsPageState();
|
||||
}
|
||||
|
||||
class _DiarySettingsPageState extends ConsumerState<DiarySettingsPage> {
|
||||
/// "N명 더 초대할 수 있어요" 를 누르면 내려갈 곳
|
||||
final _inviteKey = GlobalKey();
|
||||
|
||||
DiaryNotifier get _diaries => ref.read(diaryProvider.notifier);
|
||||
ToastNotifier get _toast => ref.read(toastProvider.notifier);
|
||||
|
||||
/// 책장으로 돌아간다 — 지우거나 나간 다이어리를 그리지 않게 먼저 화면을 바꾼다
|
||||
void _backToShelf() {
|
||||
ref.read(recordsTabProvider.notifier).select(RecordsTab.diary);
|
||||
context.go(AppRoutes.records);
|
||||
}
|
||||
|
||||
Future<void> _delete(Diary diary) async {
|
||||
final ok = await showRupRemoveConfirm(
|
||||
context,
|
||||
title: '다이어리를 제거할까요?',
|
||||
message: '다이어리를 삭제하면 다시 복구할 수 없으며,\n이때까지 적은 기록이 전부 삭제돼요.',
|
||||
);
|
||||
if (!ok || !context.mounted) return;
|
||||
// 먼저 책장으로 돌아가 지워진 다이어리를 그리지 않게 한다
|
||||
ref.read(recordsTabProvider.notifier).select(RecordsTab.diary);
|
||||
context.go(AppRoutes.records);
|
||||
if (!await succeeded(ref.read(diaryProvider.notifier).deleteDiary(diary.id))) return;
|
||||
ref.read(toastProvider.notifier).show('다이어리를 제거했어요');
|
||||
if (!ok || !mounted) return;
|
||||
final diaries = _diaries;
|
||||
final toast = _toast;
|
||||
_backToShelf();
|
||||
if (!await succeeded(diaries.deleteDiary(diary.id))) return;
|
||||
toast.show('다이어리를 제거했어요');
|
||||
}
|
||||
|
||||
/// 개인 ↔ 공유 — 개인으로 바꾸면 함께 쓰던 멤버가 모두 나가므로 먼저 묻는다
|
||||
Future<void> _setShared(Diary diary, bool shared) async {
|
||||
if (shared == diary.shared) return;
|
||||
if (!_diaries.canShare) {
|
||||
_toast.show(kSharedDiaryLocalOnly, icon: RupIconName.users);
|
||||
return;
|
||||
}
|
||||
if (!shared && diary.memberCount > 1) {
|
||||
final ok = await showRupConfirm(
|
||||
context,
|
||||
title: '개인 다이어리로 전환할까요?',
|
||||
message: '함께 기록하던 멤버 ${diary.memberCount - 1}명이 모두 다이어리에서 나가게 돼요.\n이 작업은 되돌릴 수 없어요.',
|
||||
confirmLabel: '전환하기',
|
||||
layout: RupConfirmLayout.row,
|
||||
icon: RupIconName.alert,
|
||||
iconColor: _kWarn,
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
}
|
||||
if (!await succeeded(_diaries.setShared(diary.id, shared: shared))) return;
|
||||
_toast.show(shared ? '공유 다이어리로 전환했어요' : '개인 다이어리로 전환했어요',
|
||||
icon: shared ? RupIconName.users : RupIconName.check);
|
||||
}
|
||||
|
||||
Future<void> _remove(Diary diary, DiaryMember member) async {
|
||||
final name = memberName(ref, member);
|
||||
final ok = await showRupConfirm(
|
||||
context,
|
||||
title: '$name님을 내보낼까요?',
|
||||
message: '내보낸 멤버는 더 이상 이 다이어리를 보거나 기록할 수 없어요.',
|
||||
confirmLabel: '내보내기',
|
||||
layout: RupConfirmLayout.row,
|
||||
leading: MemberAvatar(person: member, size: 52),
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
if (!await succeeded(_diaries.removeMember(diary.id, member.id))) return;
|
||||
_toast.show('$name님을 내보냈어요');
|
||||
}
|
||||
|
||||
Future<void> _leave(Diary diary) async {
|
||||
final ok = await showRupConfirm(
|
||||
context,
|
||||
title: '다이어리에서 나갈까요?',
|
||||
message: '공유 다이어리는 한번 나가면\n주인이 다시 초대해주지 않으면 다시 들어올 수 없어요.',
|
||||
confirmLabel: '나가기',
|
||||
layout: RupConfirmLayout.row,
|
||||
icon: RupIconName.alert,
|
||||
iconColor: _kWarn,
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
final diaries = _diaries;
|
||||
final toast = _toast;
|
||||
_backToShelf();
|
||||
if (!await succeeded(diaries.leave(diary.id))) return;
|
||||
toast.show('다이어리에서 나왔어요');
|
||||
}
|
||||
|
||||
void _jumpToInvite() {
|
||||
final target = _inviteKey.currentContext;
|
||||
if (target != null) {
|
||||
unawaited(Scrollable.ensureVisible(target, duration: const Duration(milliseconds: 350), curve: Curves.easeOut));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final diary = ref.watch(diaryBookProvider).diary(diaryId);
|
||||
Widget build(BuildContext context) {
|
||||
final diary = ref.watch(diaryBookProvider).diary(widget.diaryId);
|
||||
if (diary == null) return const Scaffold(body: SizedBox.shrink());
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final code = diary.inviteCode;
|
||||
final canInvite = diary.isOwner && diary.shared && !diary.membersFull && code != null;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: ListView(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xs,
|
||||
AppSpacing.xl,
|
||||
MediaQuery.paddingOf(context).bottom + 40,
|
||||
),
|
||||
children: [
|
||||
SubPageHeader(
|
||||
title: '다이어리 설정',
|
||||
subtitle: diary.name,
|
||||
displayFont: true,
|
||||
onBack: () => context.pop(),
|
||||
trailing: Pressable(
|
||||
onTap: () => unawaited(showDiaryFormSheet(context, editing: diary)),
|
||||
pressedScale: .9,
|
||||
semanticLabel: '다이어리 수정하기',
|
||||
child: const SizedBox(
|
||||
width: 30,
|
||||
height: 34,
|
||||
child: Center(child: RupIcon(RupIconName.pencilCute, size: 25, stroke: 1.9, color: AppColors.ink2)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SubPageHeader(
|
||||
title: '다이어리 설정',
|
||||
subtitle: diary.name,
|
||||
displayFont: true,
|
||||
onBack: () => context.pop(),
|
||||
// 다이어리 정보는 주인만 고친다
|
||||
trailing: diary.isOwner
|
||||
? Pressable(
|
||||
onTap: () => unawaited(showDiaryFormSheet(context, editing: diary)),
|
||||
pressedScale: .9,
|
||||
semanticLabel: '다이어리 수정하기',
|
||||
child: const SizedBox(
|
||||
width: 30,
|
||||
height: 34,
|
||||
child: Center(child: RupIcon(RupIconName.pencilCute, size: 25, stroke: 1.9, color: AppColors.ink2)),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
if (diary.isOwner)
|
||||
SegToggle<bool>(
|
||||
large: true,
|
||||
first: (false, '개인 다이어리'),
|
||||
second: (true, '공유 다이어리'),
|
||||
value: diary.shared,
|
||||
iconOf: (shared, on) => RupIcon(shared ? RupIconName.users : RupIconName.user, size: 18, stroke: on ? 2.4 : 1.9),
|
||||
onChanged: (shared) => unawaited(_setShared(diary, shared)),
|
||||
)
|
||||
else
|
||||
JoinedDiaryCard(diary: diary),
|
||||
if (diary.shared) ...[
|
||||
SizedBox(height: diary.isOwner ? 24 : 22),
|
||||
CountSectionTitle(title: '멤버', count: '${diary.memberCount}/$kMaxDiaryMembers'),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
DiaryMembersCard(
|
||||
diary: diary,
|
||||
onRemove: (m) => unawaited(_remove(diary, m)),
|
||||
onInvite: _jumpToInvite,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 26),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: SectionTitle(title: '알림 설정'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
RupCard(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, row) in _kNoticeRows.indexed)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: row.tint.withValues(alpha: .14),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: RupIcon(row.icon, size: 19, stroke: 2, color: row.tint),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
row.title,
|
||||
style: textTheme.titleSmall!.copyWith(fontSize: 14.5, height: _kNormal),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
row.desc,
|
||||
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: 1.45),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
HeartToggle(
|
||||
value: diary.notifies(row.notice),
|
||||
label: row.title,
|
||||
onChanged: (on) => unawaited(succeeded(_diaries.setNotice(diary, row.notice, on: on))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SegToggle<bool>(
|
||||
large: true,
|
||||
first: (false, '개인 다이어리'),
|
||||
second: (true, '공유 다이어리'),
|
||||
value: false,
|
||||
iconOf: (shared, on) => RupIcon(shared ? RupIconName.users : RupIconName.user, size: 18, stroke: on ? 2.4 : 1.9),
|
||||
onChanged: (shared) {
|
||||
if (shared) ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: SectionTitle(title: '알림 설정'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
RupCard(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, row) in _kNoticeRows.indexed)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2)),
|
||||
const SizedBox(height: 22),
|
||||
if (canInvite) DiaryInviteSection(key: _inviteKey, diary: diary, code: code),
|
||||
if (!diary.isOwner)
|
||||
RupButton(
|
||||
label: '다이어리 나가기',
|
||||
variant: RupButtonVariant.ghost,
|
||||
foreground: _kWarn,
|
||||
leading: const RupIcon(RupIconName.logout, size: 17, stroke: 2, color: _kWarn),
|
||||
onPressed: () => unawaited(_leave(diary)),
|
||||
)
|
||||
else ...[
|
||||
const SizedBox(height: 22),
|
||||
Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => unawaited(_delete(diary)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
||||
child: Text(
|
||||
'다이어리 제거',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.muted,
|
||||
height: _kNormal,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: row.tint.withValues(alpha: .14),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: RupIcon(row.icon, size: 19, stroke: 2, color: row.tint),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
row.title,
|
||||
style: textTheme.titleSmall!.copyWith(fontSize: 14.5, height: _kNormal),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
row.desc,
|
||||
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: 1.45),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
HeartToggle(
|
||||
value: diary.notifies(row.notice),
|
||||
label: row.title,
|
||||
onChanged: (on) => unawaited(succeeded(ref.read(diaryProvider.notifier).updateDiary(
|
||||
diary.copyWith(
|
||||
mutedNotices: on
|
||||
? ({...diary.mutedNotices}..remove(row.notice))
|
||||
: {...diary.mutedNotices, row.notice},
|
||||
),
|
||||
))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 44),
|
||||
Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => unawaited(_delete(context, ref, diary)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
||||
child: Text(
|
||||
'다이어리 제거',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.muted,
|
||||
height: _kNormal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:tma_app/core/utils/date_format.dart';
|
||||
import 'package:tma_app/features/diary/data/diary_repository.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary_book.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary_invitation.dart';
|
||||
import 'package:tma_app/features/diary/domain/mood.dart';
|
||||
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
|
||||
|
||||
@@ -28,20 +29,28 @@ class DiaryNotifier extends AsyncNotifier<DiaryBook> {
|
||||
if (ref.mounted) state = AsyncData(next);
|
||||
}
|
||||
|
||||
/// 공유 다이어리를 쓸 수 있는지 (서버 모드)
|
||||
bool get canShare => _repo.canShare;
|
||||
|
||||
/// 책장이 가득 찼는지 — 보관함으로 간 다이어리는 세지 않는다
|
||||
bool get atMax => _book.atMax(ref.read(petListProvider).value?.archivedIds ?? const {});
|
||||
|
||||
/// 새 다이어리 — 가득 찼으면 null
|
||||
Future<Diary?> createDiary({
|
||||
required String name,
|
||||
required List<String> targetIds,
|
||||
required Color ribbon,
|
||||
String? coverPath,
|
||||
bool shared = false,
|
||||
}) async {
|
||||
if (_book.atMax(ref.read(petListProvider).value?.archivedIds ?? const {})) return null;
|
||||
if (atMax) return null;
|
||||
final diary = await _repo.createDiary(
|
||||
name: name.trim().isEmpty ? kDefaultDiaryName : name.trim(),
|
||||
targetIds: targetIds,
|
||||
ribbon: ribbon,
|
||||
tint: kDiaryTints[Random().nextInt(kDiaryTints.length)],
|
||||
coverPath: coverPath,
|
||||
shared: shared,
|
||||
);
|
||||
if (diary != null) _set(_book.addDiary(diary));
|
||||
return diary;
|
||||
@@ -54,6 +63,44 @@ class DiaryNotifier extends AsyncNotifier<DiaryBook> {
|
||||
_set(_book.deleteDiary(id));
|
||||
}
|
||||
|
||||
/// 내 알림 하나를 켜거나 끈다
|
||||
Future<void> setNotice(Diary diary, DiaryNotice notice, {required bool on}) async {
|
||||
final muted = on ? ({...diary.mutedNotices}..remove(notice)) : {...diary.mutedNotices, notice};
|
||||
await _repo.setMutedNotices(diary.id, muted);
|
||||
final latest = _book.diary(diary.id);
|
||||
if (latest != null) _set(_book.updateDiary(latest.copyWith(mutedNotices: muted)));
|
||||
}
|
||||
|
||||
// ── 공유 다이어리 ────────────────────────────────────────
|
||||
/// 다른 멤버가 새로 남긴 기록·댓글을 다시 읽는다 — 불러오는 중 화면 없이 바꿔 끼운다
|
||||
/// [quiet] 면 실패해도 알림을 띄우지 않는다 (앱이 뒤에서 다시 읽을 때)
|
||||
Future<void> refresh({bool quiet = false}) async {
|
||||
// 기기 저장 모드는 나만 쓰므로 다시 읽을 것이 없다
|
||||
if (!_repo.canShare) return;
|
||||
_set(await _repo.fetch(quiet: quiet));
|
||||
}
|
||||
|
||||
/// 개인 ↔ 공유 전환 (주인만)
|
||||
Future<void> setShared(String id, {required bool shared}) async =>
|
||||
_set(_book.updateDiary(await _repo.setShared(id, shared)));
|
||||
|
||||
Future<DiaryInvitation> previewInvitation(String code) => _repo.previewInvitation(code);
|
||||
|
||||
/// 초대 코드로 참여 — 참여한 다이어리의 기록까지 다시 읽는다
|
||||
Future<void> join(String code) async => _set(await _repo.join(code));
|
||||
|
||||
/// 참여한 다이어리에서 나간다 — 내 책장에서 그 다이어리와 기록이 사라진다
|
||||
Future<void> leave(String id) async {
|
||||
await _repo.leave(id);
|
||||
_set(_book.deleteDiary(id));
|
||||
}
|
||||
|
||||
/// 멤버를 내보낸다 (주인만) — 서버가 초대 코드를 새로 바꾸므로 다시 읽는다
|
||||
Future<void> removeMember(String diaryId, String memberId) async {
|
||||
await _repo.removeMember(diaryId, memberId);
|
||||
_set(await _repo.fetch());
|
||||
}
|
||||
|
||||
Future<void> addPost({required String diaryId, required String text, Mood? mood, String? photoPath}) async {
|
||||
final post = await _repo.addPost(
|
||||
diaryId: diaryId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:tma_app/core/constants/app_assets.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/theme/app_shadows.dart';
|
||||
@@ -12,6 +13,8 @@ import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
|
||||
import 'package:tma_app/features/pet/presentation/widgets/target_avatar.dart';
|
||||
import 'package:tma_app/shared/widgets/dashed_border_box.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/person_avatar.dart';
|
||||
import 'package:tma_app/shared/widgets/photo_image.dart';
|
||||
|
||||
/// 표지 가로:세로 1:1.16 (시안 aspectRatio 1 / 1.16)
|
||||
const double kDiaryCoverAspect = 1 / 1.16;
|
||||
@@ -91,8 +94,8 @@ class DiaryCoverTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(top: -2, right: 15, child: DiaryRibbon(color: diary.ribbon)),
|
||||
if (showTargets) Positioned(left: 12, top: 11, child: _CoverTargets(ids: diary.targetIds)),
|
||||
Positioned(top: -2, right: 15, child: DiaryRibbon(color: diary.ribbon, shared: diary.shared)),
|
||||
if (showTargets) Positioned(left: 12, top: 11, child: _CoverTargets(diary: diary)),
|
||||
Positioned(
|
||||
left: 14,
|
||||
right: 12,
|
||||
@@ -137,23 +140,27 @@ class DiaryCoverTile extends StatelessWidget {
|
||||
|
||||
/// 표지 왼쪽 위 아이들 — 조금씩 겹쳐 놓는다
|
||||
class _CoverTargets extends StatelessWidget {
|
||||
const _CoverTargets({required this.ids});
|
||||
const _CoverTargets({required this.diary});
|
||||
|
||||
final List<String> ids;
|
||||
final Diary diary;
|
||||
|
||||
static const double _size = 28;
|
||||
static const double _overlap = 8;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (ids.isEmpty) return const SizedBox.shrink();
|
||||
// 참여한 다이어리의 아이들은 내 반려동물 목록에 없다 — 서버가 준 이름·사진으로 그린다
|
||||
final avatars = diary.isOwner
|
||||
? [for (final id in diary.targetIds) TargetAvatar(targetId: id, size: _size)]
|
||||
: [for (final t in diary.targets) _SharedTargetAvatar(target: t, size: _size)];
|
||||
if (avatars.isEmpty) return const SizedBox.shrink();
|
||||
return SizedBox(
|
||||
height: _size,
|
||||
width: _size + (ids.length - 1) * (_size - _overlap),
|
||||
width: _size + (avatars.length - 1) * (_size - _overlap),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (final (i, id) in ids.indexed)
|
||||
for (final (i, avatar) in avatars.indexed)
|
||||
Positioned(
|
||||
left: i * (_size - _overlap),
|
||||
child: DecoratedBox(
|
||||
@@ -162,7 +169,7 @@ class _CoverTargets extends StatelessWidget {
|
||||
color: Colors.white,
|
||||
boxShadow: [BoxShadow(color: Colors.white.withValues(alpha: .9), spreadRadius: 2)],
|
||||
),
|
||||
child: TargetAvatar(targetId: id, size: _size),
|
||||
child: avatar,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -171,6 +178,35 @@ class _CoverTargets extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 참여한 다이어리의 아이 — 주인 본인("나")이면 사람 사진, 아이면 아이 사진 (없으면 기본 얼굴)
|
||||
class _SharedTargetAvatar extends StatelessWidget {
|
||||
const _SharedTargetAvatar({required this.target, required this.size});
|
||||
|
||||
final DiaryTarget target;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (target.isSelf) return PersonAvatar(size: size, photoPath: target.photoPath, background: AppColors.primary50);
|
||||
final photo = target.photoPath;
|
||||
final placeholder = SvgPicture.asset(AppAssets.petDefault, fit: BoxFit.cover);
|
||||
return Semantics(
|
||||
label: target.name == null ? null : '${target.name} 사진',
|
||||
image: true,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(shape: BoxShape.circle, color: _kSharedTargetFill),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: photo == null ? placeholder : PhotoImage(path: photo, fallback: placeholder),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 사진 없는 아이 바탕 — PetAvatar 와 같은 옅은 회색
|
||||
const Color _kSharedTargetFill = Color(0x0A000000);
|
||||
|
||||
/// 책갈피 리본 — 반투명 유리 느낌(뒤를 흐리게) + 고른 색
|
||||
class DiaryRibbon extends StatelessWidget {
|
||||
const DiaryRibbon({super.key, required this.color, this.shared = false});
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/api/error_lexicon.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/router/app_routes.dart';
|
||||
import 'package:tma_app/core/theme/app_shadows.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary_invitation.dart';
|
||||
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/pressable.dart';
|
||||
import 'package:tma_app/shared/widgets/qr_scanner_page.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_button.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_dialog.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_sheet.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
|
||||
/// 공유 다이어리(초대·참여·멤버)는 다른 회원과 함께 쓰므로 서버가 있어야 한다
|
||||
const String kSharedDiaryLocalOnly = '기기 저장 모드에서는 공유 다이어리를 쓸 수 없어요';
|
||||
|
||||
/// 초대 코드 입력칸 최대 길이 (시안 maxLength 12)
|
||||
const int _kCodeMaxLength = 12;
|
||||
|
||||
/// 공유 다이어리 참여 스캐너 문구
|
||||
const QrScanCopy _kScanCopy = QrScanCopy(
|
||||
title: '공유 다이어리 참여',
|
||||
hint: '초대받은 다이어리의 QR 코드를\n사각형 안에 비춰주세요.',
|
||||
foundHint: '초대 QR을 찾았어요!',
|
||||
manualLabel: '초대 코드 입력하기',
|
||||
unavailableHint: '카메라를 쓸 수 없어요.\n설정에서 카메라 권한을 허용하거나 초대 코드를 입력해 주세요.',
|
||||
);
|
||||
|
||||
/// 공유 다이어리 참여 스캐너 — 초대 QR 을 비추면 참여할 다이어리를 불러온다
|
||||
class DiaryJoinScannerPage extends StatelessWidget {
|
||||
const DiaryJoinScannerPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QrScannerPage<DiaryInvitation>(
|
||||
copy: _kScanCopy,
|
||||
parse: parseInviteCode,
|
||||
// 초대 QR 이 아니면 서버가 못 찾았을 때와 같은 안내
|
||||
invalidMessage: ErrorLexicon.getMessage('DIARY_003'),
|
||||
load: (ref, code) => ref.read(diaryProvider.notifier).previewInvitation(code),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 참여하기 시트에서 고른 것 — QR 스캔으로 가거나, 코드로 찾은 초대
|
||||
sealed class _JoinChoice {
|
||||
const _JoinChoice();
|
||||
}
|
||||
|
||||
final class _ChoseScan extends _JoinChoice {
|
||||
const _ChoseScan();
|
||||
}
|
||||
|
||||
final class _Found extends _JoinChoice {
|
||||
const _Found(this.invitation);
|
||||
|
||||
final DiaryInvitation invitation;
|
||||
}
|
||||
|
||||
/// 공유 다이어리 참여 — 참여하기 시트(QR 스캔 / 코드 입력) → 확인 → 참여
|
||||
Future<void> startJoiningDiary(BuildContext context, WidgetRef ref) async {
|
||||
final diaries = ref.read(diaryProvider.notifier);
|
||||
final toast = ref.read(toastProvider.notifier);
|
||||
if (!diaries.canShare) {
|
||||
toast.show(kSharedDiaryLocalOnly, icon: RupIconName.users);
|
||||
return;
|
||||
}
|
||||
if (diaries.atMax) {
|
||||
toast.show('다이어리는 최대 $kMaxDiaries개까지예요', icon: RupIconName.alert);
|
||||
return;
|
||||
}
|
||||
|
||||
DiaryInvitation? invitation;
|
||||
// 스캐너에서 "초대 코드 입력하기"를 누르면 시트로 돌아온다
|
||||
while (invitation == null) {
|
||||
if (!context.mounted) return;
|
||||
final choice = await showRupSheet<_JoinChoice>(
|
||||
context,
|
||||
title: '공유 다이어리 참여',
|
||||
builder: (_) => const _JoinBody(),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
switch (choice) {
|
||||
case null:
|
||||
return;
|
||||
case _Found(invitation: final found):
|
||||
await confirmDiaryJoin(context, ref, found);
|
||||
return;
|
||||
case _ChoseScan():
|
||||
final outcome = await context.push<ScanOutcome<DiaryInvitation>>(AppRoutes.diaryJoinScan);
|
||||
switch (outcome) {
|
||||
case ScanFound(:final value):
|
||||
invitation = value;
|
||||
case ScanTypeCode():
|
||||
continue;
|
||||
case null:
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (context.mounted) await confirmDiaryJoin(context, ref, invitation);
|
||||
}
|
||||
|
||||
/// 참여 전 확인 — 이미 함께 쓰거나 자리가 없으면 서버와 같은 안내로 멈춘다 (앱 링크로 열 때도 쓴다)
|
||||
/// 참여했으면 true
|
||||
Future<bool> confirmDiaryJoin(BuildContext context, WidgetRef ref, DiaryInvitation invitation) async {
|
||||
final toast = ref.read(toastProvider.notifier);
|
||||
if (invitation.isMember) {
|
||||
toast.show(ErrorLexicon.getMessage('DIARY_005'), icon: RupIconName.alert);
|
||||
return false;
|
||||
}
|
||||
if (invitation.isFull) {
|
||||
toast.show(ErrorLexicon.getMessage('DIARY_004'), icon: RupIconName.alert);
|
||||
return false;
|
||||
}
|
||||
final ok = await showRupConfirm(
|
||||
context,
|
||||
title: '${invitation.name}에\n참여하시겠어요?',
|
||||
titleSpan: TextSpan(children: [
|
||||
TextSpan(text: invitation.name, style: const TextStyle(color: AppColors.primary600)),
|
||||
const TextSpan(text: '에\n참여하시겠어요?'),
|
||||
]),
|
||||
message: '공유 다이어리에 참여해서\n아이에 대한 기록을 함께 작성해봐요!',
|
||||
confirmLabel: '참여하기',
|
||||
layout: RupConfirmLayout.row,
|
||||
glyph: RupGlyph.diaryShared,
|
||||
iconColor: AppColors.accent,
|
||||
iconBackground: AppColors.accent100,
|
||||
);
|
||||
if (!ok) return false;
|
||||
if (!await succeeded(ref.read(diaryProvider.notifier).join(invitation.code))) return false;
|
||||
toast.show('공유 다이어리에 참여했어요');
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 참여하기 시트 — QR 스캔 / 또는 / 초대 코드 입력 + 참여하기
|
||||
class _JoinBody extends ConsumerStatefulWidget {
|
||||
const _JoinBody();
|
||||
|
||||
@override
|
||||
ConsumerState<_JoinBody> createState() => _JoinBodyState();
|
||||
}
|
||||
|
||||
class _JoinBodyState extends ConsumerState<_JoinBody> {
|
||||
final _code = TextEditingController();
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 입력이 생기면 버튼을 켠다
|
||||
_code.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_code.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_busy) return;
|
||||
final toast = ref.read(toastProvider.notifier);
|
||||
if (_code.text.trim().isEmpty) {
|
||||
toast.show('초대 코드를 입력해주세요', icon: RupIconName.alert);
|
||||
return;
|
||||
}
|
||||
final code = parseInviteCode(_code.text);
|
||||
if (code == null) {
|
||||
// 초대 코드 모양이 아니다 — 서버가 못 찾았을 때와 같은 안내
|
||||
toast.show(ErrorLexicon.getMessage('DIARY_003'), icon: RupIconName.alert);
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final invitation = await ref.read(diaryProvider.notifier).previewInvitation(code);
|
||||
if (mounted) Navigator.of(context).pop(_Found(invitation));
|
||||
} catch (e) {
|
||||
if (!isReportedApiFailure(e)) rethrow;
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
TextStyle ui(double size, FontWeight weight, Color color) =>
|
||||
textTheme.bodyMedium!.copyWith(fontSize: size, fontWeight: weight, color: color, height: AppTypography.normalHeight);
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.xs),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1) QR 스캔 — 가장 빠른 방법
|
||||
Pressable(
|
||||
pressedScale: 1,
|
||||
pressedBrightness: .97,
|
||||
onTap: () => Navigator.of(context).pop(const _ChoseScan()),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary50,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(color: AppColors.primary100, width: 1.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(AppSpacing.md),
|
||||
boxShadow: AppShadows.sm,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.qrcode, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('QR 코드 스캔', style: ui(15, FontWeight.w700, AppColors.ink)),
|
||||
const SizedBox(height: 2),
|
||||
Text('초대 QR을 비추면 바로 확인돼요', style: ui(12, FontWeight.w400, AppColors.muted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 구분선 + "또는" (시안 .rup-orline)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: Divider(height: 1, thickness: 1, color: AppColors.line2)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text('또는', style: ui(12, FontWeight.w700, AppColors.muted)),
|
||||
),
|
||||
const Expanded(child: Divider(height: 1, thickness: 1, color: AppColors.line2)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 2) 초대 코드 직접 입력
|
||||
RupField(
|
||||
label: '초대 코드 입력',
|
||||
child: RupTextField(
|
||||
controller: _code,
|
||||
hintText: 'RUP-8F3K2Q',
|
||||
maxLength: _kCodeMaxLength,
|
||||
textInputAction: TextInputAction.done,
|
||||
textAlign: TextAlign.center,
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 19,
|
||||
letterSpacing: 19 * .06,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
),
|
||||
inputFormatters: [_UpperCaseFormatter()],
|
||||
onSubmitted: (_) => unawaited(_submit()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
RupButton(
|
||||
label: '참여하기',
|
||||
large: true,
|
||||
dimmed: _code.text.trim().isEmpty || _busy,
|
||||
dimmedOpacity: .45,
|
||||
onPressed: () => unawaited(_submit()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 초대 코드는 대문자로 받는다 (시안 toUpperCase)
|
||||
class _UpperCaseFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) =>
|
||||
newValue.copyWith(text: newValue.text.toUpperCase());
|
||||
}
|
||||
@@ -37,17 +37,21 @@ TextStyle _ui(BuildContext context, double size, FontWeight weight, Color color,
|
||||
TextStyle _display(double size, {FontWeight weight = FontWeight.w700, Color color = AppColors.ink}) =>
|
||||
TextStyle(fontFamily: AppFonts.display, fontSize: size, fontWeight: weight, color: color, height: AppTypography.displayNormalHeight);
|
||||
|
||||
/// 글쓴이 아바타 — 지금은 나 하나
|
||||
/// 글쓴이 아바타 — 남이면 그 사람 사진, 나면(null) 내 프로필 사진
|
||||
class _AuthorAvatar extends ConsumerWidget {
|
||||
const _AuthorAvatar({required this.size});
|
||||
const _AuthorAvatar({required this.size, this.author});
|
||||
|
||||
final double size;
|
||||
final DiaryPerson? author;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) =>
|
||||
PersonAvatar(size: size, photoPath: ref.watch(profileProvider).photoPath);
|
||||
PersonAvatar(size: size, photoPath: author == null ? ref.watch(profileProvider).photoPath : author!.photoPath);
|
||||
}
|
||||
|
||||
/// 글쓴이 이름 — 남이면 그 사람 닉네임, 나면 내 닉네임
|
||||
String _authorName(WidgetRef ref, DiaryPerson? author) => author?.name ?? ref.watch(userNicknameProvider);
|
||||
|
||||
/// 글쓴이 · 날짜 · 기분 · ⋮
|
||||
class _PostMeta extends ConsumerWidget {
|
||||
const _PostMeta({required this.post, required this.onMenu, this.avatar = 28, this.nameSize = 13});
|
||||
@@ -62,20 +66,21 @@ class _PostMeta extends ConsumerWidget {
|
||||
final mood = post.mood;
|
||||
return Row(
|
||||
children: [
|
||||
_AuthorAvatar(size: avatar),
|
||||
_AuthorAvatar(size: avatar, author: post.author),
|
||||
SizedBox(width: avatar > 30 ? 10 : 9),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(ref.watch(userNicknameProvider), style: _display(nameSize)),
|
||||
Text(_authorName(ref, post.author), style: _display(nameSize)),
|
||||
const SizedBox(height: 1),
|
||||
Text(relativeDayLabel(post.date, DateTime.now()), style: _ui(context, 11, FontWeight.w400, AppColors.muted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (mood != null) ...[MoodIcon(mood: mood, size: 22), const SizedBox(width: 9)],
|
||||
_MoreButton(onTap: onMenu),
|
||||
if (mood != null) ...[MoodIcon(mood: mood, size: 22), if (post.isMine) const SizedBox(width: 9)],
|
||||
// 수정·삭제는 쓴 사람만
|
||||
if (post.isMine) _MoreButton(onTap: onMenu),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -323,7 +328,6 @@ class _CommentsState extends ConsumerState<_Comments> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final comments = ref.watch(diaryBookProvider.select((b) => b.commentsOf(widget.postId)));
|
||||
final name = ref.watch(userNicknameProvider);
|
||||
final muted = _ui(context, 12, FontWeight.w600, AppColors.muted);
|
||||
|
||||
return Container(
|
||||
@@ -343,7 +347,7 @@ class _CommentsState extends ConsumerState<_Comments> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const _AuthorAvatar(size: 30),
|
||||
_AuthorAvatar(size: 30, author: c.author),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -351,8 +355,8 @@ class _CommentsState extends ConsumerState<_Comments> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(name, style: _display(12.5))),
|
||||
if (_editingId != c.id) ...[
|
||||
Expanded(child: Text(_authorName(ref, c.author), style: _display(12.5))),
|
||||
if (c.isMine && _editingId != c.id) ...[
|
||||
_SmallAction(label: '수정', style: muted, onTap: () => setState(() {
|
||||
_editingId = c.id;
|
||||
_edit.text = c.text;
|
||||
@@ -558,29 +562,30 @@ class PhotoPostTile extends StatelessWidget {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(color: AppColors.primary50, child: PetPhotoImage(path: post.photoPath!)),
|
||||
Positioned(
|
||||
top: 7,
|
||||
right: 7,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: '기록 관리',
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: onMenu,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [BoxShadow(color: Color(0x2E000000), blurRadius: 4, offset: Offset(0, 1))],
|
||||
if (post.isMine)
|
||||
Positioned(
|
||||
top: 7,
|
||||
right: 7,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: '기록 관리',
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: onMenu,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: const [BoxShadow(color: Color(0x2E000000), blurRadius: 4, offset: Offset(0, 1))],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.moreV, size: 17, color: AppColors.ink2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.moreV, size: 17, color: AppColors.ink2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/theme/app_shadows.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/auth/presentation/providers/user_provider.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary_invitation.dart';
|
||||
import 'package:tma_app/features/mypage/data/profile_repository.dart';
|
||||
import 'package:tma_app/shared/widgets/blurred_modal.dart';
|
||||
import 'package:tma_app/shared/widgets/dashed_border_box.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/person_avatar.dart';
|
||||
import 'package:tma_app/shared/widgets/pressable.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_button.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_card.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
import 'package:tma_app/shared/widgets/section_title.dart';
|
||||
|
||||
const double _kNormal = AppTypography.normalHeight;
|
||||
const double _kMemberAvatar = 40;
|
||||
const double _kInviteQr = 150;
|
||||
const double _kZoomQr = 230;
|
||||
|
||||
TextStyle _ui(BuildContext context, double size, FontWeight weight, Color color) =>
|
||||
Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: size, fontWeight: weight, color: color, height: _kNormal);
|
||||
|
||||
/// 멤버 사진 — 나면 내 프로필 사진
|
||||
class MemberAvatar extends ConsumerWidget {
|
||||
const MemberAvatar({super.key, required this.person, required this.size});
|
||||
|
||||
final DiaryPerson person;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final photo = person.isMe ? ref.watch(profileProvider).photoPath : person.photoPath;
|
||||
return PersonAvatar(size: size, photoPath: photo);
|
||||
}
|
||||
}
|
||||
|
||||
/// 멤버 이름 — 나면 내 닉네임
|
||||
String memberName(WidgetRef ref, DiaryPerson person) => person.isMe ? ref.watch(userNicknameProvider) : person.name;
|
||||
|
||||
/// 멤버 목록 — 주인 표시, 주인이 보면 "내보내기", 자리가 남으면 초대 줄
|
||||
class DiaryMembersCard extends ConsumerWidget {
|
||||
const DiaryMembersCard({super.key, required this.diary, required this.onRemove, required this.onInvite});
|
||||
|
||||
final Diary diary;
|
||||
final ValueChanged<DiaryMember> onRemove;
|
||||
|
||||
/// 초대 줄을 누르면 아래 "멤버 초대하기" 로 내려간다
|
||||
final VoidCallback onInvite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final rows = <Widget>[
|
||||
for (final m in diary.members)
|
||||
_MemberRow(
|
||||
avatar: MemberAvatar(person: m, size: _kMemberAvatar),
|
||||
name: '${memberName(ref, m)}${m.isMe ? ' (나)' : ''}',
|
||||
trailing: m.isOwner
|
||||
? const _OwnerBadge()
|
||||
: diary.isOwner
|
||||
? _KickButton(name: memberName(ref, m), onTap: () => onRemove(m))
|
||||
: null,
|
||||
),
|
||||
if (diary.isOwner && !diary.membersFull)
|
||||
Pressable(
|
||||
pressedScale: 1,
|
||||
pressedOpacity: .7,
|
||||
onTap: onInvite,
|
||||
child: _MemberRow(
|
||||
avatar: const DashedBorderBox(
|
||||
color: AppColors.line,
|
||||
strokeWidth: 2,
|
||||
radius: _kMemberAvatar / 2,
|
||||
child: SizedBox.square(
|
||||
dimension: _kMemberAvatar - 4,
|
||||
child: Center(child: RupIcon(RupIconName.plus, size: 20, stroke: 2.2, color: AppColors.muted)),
|
||||
),
|
||||
),
|
||||
name: '${kMaxDiaryMembers - diary.memberCount}명 더 초대할 수 있어요',
|
||||
muted: true,
|
||||
trailing: const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted),
|
||||
),
|
||||
),
|
||||
];
|
||||
return RupCard(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, row) in rows.indexed)
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2))),
|
||||
child: row,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberRow extends StatelessWidget {
|
||||
const _MemberRow({required this.avatar, required this.name, this.trailing, this.muted = false});
|
||||
|
||||
final Widget avatar;
|
||||
final String name;
|
||||
final Widget? trailing;
|
||||
|
||||
/// 초대 줄 — 흐린 작은 글
|
||||
final bool muted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
avatar,
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: muted
|
||||
? _ui(context, 13.5, FontWeight.w600, AppColors.muted)
|
||||
: _ui(context, 15, FontWeight.w700, AppColors.ink),
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[const SizedBox(width: AppSpacing.sm), trailing!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "내보내기" — 작은 흰 버튼 (시안 .rup-btn--ghost, 7/13 · 12.5)
|
||||
class _KickButton extends StatelessWidget {
|
||||
const _KickButton({required this.name, required this.onTap});
|
||||
|
||||
final String name;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Pressable(
|
||||
onTap: onTap,
|
||||
semanticLabel: '$name님 내보내기',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
border: Border.all(color: AppColors.line, width: 1.5),
|
||||
),
|
||||
child: Text('내보내기', style: AppTypography.button(12.5, color: AppColors.ink)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "주인" 표시 (시안: primary-50 바탕, primary-100 테두리)
|
||||
class _OwnerBadge extends StatelessWidget {
|
||||
const _OwnerBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary50,
|
||||
borderRadius: BorderRadius.circular(AppSpacing.sm),
|
||||
border: Border.all(color: AppColors.primary100),
|
||||
),
|
||||
child: Text('주인', style: _ui(context, 11.5, FontWeight.w700, AppColors.primary600)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 참여한 다이어리 — 누가 만든 다이어리인지 (주인이 아닐 때 개인/공유 선택 대신)
|
||||
class JoinedDiaryCard extends ConsumerWidget {
|
||||
const JoinedDiaryCard({super.key, required this.diary});
|
||||
|
||||
final Diary diary;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final owner = diary.owner;
|
||||
return RupCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(color: AppColors.accent100, borderRadius: BorderRadius.circular(AppSpacing.md)),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.users, size: 21, stroke: 2, color: AppColors.accent),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('공유 다이어리', style: _ui(context, 14.5, FontWeight.w700, AppColors.ink)),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
'${owner == null ? kUnknownPersonName : memberName(ref, owner)}님이 만든 다이어리예요',
|
||||
style: _ui(context, 12, FontWeight.w400, AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 초대 코드 복사 — 클립보드에 넣고 알린다
|
||||
Future<void> copyInviteCode(WidgetRef ref, String code) async {
|
||||
final toast = ref.read(toastProvider.notifier);
|
||||
await Clipboard.setData(ClipboardData(text: code));
|
||||
toast.show('초대 코드를 복사했어요', icon: RupIconName.copy);
|
||||
}
|
||||
|
||||
/// 초대 QR — 가운데 다이어리 표시
|
||||
class _InviteQr extends StatelessWidget {
|
||||
const _InviteQr({required this.code, required this.size, required this.badge, required this.padding});
|
||||
|
||||
final String code;
|
||||
final double size;
|
||||
final double badge;
|
||||
final double padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(padding),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(padding + 6),
|
||||
border: Border.all(color: AppColors.line),
|
||||
boxShadow: AppShadows.sm,
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
QrImageView(
|
||||
data: diaryInviteLink(code),
|
||||
size: size,
|
||||
padding: EdgeInsets.zero,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.H,
|
||||
semanticsLabel: '다이어리 초대 QR 코드',
|
||||
),
|
||||
Container(
|
||||
width: badge,
|
||||
height: badge,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(badge * .32),
|
||||
boxShadow: [BoxShadow(color: Colors.white, spreadRadius: badge / 10)],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: RupGlyphIcon(RupGlyph.diary, size: badge / 2, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 멤버 초대하기 — QR(누르면 커진다) + 초대 코드 복사 + 안내
|
||||
class DiaryInviteSection extends ConsumerWidget {
|
||||
const DiaryInviteSection({super.key, required this.diary, required this.code});
|
||||
|
||||
final Diary diary;
|
||||
final String code;
|
||||
|
||||
Future<void> _zoom(BuildContext context, WidgetRef ref) {
|
||||
return showBlurredDialog<void>(
|
||||
context,
|
||||
builder: (dialogContext) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
insetPadding: const EdgeInsets.all(AppSpacing.xl),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 330),
|
||||
child: RupCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('초대 QR', style: _ui(dialogContext, 17, FontWeight.w800, AppColors.ink)),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text.rich(
|
||||
TextSpan(children: [
|
||||
const TextSpan(text: '상대방이 rup 카메라로 스캔하면\n'),
|
||||
TextSpan(text: diary.name, style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.ink)),
|
||||
const TextSpan(text: '에 함께 기록해요.'),
|
||||
]),
|
||||
textAlign: TextAlign.center,
|
||||
style: _ui(dialogContext, 13, FontWeight.w400, AppColors.ink2).copyWith(height: 1.55),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_InviteQr(code: code, size: _kZoomQr, badge: 56, padding: 18),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
code,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 24,
|
||||
letterSpacing: 24 * .08,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
RupButton(
|
||||
label: '초대 코드 복사',
|
||||
large: true,
|
||||
variant: RupButtonVariant.soft,
|
||||
leading: const RupIcon(RupIconName.copy, size: 17, stroke: 2.1, color: AppColors.primary600),
|
||||
onPressed: () => unawaited(copyInviteCode(ref, code)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const CountSectionTitle(title: '멤버 초대하기'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, AppSpacing.xs, AppSpacing.xs, AppSpacing.lg),
|
||||
child: Text(
|
||||
'QR코드 혹은 초대코드로 함께 기록할 사람을 불러보세요.',
|
||||
style: _ui(context, 13, FontWeight.w400, AppColors.muted).copyWith(height: 1.55),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: '초대 QR 크게 보기',
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(_zoom(context, ref)),
|
||||
child: _InviteQr(code: code, size: _kInviteQr, badge: 40, padding: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const RupIcon(RupIconName.search, size: 13, stroke: 2, color: AppColors.muted),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text('탭하면 QR이 커져요', style: _ui(context, 11.5, FontWeight.w400, AppColors.muted)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: '초대 코드 복사',
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(copyInviteCode(ref, code)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.line, width: 1.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('초대 코드', style: _ui(context, 12, FontWeight.w700, AppColors.muted)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
code,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 21,
|
||||
letterSpacing: 21 * .06,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const RupIcon(RupIconName.copy, size: 19, stroke: 2, color: AppColors.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, AppSpacing.lg, AppSpacing.xs, 22),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 1),
|
||||
child: RupIcon(RupIconName.alert, size: 15, stroke: 2.2, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
const TextSpan(children: [
|
||||
TextSpan(text: '받은 사람은 '),
|
||||
TextSpan(text: '다이어리 추가 → 공유 다이어리 참여하기', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
TextSpan(text: '에서 코드를 입력하면 함께 기록할 수 있어요.'),
|
||||
]),
|
||||
style: _ui(context, 12.5, FontWeight.w400, AppColors.ink2).copyWith(height: 1.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
|
||||
import 'package:tma_app/features/diary/presentation/widgets/diary_cover.dart';
|
||||
import 'package:tma_app/features/diary/presentation/widgets/diary_join.dart';
|
||||
import 'package:tma_app/features/pet/data/pet_photo_store.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet_target.dart';
|
||||
@@ -25,9 +26,6 @@ import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
|
||||
const double _kNormal = AppTypography.normalHeight;
|
||||
|
||||
/// 공유 다이어리(초대·참여·멤버)는 서버가 필요해 아직 열지 않는다
|
||||
const String kSharedDiarySoon = '공유 다이어리는 곧 열릴 예정이에요';
|
||||
|
||||
/// 표지 편집 칸 높이 (수정 시트) — 만들 때는 정사각형
|
||||
const double _kEditCoverHeight = 150;
|
||||
const double _kCoverRadius = 10;
|
||||
@@ -135,7 +133,7 @@ Future<void> showAddDiarySheet(BuildContext context, WidgetRef ref) {
|
||||
description: '초대 코드로 함께 쓰는 다이어리에 참여해요',
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users);
|
||||
unawaited(startJoiningDiary(context, ref));
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -167,6 +165,7 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> {
|
||||
late final List<String> _targets = [...?widget.editing?.targetIds];
|
||||
late Color _ribbon = widget.editing?.ribbon ?? kRibbonColors.first;
|
||||
late String? _cover = widget.editing?.coverPath;
|
||||
bool _shared = false;
|
||||
bool _saving = false;
|
||||
|
||||
bool get _editing => widget.editing != null;
|
||||
@@ -203,8 +202,14 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> {
|
||||
targetIds: List.of(_targets),
|
||||
ribbon: _ribbon,
|
||||
coverPath: _cover,
|
||||
shared: _shared,
|
||||
);
|
||||
toast.show(created == null ? '다이어리는 최대 $kMaxDiaries개까지예요' : '새 다이어리를 만들었어요',
|
||||
toast.show(
|
||||
created == null
|
||||
? '다이어리는 최대 $kMaxDiaries개까지예요'
|
||||
: _shared
|
||||
? '공유 다이어리를 만들었어요'
|
||||
: '새 다이어리를 만들었어요',
|
||||
icon: created == null ? RupIconName.alert : RupIconName.check);
|
||||
} else {
|
||||
await notifier.updateDiary(editing.copyWith(
|
||||
@@ -270,14 +275,18 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> {
|
||||
RupSegmented<bool>(
|
||||
options: const [(false, '개인 다이어리'), (true, '공유 다이어리')],
|
||||
icons: const [RupIconName.user, RupIconName.users],
|
||||
value: false,
|
||||
value: _shared,
|
||||
onChanged: (shared) {
|
||||
if (shared) ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users);
|
||||
if (shared && !ref.read(diaryProvider.notifier).canShare) {
|
||||
ref.read(toastProvider.notifier).show(kSharedDiaryLocalOnly, icon: RupIconName.users);
|
||||
return;
|
||||
}
|
||||
setState(() => _shared = shared);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
'나만 기록하는 다이어리예요. 나중에 공유로 바꿀 수 있어요.',
|
||||
_shared ? '만든 뒤 가족·지인을 초대해 함께 기록할 수 있어요.' : '나만 기록하는 다이어리예요. 나중에 공유로 바꿀 수 있어요.',
|
||||
style: _ui(context, 11.5, FontWeight.w400, AppColors.muted).copyWith(height: 1.45),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -120,7 +120,7 @@ class _HomeDiaryTile extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final diary = book.diary(post.diaryId);
|
||||
// 누구의 기록인지 — 다이어리의 첫 아이, 없으면 다이어리 이름
|
||||
final who = diary == null ? '' : (diary.targetIds.map((id) => names[id]).nonNulls.firstOrNull ?? diary.name);
|
||||
final who = diary == null ? '' : (diary.firstTargetName(names) ?? diary.name);
|
||||
final photo = post.photoPath;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/api/error_lexicon.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/router/app_routes.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary_invitation.dart';
|
||||
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
|
||||
import 'package:tma_app/features/diary/presentation/widgets/diary_join.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/receive_card_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart';
|
||||
import 'package:tma_app/features/records/presentation/providers/records_tab_provider.dart';
|
||||
import 'package:tma_app/features/schedule/domain/calendar_sharing.dart';
|
||||
import 'package:tma_app/features/schedule/presentation/providers/calendar_sharing_providers.dart';
|
||||
import 'package:tma_app/features/schedule/presentation/widgets/calendar_link_flow.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
|
||||
/// 앱 링크로 여는 것 — 기기 카메라로 QR 을 찍어 앱이 열렸을 때
|
||||
enum AppLinkKind {
|
||||
/// 펫 명함 받기 — rup://app/pet-card/<명함 코드>
|
||||
petCard,
|
||||
|
||||
/// 공유 다이어리 참여 — rup://app/diary-invite/<초대 코드>
|
||||
diaryInvite,
|
||||
|
||||
/// 일정 연동 — rup://app/calendar-invite/<초대 코드>
|
||||
calendarInvite,
|
||||
}
|
||||
|
||||
/// 앱 링크 도착 화면 — 링크의 코드를 서버에서 확인하고 받기·참여·연동 확인으로 이어 준다.
|
||||
/// 끝나면(취소·실패 포함) 그 기능의 탭으로 보낸다. 실패 알림은 인터셉터가 띄운다.
|
||||
class LinkLandingPage extends ConsumerStatefulWidget {
|
||||
const LinkLandingPage({super.key, required this.kind, required this.code});
|
||||
|
||||
final AppLinkKind kind;
|
||||
|
||||
/// 링크에 담긴 코드 (아직 확인 전)
|
||||
final String code;
|
||||
|
||||
@override
|
||||
ConsumerState<LinkLandingPage> createState() => _LinkLandingPageState();
|
||||
}
|
||||
|
||||
class _LinkLandingPageState extends ConsumerState<LinkLandingPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 첫 화면을 그린 뒤 시작한다 — 확인 창은 이 화면 위에 뜬다
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_open());
|
||||
});
|
||||
}
|
||||
|
||||
ToastNotifier get _toast => ref.read(toastProvider.notifier);
|
||||
|
||||
Future<void> _open() async {
|
||||
final router = GoRouter.of(context);
|
||||
try {
|
||||
switch (widget.kind) {
|
||||
case AppLinkKind.petCard:
|
||||
await _openCard(router);
|
||||
case AppLinkKind.diaryInvite:
|
||||
await _openDiary(router);
|
||||
case AppLinkKind.calendarInvite:
|
||||
await _openCalendar(router);
|
||||
}
|
||||
} catch (e) {
|
||||
// 서버 실패는 인터셉터가 알렸다 — 그 기능의 탭으로 보낸다
|
||||
if (!isReportedApiFailure(e)) rethrow;
|
||||
router.go(switch (widget.kind) {
|
||||
AppLinkKind.petCard => AppRoutes.petCards,
|
||||
AppLinkKind.diaryInvite || AppLinkKind.calendarInvite => AppRoutes.records,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 명함 — 미리 보고 저장 확인 화면으로 (펫 명함 탭 위에 연다)
|
||||
Future<void> _openCard(GoRouter router) async {
|
||||
final cards = ref.read(receivedCardsProvider.notifier);
|
||||
final code = parseCardCode(widget.code);
|
||||
if (!cards.available || code == null) {
|
||||
_toast.show(
|
||||
cards.available ? ErrorLexicon.getMessage('CARD_002') : kReceiveCardLocalOnly,
|
||||
icon: RupIconName.qrcode,
|
||||
);
|
||||
router.go(AppRoutes.petCards);
|
||||
return;
|
||||
}
|
||||
final card = await cards.preview(code);
|
||||
router.go(AppRoutes.petCards);
|
||||
unawaited(router.push(AppRoutes.petCardReceive, extra: card));
|
||||
}
|
||||
|
||||
/// 다이어리 — 미리 보고 참여 확인, 끝나면 다이어리 책장으로
|
||||
Future<void> _openDiary(GoRouter router) async {
|
||||
final diaries = ref.read(diaryProvider.notifier);
|
||||
ref.read(recordsTabProvider.notifier).select(RecordsTab.diary);
|
||||
final code = parseInviteCode(widget.code);
|
||||
// 책장이 가득 찼는지 보려면 다이어리를 먼저 읽어야 한다
|
||||
await ref.read(diaryProvider.future);
|
||||
final stop = !diaries.canShare
|
||||
? kSharedDiaryLocalOnly
|
||||
: code == null
|
||||
? ErrorLexicon.getMessage('DIARY_003')
|
||||
: diaries.atMax
|
||||
? '다이어리는 최대 $kMaxDiaries개까지예요'
|
||||
: null;
|
||||
if (stop != null || code == null) {
|
||||
_toast.show(stop!, icon: RupIconName.users);
|
||||
router.go(AppRoutes.records);
|
||||
return;
|
||||
}
|
||||
final invitation = await diaries.previewInvitation(code);
|
||||
if (mounted) await confirmDiaryJoin(context, ref, invitation);
|
||||
router.go(AppRoutes.records);
|
||||
}
|
||||
|
||||
/// 일정 — 미리 보고 연동 확인, 연동했으면 함께 관리 화면까지
|
||||
Future<void> _openCalendar(GoRouter router) async {
|
||||
ref.read(recordsTabProvider.notifier).select(RecordsTab.schedule);
|
||||
final code = parseCalendarCode(widget.code);
|
||||
if (!await canStartCalendarLink(ref)) {
|
||||
router.go(AppRoutes.records);
|
||||
return;
|
||||
}
|
||||
if (code == null) {
|
||||
_toast.show(ErrorLexicon.getMessage('SCHED_001'), icon: RupIconName.users);
|
||||
router.go(AppRoutes.records);
|
||||
return;
|
||||
}
|
||||
final invitation = await ref.read(calendarSharingProvider.notifier).previewInvitation(code);
|
||||
final linked = mounted && await confirmCalendarLink(context, ref, invitation);
|
||||
router.go(AppRoutes.records);
|
||||
if (linked) unawaited(router.push(AppRoutes.shareCalendar));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.6, color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 링크의 경로 → 무엇을 열지 (라우터가 쓴다)
|
||||
String appLinkPattern(AppLinkKind kind) => switch (kind) {
|
||||
AppLinkKind.petCard => '/$kCardLinkSegment/:code',
|
||||
AppLinkKind.diaryInvite => '/$kDiaryInviteLinkSegment/:code',
|
||||
AppLinkKind.calendarInvite => '/$kCalendarInviteLinkSegment/:code',
|
||||
};
|
||||
@@ -22,6 +22,7 @@ class AppNotice {
|
||||
required this.at,
|
||||
required this.text,
|
||||
this.who,
|
||||
this.whoPhotoPath,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -35,6 +36,9 @@ class AppNotice {
|
||||
|
||||
/// 새 글·댓글을 남긴 사람 이름
|
||||
final String? who;
|
||||
|
||||
/// 그 사람의 프로필 사진
|
||||
final String? whoPhotoPath;
|
||||
}
|
||||
|
||||
/// 알림을 묶는 날 — 오늘 · 어제 · 최근 7일
|
||||
|
||||
@@ -3,11 +3,13 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/router/app_routes.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/core/utils/clock.dart';
|
||||
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
|
||||
import 'package:tma_app/features/notifications/domain/app_notice.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
@@ -37,6 +39,10 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(ref.read(noticeSeenProvider.notifier).markSeen(ref.read(clockProvider)()));
|
||||
});
|
||||
// 공유 다이어리가 있으면 다른 멤버의 새 글·댓글을 다시 읽는다
|
||||
if (ref.read(diaryBookProvider).diaries.any((d) => d.shared)) {
|
||||
unawaited(succeeded(ref.read(diaryProvider.notifier).refresh()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -147,7 +153,7 @@ class _NoticeRow extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (n.who != null)
|
||||
const PersonAvatar(size: _kAvatar)
|
||||
PersonAvatar(size: _kAvatar, photoPath: n.whoPhotoPath)
|
||||
else
|
||||
Container(
|
||||
width: _kAvatar,
|
||||
|
||||
@@ -31,8 +31,11 @@ class NoticeSeenNotifier extends Notifier<DateTime?> {
|
||||
|
||||
final noticeSeenProvider = NotifierProvider<NoticeSeenNotifier, DateTime?>(NoticeSeenNotifier.new);
|
||||
|
||||
/// 알림으로 보여 줄 기간 — 알림 화면은 최근 7일까지 묶어 보여 준다 ([NoticeDay])
|
||||
const Duration _kNoticeWindow = Duration(days: 7);
|
||||
|
||||
/// 모든 다이어리의 알림 — 최근 것이 위로.
|
||||
/// 지금은 기기에서 만드는 기록 알림만 있다. 공유 다이어리의 새 글·댓글은 서버가 생기면 여기에 더한다.
|
||||
/// 기록 알림은 기기에서 만들고, 공유 다이어리의 새 글·댓글은 서버에서 읽은 기록·댓글 중 남이 쓴 것으로 만든다.
|
||||
final noticesProvider = Provider<List<AppNotice>>((ref) => _watchNotices(ref));
|
||||
|
||||
/// 알림 목록을 알림기에서 바로 계산한다 — 파생 프로바이더끼리 잇지 않는다 ([petListProvider] 주석 참고)
|
||||
@@ -50,7 +53,7 @@ List<AppNotice> _watchNotices(Ref ref) {
|
||||
if (settings[AppSetting.scheduleNotification] == false) return const [];
|
||||
|
||||
final book = ref.watch(diaryProvider).value ?? DiaryBook.empty;
|
||||
final notices = <AppNotice>[];
|
||||
final notices = <AppNotice>[...diaryActivityNotices(book, watchShelf(ref), now)];
|
||||
if (now.hour >= kDiaryReminderHour) {
|
||||
final at = DateTime(now.year, now.month, now.day, kDiaryReminderHour);
|
||||
// 추억 보관함으로 간 다이어리는 알리지 않는다
|
||||
@@ -69,6 +72,49 @@ List<AppNotice> _watchNotices(Ref ref) {
|
||||
return notices..sort((a, b) => b.at.compareTo(a.at));
|
||||
}
|
||||
|
||||
/// 공유 다이어리에서 다른 멤버가 남긴 새 글 · 내 기록에 달린 댓글 (최근 7일, 다이어리별 알림 설정을 따른다)
|
||||
List<AppNotice> diaryActivityNotices(DiaryBook book, List<Diary> shelf, DateTime now) {
|
||||
final since = now.subtract(_kNoticeWindow);
|
||||
final shared = {for (final d in shelf.where((d) => d.shared)) d.id: d};
|
||||
final notices = <AppNotice>[];
|
||||
final myPosts = <String, DiaryPost>{};
|
||||
for (final p in book.posts) {
|
||||
final diary = shared[p.diaryId];
|
||||
if (diary == null) continue;
|
||||
if (p.isMine) {
|
||||
myPosts[p.id] = p;
|
||||
continue;
|
||||
}
|
||||
if (!diary.notifies(DiaryNotice.newPost) || p.createdAt.isBefore(since)) continue;
|
||||
notices.add(AppNotice(
|
||||
id: 'post-${p.id}',
|
||||
type: NoticeType.newPost,
|
||||
diaryId: diary.id,
|
||||
diaryName: diary.name,
|
||||
at: p.createdAt,
|
||||
text: p.text,
|
||||
who: p.author?.name,
|
||||
whoPhotoPath: p.author?.photoPath,
|
||||
));
|
||||
}
|
||||
for (final c in book.comments) {
|
||||
final post = myPosts[c.postId];
|
||||
final diary = post == null ? null : shared[post.diaryId];
|
||||
if (diary == null || c.isMine || !diary.notifies(DiaryNotice.newComment) || c.createdAt.isBefore(since)) continue;
|
||||
notices.add(AppNotice(
|
||||
id: 'comment-${c.id}',
|
||||
type: NoticeType.newComment,
|
||||
diaryId: diary.id,
|
||||
diaryName: diary.name,
|
||||
at: c.createdAt,
|
||||
text: c.text,
|
||||
who: c.author?.name,
|
||||
whoPhotoPath: c.author?.photoPath,
|
||||
));
|
||||
}
|
||||
return notices;
|
||||
}
|
||||
|
||||
/// 아직 보지 않은 알림이 있는지 — 홈 종에 점을 찍는다
|
||||
final hasUnreadNoticeProvider = Provider<bool>((ref) {
|
||||
final seen = ref.watch(noticeSeenProvider);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:tma_app/core/config/app_links.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
@@ -63,17 +64,18 @@ class ReceivedCard extends SharedCard {
|
||||
}
|
||||
}
|
||||
|
||||
/// 명함 QR 에 담는 주소 — 앱의 스캐너가 읽는다 (시스템 카메라로 찍으면 앱을 연다)
|
||||
String petCardDeepLink(String code) => 'rup://pet-card/$code';
|
||||
/// 명함 링크의 경로 앞부분 — rup://app/pet-card/0428-117 (라우터 경로 /pet-card/:code)
|
||||
const String kCardLinkSegment = 'pet-card';
|
||||
|
||||
/// 명함 QR 에 담는 주소 — 앱의 스캐너가 읽고, 기기 카메라로 찍으면 앱이 열려 바로 받기로 간다
|
||||
String petCardDeepLink(String code) => appLink('/$kCardLinkSegment/$code');
|
||||
|
||||
final RegExp _kCardCode = RegExp(r'^\d{4}-\d{3}$');
|
||||
|
||||
/// QR 또는 직접 입력한 값에서 명함 코드를 꺼낸다 — 주소(rup://pet-card/0428-117)·코드(0428-117)·
|
||||
/// 하이픈 없이 쓴 7자리(0428117) 모두 받는다. 명함 코드가 아니면 null
|
||||
/// QR 또는 직접 입력한 값에서 명함 코드를 꺼낸다 — 주소(rup://app/pet-card/0428-117, 예전 rup://pet-card/…)·
|
||||
/// 코드(0428-117)·하이픈 없이 쓴 7자리(0428117) 모두 받는다. 명함 코드가 아니면 null
|
||||
String? parseCardCode(String raw) {
|
||||
var text = raw.trim();
|
||||
const prefix = 'rup://pet-card/';
|
||||
if (text.startsWith(prefix)) text = text.substring(prefix.length);
|
||||
var text = stripAppLink(raw.trim(), kCardLinkSegment);
|
||||
text = text.split('?').first.trim();
|
||||
if (RegExp(r'^\d{7}$').hasMatch(text)) text = '${text.substring(0, 4)}-${text.substring(4)}';
|
||||
return _kCardCode.hasMatch(text) ? text : null;
|
||||
|
||||
@@ -1,463 +1,30 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:tma_app/core/api/api_guard.dart';
|
||||
import 'package:tma_app/core/api/error_lexicon.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/pressable.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
import 'package:tma_app/shared/widgets/qr_scanner_page.dart';
|
||||
|
||||
// 시안 .rup-scanner 치수·색
|
||||
const Color _kScannerBg = Color(0xFF14110E);
|
||||
const Color _kDim = Color(0x99120F0C); // rgba(18,15,12,.6)
|
||||
const Color _kFoundGreen = Color(0xFF4ED08A);
|
||||
const double _kFrame = 244;
|
||||
const double _kFrameRadius = 22;
|
||||
const double _kCorner = 36;
|
||||
const double _kCornerStroke = 4;
|
||||
const double _kCloseSize = 38;
|
||||
const double _kCheckSize = 70;
|
||||
|
||||
/// 스캔 줄이 위아래로 한 번 오가는 시간 (시안 2.3s)
|
||||
const Duration _kSweep = Duration(milliseconds: 1150);
|
||||
|
||||
/// 명함을 찾은 뒤 체크 표시를 보여 주는 최소 시간 (시안 820ms)
|
||||
const Duration _kFoundHold = Duration(milliseconds: 820);
|
||||
|
||||
/// 같은 QR 을 실패한 뒤 다시 읽기까지 기다리는 시간 — 같은 알림이 계속 뜨지 않게
|
||||
const Duration _kRetryGap = Duration(seconds: 3);
|
||||
|
||||
/// 스캐너를 닫은 결과
|
||||
sealed class ScanOutcome {
|
||||
const ScanOutcome();
|
||||
}
|
||||
|
||||
/// QR 로 명함을 찾았다
|
||||
final class ScanFound extends ScanOutcome {
|
||||
const ScanFound(this.card);
|
||||
|
||||
final SharedCard card;
|
||||
}
|
||||
|
||||
/// "명함 코드 입력하기"를 눌렀다
|
||||
final class ScanTypeCode extends ScanOutcome {
|
||||
const ScanTypeCode();
|
||||
}
|
||||
|
||||
/// QR 을 읽어 알려 주는 카메라 화면
|
||||
typedef ScannerCamera = Widget Function(ValueChanged<String> onCode);
|
||||
|
||||
/// 스캐너 카메라 — 테스트에서 실제 카메라 대신 끼운다 (null 이면 기기 카메라)
|
||||
final scannerCameraProvider = Provider<ScannerCamera?>((ref) => null);
|
||||
/// 명함 받기 스캐너 문구
|
||||
const QrScanCopy _kCopy = QrScanCopy(
|
||||
title: '명함 받기',
|
||||
hint: '상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.',
|
||||
foundHint: '명함을 찾았어요! 정보 불러오는 중…',
|
||||
manualLabel: '명함 코드 입력하기',
|
||||
unavailableHint: '카메라를 쓸 수 없어요.\n설정에서 카메라 권한을 허용하거나 명함 코드를 입력해 주세요.',
|
||||
);
|
||||
|
||||
/// 명함 받기 스캐너 — 상대 명함의 QR 을 비추면 명함을 불러온다
|
||||
class CardScannerPage extends ConsumerStatefulWidget {
|
||||
class CardScannerPage extends StatelessWidget {
|
||||
const CardScannerPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CardScannerPage> createState() => _CardScannerPageState();
|
||||
}
|
||||
|
||||
class _CardScannerPageState extends ConsumerState<CardScannerPage> {
|
||||
late final ScannerCamera? _fakeCamera = ref.read(scannerCameraProvider);
|
||||
MobileScannerController? _controller;
|
||||
bool _found = false;
|
||||
bool _busy = false;
|
||||
String? _lastRaw;
|
||||
DateTime _lastAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_fakeCamera == null) _controller = MobileScannerController(formats: const [BarcodeFormat.qrCode]);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_controller?.dispose());
|
||||
// 다른 화면은 상태 표시줄 모양을 따로 정하지 않는다 — 어두운 글자(앱 기본)로 되돌린다
|
||||
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
final raw = capture.barcodes.map((b) => b.rawValue).nonNulls.firstOrNull;
|
||||
if (raw != null) unawaited(_onCode(raw));
|
||||
}
|
||||
|
||||
Future<void> _onCode(String raw) async {
|
||||
if (_busy) return;
|
||||
final now = DateTime.now();
|
||||
if (raw == _lastRaw && now.difference(_lastAt) < _kRetryGap) return;
|
||||
_lastRaw = raw;
|
||||
_lastAt = now;
|
||||
|
||||
final code = parseCardCode(raw);
|
||||
if (code == null) {
|
||||
// 펫 명함 QR 이 아니다 — 서버가 못 찾았을 때와 같은 안내
|
||||
ref.read(toastProvider.notifier).show(ErrorLexicon.getMessage('CARD_002'), icon: RupIconName.alert);
|
||||
return;
|
||||
}
|
||||
_busy = true;
|
||||
setState(() => _found = true);
|
||||
final shown = Stopwatch()..start();
|
||||
try {
|
||||
final card = await ref.read(receivedCardsProvider.notifier).preview(code);
|
||||
final rest = _kFoundHold - shown.elapsed;
|
||||
if (rest > Duration.zero) await Future<void>.delayed(rest);
|
||||
if (mounted) context.pop(ScanFound(card));
|
||||
} catch (e) {
|
||||
if (!isReportedApiFailure(e)) rethrow;
|
||||
// 실패 알림은 인터셉터가 띄웠다 — 다시 비출 수 있게 되돌린다
|
||||
_lastAt = DateTime.now();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_found = false;
|
||||
_busy = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _camera() {
|
||||
final camera = _fakeCamera;
|
||||
if (camera != null) return camera(_onCode);
|
||||
return MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
// 카메라를 켜는 동안·쓸 수 없을 때는 시안의 어두운 바탕 (안내는 아래 문구가 맡는다)
|
||||
placeholderBuilder: (context) => const _Feed(),
|
||||
errorBuilder: (context, error) => const _Feed(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final top = MediaQuery.paddingOf(context).top;
|
||||
final topBarHeight = top + 8 + _kCloseSize;
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: Scaffold(
|
||||
backgroundColor: _kScannerBg,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _camera()),
|
||||
Positioned.fill(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: topBarHeight),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: _Frame(found: _found),
|
||||
),
|
||||
),
|
||||
_Bottom(
|
||||
found: _found,
|
||||
camera: _controller,
|
||||
onManual: () => context.pop(const ScanTypeCode()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 조준 프레임 바깥을 어둡게 덮는 막이 위 막대를 가리지 않게 맨 위에 둔다
|
||||
Positioned(
|
||||
top: top + 8,
|
||||
left: 18,
|
||||
right: 18,
|
||||
child: _TopBar(onClose: () => context.pop()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 카메라 화면이 아직 없을 때의 바탕 (시안 .rup-scanner__feed)
|
||||
class _Feed extends StatelessWidget {
|
||||
const _Feed();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
center: Alignment(0, -.2),
|
||||
radius: 1.1,
|
||||
colors: [Color(0xFF3A332B), Color(0xFF221D17), _kScannerBg],
|
||||
stops: [0, .56, 1],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({required this.onClose});
|
||||
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
label: '닫기',
|
||||
child: Pressable(
|
||||
pressedScale: .9,
|
||||
onTap: onClose,
|
||||
child: Container(
|
||||
width: _kCloseSize,
|
||||
height: _kCloseSize,
|
||||
decoration: const BoxDecoration(color: Color(0x24FFFFFF), shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.x, size: 20, stroke: 2.4, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'명함 받기',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _kCloseSize),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 조준 프레임 — 바깥은 어둡게, 모서리 표시 + 스캔 줄, 찾으면 초록 체크
|
||||
class _Frame extends StatefulWidget {
|
||||
const _Frame({required this.found});
|
||||
|
||||
final bool found;
|
||||
|
||||
@override
|
||||
State<_Frame> createState() => _FrameState();
|
||||
}
|
||||
|
||||
class _FrameState extends State<_Frame> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _sweep = AnimationController(vsync: this, duration: _kSweep)
|
||||
..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sweep.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final found = widget.found;
|
||||
return SizedBox.square(
|
||||
dimension: _kFrame,
|
||||
child: CustomPaint(
|
||||
painter: const _DimOutside(),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: TweenAnimationBuilder<Color?>(
|
||||
tween: ColorTween(end: found ? _kFoundGreen : AppColors.primary),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
builder: (context, color, _) => CustomPaint(painter: _Corners(color ?? AppColors.primary)),
|
||||
),
|
||||
),
|
||||
if (!found)
|
||||
AnimatedBuilder(
|
||||
animation: _sweep,
|
||||
builder: (context, child) {
|
||||
final t = Curves.easeInOut.transform(_sweep.value);
|
||||
return Positioned(left: 10, right: 10, top: 12 + (_kFrame - 28) * t, child: child!);
|
||||
},
|
||||
child: Container(
|
||||
height: 2,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(colors: [Color(0x00FF7500), AppColors.primary, Color(0x00FF7500)]),
|
||||
boxShadow: [BoxShadow(color: Color(0xB3FF7500), blurRadius: 14, spreadRadius: 2)],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
opacity: found ? 1 : 0,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: const Cubic(.34, 1.5, .5, 1),
|
||||
scale: found ? 1 : .5,
|
||||
child: Container(
|
||||
width: _kCheckSize,
|
||||
height: _kCheckSize,
|
||||
decoration: const BoxDecoration(
|
||||
color: _kFoundGreen,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Color(0x804ED08A), offset: Offset(0, 10), blurRadius: 30)],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.check, size: 36, stroke: 3, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 프레임 바깥 화면 전체를 어둡게 (시안 box-shadow 0 0 0 2000px)
|
||||
class _DimOutside extends CustomPainter {
|
||||
const _DimOutside();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const reach = 4000.0;
|
||||
final outside = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Rect.fromLTRB(-reach, -reach, size.width + reach, size.height + reach))
|
||||
..addRRect(RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(_kFrameRadius)));
|
||||
canvas.drawPath(outside, Paint()..color = _kDim);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DimOutside oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// 네 모서리 표시 — 프레임 테두리를 따라 굵기 4, 길이 36
|
||||
class _Corners extends CustomPainter {
|
||||
const _Corners(this.color);
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = _kCornerStroke
|
||||
..strokeCap = StrokeCap.butt;
|
||||
const r = 16.0; // 시안 바깥 반경 18 − 굵기 절반
|
||||
const reach = _kCorner - _kCornerStroke / 2;
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
final rect = Rect.fromCircle(center: Offset.zero, radius: r);
|
||||
final corners = <(Offset, double)>[
|
||||
(Offset.zero, math.pi), // 왼쪽 위
|
||||
(Offset(w, 0), -math.pi / 2), // 오른쪽 위
|
||||
(Offset(w, h), 0), // 오른쪽 아래
|
||||
(Offset(0, h), math.pi / 2), // 왼쪽 아래
|
||||
];
|
||||
for (final (corner, start) in corners) {
|
||||
// 모서리를 원점으로 옮겨 같은 모양(가로·세로 직선 + 사분원)을 돌려 그린다
|
||||
canvas
|
||||
..save()
|
||||
..translate(corner.dx, corner.dy)
|
||||
..rotate(start - math.pi);
|
||||
final path = Path()
|
||||
..moveTo(0, reach)
|
||||
..lineTo(0, r)
|
||||
..arcTo(rect.shift(const Offset(r, r)), math.pi, math.pi / 2, false)
|
||||
..lineTo(reach, 0);
|
||||
canvas
|
||||
..drawPath(path, paint)
|
||||
..restore();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_Corners oldDelegate) => oldDelegate.color != color;
|
||||
}
|
||||
|
||||
/// 안내 문구 + "명함 코드 입력하기"
|
||||
class _Bottom extends StatelessWidget {
|
||||
const _Bottom({required this.found, required this.camera, required this.onManual});
|
||||
|
||||
final bool found;
|
||||
|
||||
/// 카메라 상태 — 쓸 수 없으면 안내 문구를 바꾼다 (테스트 카메라면 null)
|
||||
final MobileScannerController? camera;
|
||||
final VoidCallback onManual;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final camera = this.camera;
|
||||
final hint = camera == null
|
||||
? _hint(found: found, unavailable: false)
|
||||
: ValueListenableBuilder<MobileScannerState>(
|
||||
valueListenable: camera,
|
||||
builder: (context, state, _) => _hint(found: found, unavailable: state.error != null),
|
||||
);
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, 0, 24, math.max(MediaQuery.paddingOf(context).bottom + 96, 150)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
hint,
|
||||
// 찾은 뒤에는 버튼을 숨기되 자리는 지켜 프레임이 움직이지 않게 한다
|
||||
Visibility.maintain(
|
||||
visible: !found,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 22),
|
||||
child: Pressable(
|
||||
pressedScale: .96,
|
||||
onTap: onManual,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x33FF7500),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: const Color(0x9EFF963C), width: 1.5),
|
||||
boxShadow: const [BoxShadow(color: Color(0x38FF7500), offset: Offset(0, 4), blurRadius: 18)],
|
||||
),
|
||||
child: const Text(
|
||||
'명함 코드 입력하기',
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 15.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _hint({required bool found, required bool unavailable}) {
|
||||
final text = found
|
||||
? '명함을 찾았어요! 정보 불러오는 중…'
|
||||
: unavailable
|
||||
? '카메라를 쓸 수 없어요.\n설정에서 카메라 권한을 허용하거나 명함 코드를 입력해 주세요.'
|
||||
: '상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.';
|
||||
return Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13.5, height: 1.55, color: Color(0xD1FFFFFF)),
|
||||
return QrScannerPage<SharedCard>(
|
||||
copy: _kCopy,
|
||||
parse: parseCardCode,
|
||||
// 펫 명함 QR 이 아니면 서버가 못 찾았을 때와 같은 안내
|
||||
invalidMessage: ErrorLexicon.getMessage('CARD_002'),
|
||||
load: (ref, code) => ref.read(receivedCardsProvider.notifier).preview(code),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,26 +10,29 @@ import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/router/app_routes.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/received_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/card_scanner_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/card_code_sheet.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/received_card_info.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/qr_scanner_page.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_button.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
import 'package:tma_app/shared/widgets/section_title.dart';
|
||||
|
||||
/// 기기 저장 모드 안내 — 받을 명함은 다른 회원의 것이라 서버가 있어야 한다
|
||||
const String kReceiveCardLocalOnly = '기기 저장 모드에서는 명함을 받을 수 없어요';
|
||||
|
||||
/// 명함 받기 — 스캐너(또는 코드 입력)로 명함을 찾아 저장 확인 화면을 연다
|
||||
Future<void> startReceivingCard(BuildContext context, WidgetRef ref) async {
|
||||
if (!ref.read(receivedCardsProvider.notifier).available) {
|
||||
ref.read(toastProvider.notifier).show('기기 저장 모드에서는 명함을 받을 수 없어요', icon: RupIconName.qrcode);
|
||||
ref.read(toastProvider.notifier).show(kReceiveCardLocalOnly, icon: RupIconName.qrcode);
|
||||
return;
|
||||
}
|
||||
final outcome = await context.push<ScanOutcome>(AppRoutes.petCardScan);
|
||||
final outcome = await context.push<ScanOutcome<SharedCard>>(AppRoutes.petCardScan);
|
||||
if (!context.mounted) return;
|
||||
final card = switch (outcome) {
|
||||
ScanFound(:final card) => card,
|
||||
ScanFound(:final value) => value,
|
||||
ScanTypeCode() => await showCardCodeSheet(context),
|
||||
null => null,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:tma_app/core/api/api_paging.dart';
|
||||
import 'package:tma_app/core/api/server_file.dart';
|
||||
import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/core/utils/color_utils.dart';
|
||||
import 'package:tma_app/core/utils/date_format.dart';
|
||||
import 'package:tma_app/core/utils/hour_minute.dart';
|
||||
import 'package:tma_app/features/schedule/data/schedule_repository.dart';
|
||||
import 'package:tma_app/features/schedule/domain/calendar_sharing.dart';
|
||||
import 'package:tma_app/features/schedule/domain/event_category.dart';
|
||||
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
|
||||
import 'package:tma_app/features/schedule/domain/schedule_book.dart';
|
||||
@@ -42,10 +44,33 @@ EventDetails eventDetailsFromApi(Map<String, Object?> json) => EventDetails(
|
||||
labelColor: colorFromHex(json['labelColor'] as String?),
|
||||
);
|
||||
|
||||
ScheduleEvent scheduleEventFromApi(Map<String, Object?> json) => ScheduleEvent(
|
||||
/// 함께 관리 — 남이 만든 일정이면 만든 사람과 대상 요약을 붙인다 (내 일정·내 id 를 모르면 그대로)
|
||||
EventDetails withOwnerFromApi(EventDetails details, Map<String, Object?> json, String? myUserId) {
|
||||
final ownerId = json['ownerId'] as String?;
|
||||
if (ownerId == null || myUserId == null || ownerId == myUserId) return details;
|
||||
final owner = json['owner'] as Map<String, Object?>?;
|
||||
return details.copyWith(
|
||||
owner: CalendarPerson(
|
||||
id: ownerId,
|
||||
nickname: owner?['nickname'] as String?,
|
||||
photoPath: serverFileRefOrNull(owner?['photoFileId'] as String?),
|
||||
),
|
||||
sharedTargets: [
|
||||
for (final t in (json['targets'] as List?)?.whereType<Map<String, Object?>>() ?? const <Map<String, Object?>>[])
|
||||
EventTarget(
|
||||
id: t['id']! as String,
|
||||
name: t['name'] as String?,
|
||||
photoPath: serverFileRefOrNull(t['photoFileId'] as String?),
|
||||
isSelf: t['id'] == kSelfTargetId,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
ScheduleEvent scheduleEventFromApi(Map<String, Object?> json, {String? myUserId}) => ScheduleEvent(
|
||||
id: json['id']! as String,
|
||||
date: parseIsoDate(json['date']! as String),
|
||||
details: eventDetailsFromApi(json),
|
||||
details: withOwnerFromApi(eventDetailsFromApi(json), json, myUserId),
|
||||
);
|
||||
|
||||
RepeatSpec repeatSpecFromApi(Map<String, Object?> json) => RepeatSpec(
|
||||
@@ -54,39 +79,47 @@ RepeatSpec repeatSpecFromApi(Map<String, Object?> json) => RepeatSpec(
|
||||
n: (json['n'] as num?)?.toInt() ?? 1,
|
||||
);
|
||||
|
||||
RepeatRule repeatRuleFromApi(Map<String, Object?> json) => RepeatRule(
|
||||
RepeatRule repeatRuleFromApi(Map<String, Object?> json, {String? myUserId}) => RepeatRule(
|
||||
id: json['id']! as String,
|
||||
from: parseIsoDate(json['from']! as String),
|
||||
spec: repeatSpecFromApi(json['spec']! as Map<String, Object?>),
|
||||
details: eventDetailsFromApi(json['details']! as Map<String, Object?>),
|
||||
details: withOwnerFromApi(eventDetailsFromApi(json['details']! as Map<String, Object?>), json, myUserId),
|
||||
);
|
||||
|
||||
Map<String, Object?> _specToApi(RepeatSpec spec) => {'mode': spec.mode.name, 'unit': spec.unit.name, 'n': spec.n};
|
||||
|
||||
/// 서버 저장소 — 어느 날에 무엇이 놓이는지는 지금처럼 앱이 계산하고, 서버는 원본(일정·규칙·완료 표시)을 둔다
|
||||
/// 서버 저장소 — 어느 날에 무엇이 놓이는지는 지금처럼 앱이 계산하고, 서버는 원본(일정·규칙·완료 표시)을 둔다.
|
||||
/// 함께 관리 중이면 연동된 사람들의 일정도 함께 오고, 남이 만든 일정에는 만든 사람이 붙는다.
|
||||
class ApiScheduleRepository implements ScheduleRepository {
|
||||
ApiScheduleRepository({required Dio dio, required SessionStore session})
|
||||
ApiScheduleRepository({required Dio dio, required SessionStore session, String? Function()? myUserId})
|
||||
: _dio = dio,
|
||||
_session = session;
|
||||
_session = session,
|
||||
_myUserId = myUserId ?? (() => null);
|
||||
|
||||
final Dio _dio;
|
||||
final SessionStore _session;
|
||||
final String? Function() _myUserId;
|
||||
|
||||
ScheduleEvent _event(Map<String, Object?> json) => scheduleEventFromApi(json, myUserId: _myUserId());
|
||||
|
||||
RepeatRule _rule(Map<String, Object?> json) => repeatRuleFromApi(json, myUserId: _myUserId());
|
||||
|
||||
@override
|
||||
Future<ScheduleBook> fetch() async {
|
||||
Future<ScheduleBook> fetch({bool quiet = false}) async {
|
||||
if (!_session.hasSession) return ScheduleBook.empty;
|
||||
|
||||
final done = <String>{};
|
||||
final events = await fetchAllPages(_dio, _kEventsPath, (json) {
|
||||
final event = scheduleEventFromApi(json);
|
||||
final events = await fetchAllPages(_dio, _kEventsPath, quiet: quiet, (json) {
|
||||
final event = _event(json);
|
||||
if (json['isDone'] == true) done.add(event.id);
|
||||
return event;
|
||||
});
|
||||
final rules = await fetchAllPages(_dio, _kRulesPath, repeatRuleFromApi);
|
||||
final rules = await fetchAllPages(_dio, _kRulesPath, _rule, quiet: quiet);
|
||||
final completions = await fetchAllPages(
|
||||
_dio,
|
||||
_kCompletionsPath,
|
||||
(json) => '${json['ruleId']! as String}@${json['date']! as String}',
|
||||
quiet: quiet,
|
||||
);
|
||||
return ScheduleBook(events: events, rules: rules, doneIds: {...done, ...completions});
|
||||
}
|
||||
@@ -104,7 +137,7 @@ class ApiScheduleRepository implements ScheduleRepository {
|
||||
],
|
||||
},
|
||||
);
|
||||
created.addAll(response.data!.map((json) => scheduleEventFromApi(json as Map<String, Object?>)));
|
||||
created.addAll(response.data!.map((json) => _event(json as Map<String, Object?>)));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -115,7 +148,7 @@ class ApiScheduleRepository implements ScheduleRepository {
|
||||
'$_kEventsPath/${event.id}',
|
||||
data: eventDetailsToApi(details),
|
||||
);
|
||||
return scheduleEventFromApi(response.data!);
|
||||
return _event(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -127,7 +160,7 @@ class ApiScheduleRepository implements ScheduleRepository {
|
||||
_kRulesPath,
|
||||
data: {'from': toIsoDate(from), 'spec': _specToApi(spec), 'details': eventDetailsToApi(details)},
|
||||
);
|
||||
return repeatRuleFromApi(response.data!);
|
||||
return _rule(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -136,7 +169,7 @@ class ApiScheduleRepository implements ScheduleRepository {
|
||||
'$_kRulesPath/${rule.id}',
|
||||
data: {'spec': _specToApi(spec), 'details': eventDetailsToApi(details)},
|
||||
);
|
||||
return repeatRuleFromApi(response.data!);
|
||||
return _rule(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/api/dio_client.dart';
|
||||
import 'package:tma_app/core/api/dio_provider.dart';
|
||||
import 'package:tma_app/core/api/server_file.dart';
|
||||
import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/core/config/app_config.dart';
|
||||
import 'package:tma_app/features/schedule/domain/calendar_sharing.dart';
|
||||
|
||||
/// 함께 관리 경로 (backend CalendarSharingController)
|
||||
const String _kSharingPath = '/calendar-sharing';
|
||||
const String _kInvitationsPath = '/calendar-invitations';
|
||||
const String _kMembershipsPath = '/calendar-memberships';
|
||||
const String _kMembershipPath = '/calendar-membership';
|
||||
|
||||
/// 함께 관리 저장소 계약 — 다른 회원과 달력을 함께 쓰므로 서버가 있어야 한다
|
||||
abstract interface class CalendarSharingRepository {
|
||||
/// 함께 관리를 쓸 수 있는지 — 기기 저장 모드에서는 쓸 수 없다
|
||||
bool get available;
|
||||
|
||||
/// 내 초대 코드와 연동된 사람들 (기기 저장 모드·로그인 전에는 null). [quiet] 면 실패해도 알림을 띄우지 않는다
|
||||
Future<CalendarSharing?> fetch({bool quiet = false});
|
||||
|
||||
/// 받은 초대 코드를 미리 본다
|
||||
Future<CalendarInvitation> previewInvitation(String code);
|
||||
|
||||
/// 받은 초대 코드로 연동한다
|
||||
Future<CalendarSharing> join(String code);
|
||||
|
||||
/// 연동을 해제한다 — 내 일정은 남고 다른 사람의 일정은 더 이상 보이지 않는다
|
||||
Future<void> leave();
|
||||
}
|
||||
|
||||
/// 기기 저장 모드 — 함께 관리를 쓸 수 없다 (화면은 안내만 띄운다)
|
||||
class UnavailableCalendarSharingRepository implements CalendarSharingRepository {
|
||||
const UnavailableCalendarSharingRepository();
|
||||
|
||||
@override
|
||||
bool get available => false;
|
||||
|
||||
@override
|
||||
Future<CalendarSharing?> fetch({bool quiet = false}) async => null;
|
||||
|
||||
@override
|
||||
Future<CalendarInvitation> previewInvitation(String code) => throw StateError(_kLocalOnly);
|
||||
|
||||
@override
|
||||
Future<CalendarSharing> join(String code) => throw StateError(_kLocalOnly);
|
||||
|
||||
@override
|
||||
Future<void> leave() async {}
|
||||
}
|
||||
|
||||
const String _kLocalOnly = '함께 관리는 서버 모드에서만 쓸 수 있다';
|
||||
|
||||
CalendarSharing calendarSharingFromApi(Map<String, Object?> json) => CalendarSharing(
|
||||
inviteCode: json['inviteCode']! as String,
|
||||
linked: json['linked'] as bool? ?? false,
|
||||
maxMembers: (json['maxMembers'] as num?)?.toInt() ?? kMaxCalendarMembers,
|
||||
members: [
|
||||
for (final m in (json['members'] as List?)?.whereType<Map<String, Object?>>() ?? const <Map<String, Object?>>[])
|
||||
CalendarPerson(
|
||||
id: m['userId']! as String,
|
||||
nickname: m['nickname'] as String?,
|
||||
photoPath: serverFileRefOrNull(m['photoFileId'] as String?),
|
||||
isMe: m['isMe'] as bool? ?? false,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
CalendarInvitation calendarInvitationFromApi(String code, Map<String, Object?> json) => CalendarInvitation(
|
||||
code: code,
|
||||
ownerNickname: json['ownerNickname'] as String?,
|
||||
memberCount: (json['memberCount'] as num?)?.toInt() ?? 1,
|
||||
maxMembers: (json['maxMembers'] as num?)?.toInt() ?? kMaxCalendarMembers,
|
||||
isMine: json['isMine'] as bool? ?? false,
|
||||
isMember: json['isMember'] as bool? ?? false,
|
||||
);
|
||||
|
||||
/// 서버 저장소
|
||||
class ApiCalendarSharingRepository implements CalendarSharingRepository {
|
||||
ApiCalendarSharingRepository({required Dio dio, required SessionStore session})
|
||||
: _dio = dio,
|
||||
_session = session;
|
||||
|
||||
final Dio _dio;
|
||||
final SessionStore _session;
|
||||
|
||||
@override
|
||||
bool get available => true;
|
||||
|
||||
@override
|
||||
Future<CalendarSharing?> fetch({bool quiet = false}) async {
|
||||
if (!_session.hasSession) return null;
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
_kSharingPath,
|
||||
options: quiet ? Options(extra: {kSilentExtra: true}) : null,
|
||||
);
|
||||
return calendarSharingFromApi(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CalendarInvitation> previewInvitation(String code) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('$_kInvitationsPath/${Uri.encodeComponent(code)}');
|
||||
return calendarInvitationFromApi(code, response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CalendarSharing> join(String code) async {
|
||||
final response = await _dio.post<Map<String, dynamic>>(_kMembershipsPath, data: {'code': code});
|
||||
return calendarSharingFromApi(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> leave() => _dio.delete<void>(_kMembershipPath);
|
||||
}
|
||||
|
||||
final calendarSharingRepositoryProvider = Provider<CalendarSharingRepository>((ref) {
|
||||
return switch (ref.watch(dataModeProvider)) {
|
||||
DataMode.local => const UnavailableCalendarSharingRepository(),
|
||||
DataMode.api => ApiCalendarSharingRepository(dio: ref.watch(dioProvider), session: ref.watch(sessionStoreProvider)),
|
||||
};
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import 'package:tma_app/core/api/session_store.dart';
|
||||
import 'package:tma_app/core/config/app_config.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/core/utils/date_format.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart';
|
||||
import 'package:tma_app/features/schedule/data/api_schedule_repository.dart';
|
||||
import 'package:tma_app/features/schedule/domain/care_preset.dart';
|
||||
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
|
||||
@@ -16,7 +17,8 @@ import 'package:tma_app/features/schedule/domain/schedule_event.dart';
|
||||
/// 일정 저장소 계약 — 서버(ApiScheduleRepository) / 기기(LocalScheduleRepository).
|
||||
/// 바꾼 결과(id 가 붙은 일정·규칙)를 돌려주면 알림기가 [ScheduleBook] 에 반영한다.
|
||||
abstract interface class ScheduleRepository {
|
||||
Future<ScheduleBook> fetch();
|
||||
/// [quiet] 면 실패해도 알림을 띄우지 않는다 (뒤에서 다시 읽을 때)
|
||||
Future<ScheduleBook> fetch({bool quiet = false});
|
||||
|
||||
/// 날짜 일정 여러 건 — id 를 붙여 보낸 순서대로 돌려준다
|
||||
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts);
|
||||
@@ -75,7 +77,7 @@ class LocalScheduleRepository implements ScheduleRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ScheduleBook> fetch() async => _read();
|
||||
Future<ScheduleBook> fetch({bool quiet = false}) async => _read();
|
||||
|
||||
@override
|
||||
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) async {
|
||||
@@ -134,7 +136,11 @@ class LocalScheduleRepository implements ScheduleRepository {
|
||||
final scheduleRepositoryProvider = Provider<ScheduleRepository>((ref) {
|
||||
return switch (ref.watch(dataModeProvider)) {
|
||||
DataMode.local => LocalScheduleRepository(ref.watch(sharedPreferencesProvider)),
|
||||
DataMode.api => ApiScheduleRepository(dio: ref.watch(dioProvider), session: ref.watch(sessionStoreProvider)),
|
||||
DataMode.api => ApiScheduleRepository(
|
||||
dio: ref.watch(dioProvider),
|
||||
session: ref.watch(sessionStoreProvider),
|
||||
myUserId: () => ref.read(sharedPreferencesProvider).getString(kAuthUserIdKey),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user