generated from ttipo/project-base
feat(backend): 일정 함께 관리 API — 초대 코드로 달력 연동
가족·친구가 한 달력을 함께 쓸 수 있게 한다. - calendar_groups · calendar_group_members (한 사람 한 그룹, 최대 4명), 회원마다 개인 초대 코드(RUP-6자) - 연동 상태·초대 미리보기·코드로 연동·연동 해제 — 해제하면 내 일정만 남고, 1명 남은 그룹은 지운다 - 일정·반복·완료는 그룹 전체가 보고 고칠 수 있다. 응답에 만든 사람·대상 요약을 담고, 남의 일정을 고칠 때 대상은 그 사람의 아이로 확인한다 - 연동·해제는 advisory lock 으로 줄 세워 인원 초과·중복 그룹을 막는다 - 파일 열람 정책 — 연동된 사람의 프로필·아이 사진 - 초대 코드 생성기·고유 충돌 재시도·대상 요약을 공통으로 옮겨 다이어리와 함께 쓴다 - 에러 코드 SCHED_001~005 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
/**
|
||||
* 다이어리 — 다이어리·기록·댓글, 그리고 함께 쓰기(초대 코드·참여·나가기).
|
||||
@@ -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<DiaryInvitationDto> {
|
||||
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<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 } });
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 = {
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,7 @@
|
||||
import { inviteCodeFormat } from '@/shared/utils/invite-code';
|
||||
|
||||
/**
|
||||
* 일정 연동 초대 코드 `RUP-XXXXXX` (뒷부분 6자) — users.calendar_invite_code 의 길이(10)와 맞춘다.
|
||||
* 코드만 알면 승인 없이 달력 전체를 함께 보고 고치게 되므로 다이어리와 같은 길이로 짐작하기 어렵게 둔다.
|
||||
*/
|
||||
export const CALENDAR_INVITE_CODE = inviteCodeFormat(6);
|
||||
@@ -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<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()
|
||||
@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<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()
|
||||
@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<CalendarSharingDto> {
|
||||
return this.sharing.join(user.userId, dto);
|
||||
}
|
||||
}
|
||||
@@ -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<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.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<CalendarInvitationDto> {
|
||||
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<CalendarSharingDto> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await manager.query('SELECT pg_advisory_xact_lock($1)', [
|
||||
CALENDAR_SHARING_LOCK_KEY,
|
||||
]);
|
||||
}
|
||||
|
||||
/** [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;
|
||||
}
|
||||
|
||||
/** 초대 코드의 주인 — 형식이 틀리거나 없는 코드는 SCHED_001 */
|
||||
private async findCodeOwner(rawCode: string): Promise<User> {
|
||||
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<User & { calendarInviteCode: string }> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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자)·일정 연동(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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 = <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 codes: 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);
|
||||
|
||||
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<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 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<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]);
|
||||
|
||||
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<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', codes.a).expect(201);
|
||||
expect(memberIds(data<SharingBody>(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<EventBody[]>(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');
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,11 @@ 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': '내 명함은 받을 수 없어요.',
|
||||
|
||||
@@ -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: '내 명함은 받을 수 없어요.',
|
||||
|
||||
Reference in New Issue
Block a user