From b6c5496a05a53fc7a177106e79fc7eabc428c54b Mon Sep 17 00:00:00 2001 From: TtiPo Date: Sun, 11 Oct 2026 21:31:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=9D=BC=EC=A0=95=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EA=B4=80=EB=A6=AC=20API=20=E2=80=94=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=20=EC=BD=94=EB=93=9C=EB=A1=9C=20=EB=8B=AC=EB=A0=A5=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가족·친구가 한 달력을 함께 쓸 수 있게 한다. - calendar_groups · calendar_group_members (한 사람 한 그룹, 최대 4명), 회원마다 개인 초대 코드(RUP-6자) - 연동 상태·초대 미리보기·코드로 연동·연동 해제 — 해제하면 내 일정만 남고, 1명 남은 그룹은 지운다 - 일정·반복·완료는 그룹 전체가 보고 고칠 수 있다. 응답에 만든 사람·대상 요약을 담고, 남의 일정을 고칠 때 대상은 그 사람의 아이로 확인한다 - 연동·해제는 advisory lock 으로 줄 세워 인원 초과·중복 그룹을 막는다 - 파일 열람 정책 — 연동된 사람의 프로필·아이 사진 - 초대 코드 생성기·고유 충돌 재시도·대상 요약을 공통으로 옮겨 다이어리와 함께 쓴다 - 에러 코드 SCHED_001~005 Co-Authored-By: Claude Opus 5.5 --- backend/src/common/errors/error-codes.ts | 26 ++ .../1791719955147-AddCalendarGroups.ts | 48 ++ backend/src/modules/diary/diary-view.spec.ts | 62 +-- backend/src/modules/diary/diary-view.ts | 59 +-- backend/src/modules/diary/diary.service.ts | 48 +- backend/src/modules/diary/dto/diary.dto.ts | 7 +- .../modules/diary/entities/diary.entity.ts | 4 +- backend/src/modules/diary/invite-code.spec.ts | 38 -- backend/src/modules/diary/invite-code.ts | 35 +- backend/src/modules/pet/pet-targets.spec.ts | 63 +++ backend/src/modules/pet/pet-targets.ts | 69 +++ backend/src/modules/pet/pet.service.ts | 27 ++ .../modules/schedule/calendar-file-access.ts | 51 +++ .../modules/schedule/calendar-invite-code.ts | 7 + .../schedule/calendar-sharing.controller.ts | 128 ++++++ .../schedule/calendar-sharing.service.ts | 235 ++++++++++ .../schedule/dto/calendar-sharing.dto.ts | 141 ++++++ .../src/modules/schedule/dto/schedule.dto.ts | 83 +++- .../entities/calendar-group.entity.ts | 55 +++ .../schedule/repeat-rules.controller.ts | 23 +- .../schedule/schedule-events.controller.ts | 21 +- .../modules/schedule/schedule-view.spec.ts | 96 ++++ backend/src/modules/schedule/schedule-view.ts | 55 +++ .../src/modules/schedule/schedule.module.ts | 28 +- .../modules/schedule/schedule.service.spec.ts | 80 +++- .../src/modules/schedule/schedule.service.ts | 156 +++++-- .../src/modules/user/entities/user.entity.ts | 7 + backend/src/shared/utils/db-error.ts | 17 + backend/src/shared/utils/invite-code.spec.ts | 40 ++ backend/src/shared/utils/invite-code.ts | 41 ++ backend/test/calendar-sharing.e2e-spec.ts | 418 ++++++++++++++++++ flutter/lib/core/api/error_lexicon.g.dart | 5 + .../shared/constants/error-codes.generated.ts | 5 + 33 files changed, 1893 insertions(+), 285 deletions(-) create mode 100644 backend/src/migrations/1791719955147-AddCalendarGroups.ts delete mode 100644 backend/src/modules/diary/invite-code.spec.ts create mode 100644 backend/src/modules/pet/pet-targets.spec.ts create mode 100644 backend/src/modules/pet/pet-targets.ts create mode 100644 backend/src/modules/schedule/calendar-file-access.ts create mode 100644 backend/src/modules/schedule/calendar-invite-code.ts create mode 100644 backend/src/modules/schedule/calendar-sharing.controller.ts create mode 100644 backend/src/modules/schedule/calendar-sharing.service.ts create mode 100644 backend/src/modules/schedule/dto/calendar-sharing.dto.ts create mode 100644 backend/src/modules/schedule/entities/calendar-group.entity.ts create mode 100644 backend/src/modules/schedule/schedule-view.spec.ts create mode 100644 backend/src/modules/schedule/schedule-view.ts create mode 100644 backend/src/shared/utils/invite-code.spec.ts create mode 100644 backend/src/shared/utils/invite-code.ts create mode 100644 backend/test/calendar-sharing.e2e-spec.ts diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index b9a45d5..d9398ad 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -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, diff --git a/backend/src/migrations/1791719955147-AddCalendarGroups.ts b/backend/src/migrations/1791719955147-AddCalendarGroups.ts new file mode 100644 index 0000000..3678346 --- /dev/null +++ b/backend/src/migrations/1791719955147-AddCalendarGroups.ts @@ -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 { + 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 { + 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"`); + } +} diff --git a/backend/src/modules/diary/diary-view.spec.ts b/backend/src/modules/diary/diary-view.spec.ts index 4a42491..63dfb66 100644 --- a/backend/src/modules/diary/diary-view.spec.ts +++ b/backend/src/modules/diary/diary-view.spec.ts @@ -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 { - 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([]); - }); -}); diff --git a/backend/src/modules/diary/diary-view.ts b/backend/src/modules/diary/diary-view.ts index 852e4f9..6bfcb7c 100644 --- a/backend/src/modules/diary/diary-view.ts +++ b/backend/src/modules/diary/diary-view.ts @@ -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, - 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; -} diff --git a/backend/src/modules/diary/diary.service.ts b/backend/src/modules/diary/diary.service.ts index bcb115e..c7d3133 100644 --- a/backend/src/modules/diary/diary.service.ts +++ b/backend/src/modules/diary/diary.service.ts @@ -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,10 @@ 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 { + isUniqueViolation, + retryOnUniqueViolation, +} from '@/shared/utils/db-error'; import { CommentDto, CreateDiaryDto, @@ -49,7 +51,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,15 +82,8 @@ function onlyMemberOf( } /** 초대 코드가 겹치면(유니크 위반) 새 코드로 처음부터 다시 한다 */ -async function withFreshInviteCode(run: () => Promise): Promise { - for (let attempt = 1; ; attempt += 1) { - try { - return await run(); - } catch (error) { - if (!isUniqueViolation(error) || attempt >= CODE_RETRY) throw error; - } - } -} +const withFreshInviteCode = (run: () => Promise): Promise => + retryOnUniqueViolation(run, CODE_RETRY); /** * 다이어리 — 다이어리·기록·댓글, 그리고 함께 쓰기(초대 코드·참여·나가기). @@ -159,7 +154,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 +229,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 +314,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 +331,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { userId: string, rawCode: string, ): Promise { - const code = normalizeInviteCode(rawCode); + const code = DIARY_INVITE_CODE.normalize(rawCode); const diary = code === null ? null @@ -370,7 +365,7 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { userId: string, dto: JoinDiaryDto, ): Promise { - 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 } }); } @@ -601,24 +596,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, { diff --git a/backend/src/modules/diary/dto/diary.dto.ts b/backend/src/modules/diary/dto/diary.dto.ts index 934a707..1b45222 100644 --- a/backend/src/modules/diary/dto/diary.dto.ts +++ b/backend/src/modules/diary/dto/diary.dto.ts @@ -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, diff --git a/backend/src/modules/diary/entities/diary.entity.ts b/backend/src/modules/diary/entities/diary.entity.ts index d34306f..316524c 100644 --- a/backend/src/modules/diary/entities/diary.entity.ts +++ b/backend/src/modules/diary/entities/diary.entity.ts @@ -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 = { @@ -87,7 +87,7 @@ export class Diary { /** 초대 코드 `RUP-XXXXXX` — 공유 중일 때만 있다. 공유를 끄면 지워 예전 코드로는 들어올 수 없다 */ @Column({ type: 'varchar', - length: INVITE_CODE_LENGTH, + length: DIARY_INVITE_CODE.length, nullable: true, unique: true, }) diff --git a/backend/src/modules/diary/invite-code.spec.ts b/backend/src/modules/diary/invite-code.spec.ts deleted file mode 100644 index 33c183b..0000000 --- a/backend/src/modules/diary/invite-code.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/backend/src/modules/diary/invite-code.ts b/backend/src/modules/diary/invite-code.ts index 7616ebe..c9a4923 100644 --- a/backend/src/modules/diary/invite-code.ts +++ b/backend/src/modules/diary/invite-code.ts @@ -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); diff --git a/backend/src/modules/pet/pet-targets.spec.ts b/backend/src/modules/pet/pet-targets.spec.ts new file mode 100644 index 0000000..2eba718 --- /dev/null +++ b/backend/src/modules/pet/pet-targets.spec.ts @@ -0,0 +1,63 @@ +import { resolveTargets } from './pet-targets'; +import type { TargetPetSource } from './pet-targets'; + +const OWNER = 'owner-1'; + +function pet(overrides: Partial = {}): 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([]); + }); +}); diff --git a/backend/src/modules/pet/pet-targets.ts b/backend/src/modules/pet/pet-targets.ts new file mode 100644 index 0000000..3dbe79e --- /dev/null +++ b/backend/src/modules/pet/pet-targets.ts @@ -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, + 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; +} diff --git a/backend/src/modules/pet/pet.service.ts b/backend/src/modules/pet/pet.service.ts index 45f6468..946e26d 100644 --- a/backend/src/modules/pet/pet.service.ts +++ b/backend/src/modules/pet/pet.service.ts @@ -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> { + 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 { const pet = await this.pets.findOneBy({ id: petId, ownerId }); diff --git a/backend/src/modules/schedule/calendar-file-access.ts b/backend/src/modules/schedule/calendar-file-access.ts new file mode 100644 index 0000000..36dd972 --- /dev/null +++ b/backend/src/modules/schedule/calendar-file-access.ts @@ -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 { + const rows = await this.dataSource.query<{ allowed: boolean }[]>( + GROUP_MEMBER_CAN_READ_FILE, + [userId, fileId], + ); + return rows[0]?.allowed === true; + } +} diff --git a/backend/src/modules/schedule/calendar-invite-code.ts b/backend/src/modules/schedule/calendar-invite-code.ts new file mode 100644 index 0000000..5cc06e7 --- /dev/null +++ b/backend/src/modules/schedule/calendar-invite-code.ts @@ -0,0 +1,7 @@ +import { inviteCodeFormat } from '@/shared/utils/invite-code'; + +/** + * 일정 연동 초대 코드 `RUP-XXXXXX` (뒷부분 6자) — users.calendar_invite_code 의 길이(10)와 맞춘다. + * 코드만 알면 승인 없이 달력 전체를 함께 보고 고치게 되므로 다이어리와 같은 길이로 짐작하기 어렵게 둔다. + */ +export const CALENDAR_INVITE_CODE = inviteCodeFormat(6); diff --git a/backend/src/modules/schedule/calendar-sharing.controller.ts b/backend/src/modules/schedule/calendar-sharing.controller.ts new file mode 100644 index 0000000..38a25c9 --- /dev/null +++ b/backend/src/modules/schedule/calendar-sharing.controller.ts @@ -0,0 +1,128 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiNoContentResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; + +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'; + +/** + * 초대 코드로 찾는 엔드포인트는 코드 추측의 표적이 된다 — 전역보다 엄격하게 막는다. + * 분당 30회 — 코드를 몇 번 잘못 입력해도 충분하다. + */ +const INVITE_LOOKUP_THROTTLE = { default: { ttl: 60_000, limit: 30 } }; + +// 일정 연동 — 내 연동 상태와 연동 해제. 라우팅과 입출력만 담당한다 +@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 { + 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 { + await this.sharing.leave(user.userId); + } +} + +// 일정 연동 초대 — 코드를 받은 회원이 연동하기 전에 미리 본다 +@ApiTags('Schedule') +@ApiBearerAuth() +@Throttle(INVITE_LOOKUP_THROTTLE) +@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 { + 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() + @Throttle(INVITE_LOOKUP_THROTTLE) + @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 { + return this.sharing.join(user.userId, dto); + } +} diff --git a/backend/src/modules/schedule/calendar-sharing.service.ts b/backend/src/modules/schedule/calendar-sharing.service.ts new file mode 100644 index 0000000..fb880ef --- /dev/null +++ b/backend/src/modules/schedule/calendar-sharing.service.ts @@ -0,0 +1,235 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, EntityManager, IsNull, Repository } from 'typeorm'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { User } from '@/modules/user/entities/user.entity'; +import { retryOnUniqueViolation } from '@/shared/utils/db-error'; +import { CALENDAR_INVITE_CODE } from './calendar-invite-code'; +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 CALENDAR_SHARING_LOCK_KEY = 7_331_002; + +/** + * 일정 연동 — 최대 4명이 한 달력을 함께 본다. 사람마다 초대 코드가 하나 있고, + * 남의 코드를 입력하면 그 사람의 묶음에 들어간다(없으면 둘이 새 묶음). 해제하면 나만 빠진다. + * + * 회원 탈퇴는 FK(CASCADE)로 묶음에서 빠지므로 한 명만 남은 묶음이 생길 수 있다 — + * 그런 묶음은 연동되지 않은 것으로 보고, 손댈 때 지운다. + */ +@Injectable() +export class CalendarSharingService { + private readonly logger = new Logger(CalendarSharingService.name); + + constructor( + @InjectRepository(User) + private readonly users: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + ) {} + + /** 내 일정을 함께 보는 사람 (나 포함) — 연동되지 않았으면 나만. 일정을 읽고 고치는 범위다 */ + async memberIds(userId: string): Promise { + const group = await this.groupOf(this.dataSource.manager, userId); + return group.length >= 2 ? group.map((m) => m.userId) : [userId]; + } + + /** 내 연동 상태 — 초대 코드가 없으면 이때 만든다 */ + async getSharing(userId: string): Promise { + const me = await this.userWithInviteCode(userId); + let group = await this.groupOf(this.dataSource.manager, userId, true); + if (group.length === 1) { + // 혼자 남은 묶음 (함께하던 사람이 탈퇴) — 정리한다 + await this.dataSource.transaction(async (manager) => { + await this.lock(manager); + await this.linkedGroupOf(manager, userId); + }); + group = []; + } + return CalendarSharingDto.from(userId, me, me.calendarInviteCode, group); + } + + /** 초대 코드로 미리 보기 — 연동하기 전. 없는 코드는 SCHED_001 */ + async previewInvitation( + userId: string, + rawCode: string, + ): Promise { + const owner = await this.findCodeOwner(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 { + const owner = await this.findCodeOwner(dto.code); + if (owner.id === userId) { + throw new BusinessException('SCHED_003', { context: { userId } }); + } + + await this.dataSource.transaction(async (manager) => { + await this.lock(manager); + 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 }); + return; + } + 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()', + }); + }); + + this.logger.log(`일정 연동: 회원 ${userId} → 회원 ${owner.id} 의 묶음`); + return this.getSharing(userId); + } + + /** 연동 해제 — 나만 빠진다(내 일정은 그대로). 한 명만 남으면 묶음을 지운다. 연동 중이 아니면 아무 일도 없다 */ + async leave(userId: string): Promise { + const left = await this.dataSource.transaction(async (manager) => { + await this.lock(manager); + 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 }); + } + return true; + }); + if (left) this.logger.log(`일정 연동 해제: 회원 ${userId}`); + } + + // ── 도우미 ───────────────────────────────────────────── + private async lock(manager: EntityManager): Promise { + await manager.query('SELECT pg_advisory_xact_lock($1)', [ + CALENDAR_SHARING_LOCK_KEY, + ]); + } + + /** [userId] 가 든 묶음의 사람들 (연동한 순서) — 묶음이 없으면 [] */ + private async groupOf( + manager: EntityManager, + userId: string, + withProfiles = false, + ): Promise { + 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 { + const group = await this.groupOf(manager, userId); + if (group.length === 1) { + await manager.delete(CalendarGroup, { id: group[0].groupId }); + return []; + } + return group; + } + + /** 초대 코드의 주인 — 형식이 틀리거나 없는 코드는 SCHED_001 */ + private async findCodeOwner(rawCode: string): Promise { + const code = CALENDAR_INVITE_CODE.normalize(rawCode); + const owner = + code === null + ? null + : await this.users.findOneBy({ calendarInviteCode: code }); + if (!owner) { + throw new BusinessException('SCHED_001', { context: { code: rawCode } }); + } + return owner; + } + + /** 회원과 그 초대 코드 — 아직 없으면 만든다 (동시에 만들어도 먼저 들어간 값 하나만 남는다) */ + private async userWithInviteCode( + userId: string, + ): Promise { + const user = await this.users.findOneBy({ id: userId }); + if (!user) throw new BusinessException('RES_001', { context: { userId } }); + if (user.calendarInviteCode !== null) { + return { ...user, calendarInviteCode: user.calendarInviteCode }; + } + + await retryOnUniqueViolation( + () => + this.users.update( + { id: userId, calendarInviteCode: IsNull() }, + { calendarInviteCode: CALENDAR_INVITE_CODE.random() }, + ), + CODE_RETRY, + ); + const updated = await this.users.findOneBy({ id: userId }); + if (updated?.calendarInviteCode == null) { + throw new BusinessException('RES_001', { context: { userId } }); + } + return { ...updated, calendarInviteCode: updated.calendarInviteCode }; + } +} diff --git a/backend/src/modules/schedule/dto/calendar-sharing.dto.ts b/backend/src/modules/schedule/dto/calendar-sharing.dto.ts new file mode 100644 index 0000000..8a0cd1c --- /dev/null +++ b/backend/src/modules/schedule/dto/calendar-sharing.dto.ts @@ -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; +} diff --git a/backend/src/modules/schedule/dto/schedule.dto.ts b/backend/src/modules/schedule/dto/schedule.dto.ts index 325b4a3..0b12bde 100644 --- a/backend/src/modules/schedule/dto/schedule.dto.ts +++ b/backend/src/modules/schedule/dto/schedule.dto.ts @@ -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), }; } } diff --git a/backend/src/modules/schedule/entities/calendar-group.entity.ts b/backend/src/modules/schedule/entities/calendar-group.entity.ts new file mode 100644 index 0000000..b105cc0 --- /dev/null +++ b/backend/src/modules/schedule/entities/calendar-group.entity.ts @@ -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; +} diff --git a/backend/src/modules/schedule/repeat-rules.controller.ts b/backend/src/modules/schedule/repeat-rules.controller.ts index c713ace..b3006cc 100644 --- a/backend/src/modules/schedule/repeat-rules.controller.ts +++ b/backend/src/modules/schedule/repeat-rules.controller.ts @@ -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( diff --git a/backend/src/modules/schedule/schedule-events.controller.ts b/backend/src/modules/schedule/schedule-events.controller.ts index ee136e2..6e04a33 100644 --- a/backend/src/modules/schedule/schedule-events.controller.ts +++ b/backend/src/modules/schedule/schedule-events.controller.ts @@ -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( diff --git a/backend/src/modules/schedule/schedule-view.spec.ts b/backend/src/modules/schedule/schedule-view.spec.ts new file mode 100644 index 0000000..2135e85 --- /dev/null +++ b/backend/src/modules/schedule/schedule-view.spec.ts @@ -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 }, + ]); + }); +}); diff --git a/backend/src/modules/schedule/schedule-view.ts b/backend/src/modules/schedule/schedule-view.ts new file mode 100644 index 0000000..6d9545b --- /dev/null +++ b/backend/src/modules/schedule/schedule-view.ts @@ -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; + /** 대상 아이 (id → 아이) */ + readonly pets: ReadonlyMap; +} + +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 }), + ), + }; +} diff --git a/backend/src/modules/schedule/schedule.module.ts b/backend/src/modules/schedule/schedule.module.ts index 7a9daae..4fc92b8 100644 --- a/backend/src/modules/schedule/schedule.module.ts +++ b/backend/src/modules/schedule/schedule.module.ts @@ -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 {} diff --git a/backend/src/modules/schedule/schedule.service.spec.ts b/backend/src/modules/schedule/schedule.service.spec.ts index 25a6ab3..13238bf 100644 --- a/backend/src/modules/schedule/schedule.service.spec.ts +++ b/backend/src/modules/schedule/schedule.service.spec.ts @@ -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, rules as unknown as Repository, completions as unknown as Repository, + users as unknown as Repository, 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 }, + ]); + }); + }); }); diff --git a/backend/src/modules/schedule/schedule.service.ts b/backend/src/modules/schedule/schedule.service.ts index fcf44a8..1cccb56 100644 --- a/backend/src/modules/schedule/schedule.service.ts +++ b/backend/src/modules/schedule/schedule.service.ts @@ -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, @InjectRepository(RepeatRuleCompletion) private readonly completions: Repository, + @InjectRepository(User) + private readonly users: Repository, 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> { + 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 { 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 { - 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 { - const result = await this.events.delete({ id: eventId, ownerId }); + async deleteEvent(userId: string, eventId: string): Promise { + 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 { + 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> { + 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 { - 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 { - 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 { - const result = await this.rules.delete({ id: ruleId, ownerId }); + async deleteRule(userId: string, ruleId: string): Promise { + 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> { + 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 { - 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 { + 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 { - 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 { - 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; } diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index 92ee5a8..9ae391d 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -34,6 +34,13 @@ export class User { @Column({ type: 'uuid', nullable: true }) photoFileId!: string | null; + /** + * 일정 연동 초대 코드 `RUP-XXXXXX` — 사람마다 하나. 처음 연동 화면을 열 때 만든다 + * (CalendarSharingService). 길이는 schedule/calendar-invite-code.ts 의 형식과 맞춘다. + */ + @Column({ type: 'varchar', length: 10, nullable: true, unique: true }) + calendarInviteCode!: string | null; + @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; diff --git a/backend/src/shared/utils/db-error.ts b/backend/src/shared/utils/db-error.ts index e8a0efb..4306db7 100644 --- a/backend/src/shared/utils/db-error.ts +++ b/backend/src/shared/utils/db-error.ts @@ -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( + run: () => Promise, + attempts: number, +): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + return await run(); + } catch (error) { + if (!isUniqueViolation(error) || attempt >= attempts) throw error; + } + } +} diff --git a/backend/src/shared/utils/invite-code.spec.ts b/backend/src/shared/utils/invite-code.spec.ts new file mode 100644 index 0000000..b16740d --- /dev/null +++ b/backend/src/shared/utils/invite-code.spec.ts @@ -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(); + }); +}); diff --git a/backend/src/shared/utils/invite-code.ts b/backend/src/shared/utils/invite-code.ts new file mode 100644 index 0000000..21f2c4d --- /dev/null +++ b/backend/src/shared/utils/invite-code.ts @@ -0,0 +1,41 @@ +import { randomInt } from 'crypto'; + +// 초대 코드 `RUP-XXXX…` — 부수효과 없는 순수 함수 (무작위 값만 crypto 에서 얻는다). +// 다이어리(6자)·일정 연동(4자)처럼 뒷부분 길이만 다르게 쓴다. + +/** 코드 앞머리 — 앱이 보여 주는 형식 */ +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; + }, + }; +} diff --git a/backend/test/calendar-sharing.e2e-spec.ts b/backend/test/calendar-sharing.e2e-spec.ts new file mode 100644 index 0000000..3daab8f --- /dev/null +++ b/backend/test/calendar-sharing.e2e-spec.ts @@ -0,0 +1,418 @@ +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 = (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; + const tokens: Record = {}; + const ids: Record = {}; + const codes: Record = {}; + 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 => { + 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 => + data( + 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 => + 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); + + 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 }, + ], + }); + for (const who of people) codes[who] = (await sharing(who)).inviteCode; + }); + + 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(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(rule).id; + }); + + it('B 는 코드를 미리 보고 연동한다 (대소문자 무관)', async () => { + const preview = await http() + .get(`/api/calendar-invitations/${codes.a.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', codes.a).expect(201); + expect(data(joined).linked).toBe(true); + expect(memberIds(data(joined))).toEqual([ids.b, ids.a]); + expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.b]); + + const after = await http() + .get(`/api/calendar-invitations/${codes.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(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', codes.a).expect(201); + expect(memberIds(data(joined))).toEqual([ids.c, ids.a, ids.b]); + }); + + it('이미 연동된 사람(SCHED_005)·내 코드(SCHED_003)는 거절한다', async () => { + for (const code of [codes.a, codes.c]) { + const again = await join('b', code).expect(409); + expect(errorCode(again)).toBe('SCHED_005'); + } + const own = await join('a', codes.a).expect(400); + expect(errorCode(own)).toBe('SCHED_003'); + }); + + it('4명이 차면 SCHED_002, 다른 사람과 연동 중이면 SCHED_004', async () => { + await join('d', codes.a).expect(201); + const full = await join('e', codes.b).expect(409); + expect(errorCode(full)).toBe('SCHED_002'); + + const busy = await join('b', codes.e).expect(409); + expect(errorCode(busy)).toBe('SCHED_004'); + + await leave('d'); + 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(mine); + expect((await eventsOf('a')).map((e) => e.id)).toContain(bEvent.id); + + await leave('b'); + + 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 + await leave('c'); + }); + + it('함께하던 사람이 탈퇴해 혼자 남으면 연동되지 않은 것으로 보고 정리한다', async () => { + await join('e', codes.d).expect(201); + await http().delete('/api/users/me').set(as('d')).expect(204); + + const e = await sharing('e'); + expect(e.linked).toBe(false); + expect(memberIds(e)).toEqual([ids.e]); + + const rows = await app + .get(DataSource) + .query< + { count: string }[] + >('SELECT count(*) FROM calendar_group_members WHERE user_id = $1', [ids.e]); + expect(rows[0].count).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'); + }); +}); diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 8b3eddd..be91a16 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -16,6 +16,11 @@ const Map 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': '내 명함은 받을 수 없어요.', diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 1ae54c1..abf0dd3 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -16,6 +16,11 @@ export const ERROR_CODE_MESSAGES = { 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: '내 명함은 받을 수 없어요.',